phase 16: React dashboard with full platform control and analytics
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Shared API client targeting Query API, Symbol Registry, and Risk Engine.
|
||||
* Base URLs are configurable via env vars (VITE_*).
|
||||
*/
|
||||
|
||||
const QUERY_API_BASE = import.meta.env.VITE_QUERY_API_URL ?? '';
|
||||
const SYMBOL_REGISTRY_BASE = import.meta.env.VITE_SYMBOL_REGISTRY_URL ?? '';
|
||||
const RISK_ENGINE_BASE = import.meta.env.VITE_RISK_ENGINE_URL ?? '';
|
||||
|
||||
export type ApiBase = 'query' | 'registry' | 'risk';
|
||||
|
||||
function baseUrl(api: ApiBase): string {
|
||||
switch (api) {
|
||||
case 'query':
|
||||
return QUERY_API_BASE;
|
||||
case 'registry':
|
||||
return SYMBOL_REGISTRY_BASE;
|
||||
case 'risk':
|
||||
return RISK_ENGINE_BASE;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
body: unknown;
|
||||
|
||||
constructor(status: number, body: unknown) {
|
||||
super(`API error ${status}`);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
api: ApiBase,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const url = `${baseUrl(api)}${path}`;
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new ApiError(res.status, body);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Convenience GET */
|
||||
export function apiGet<T>(api: ApiBase, path: string): Promise<T> {
|
||||
return apiFetch<T>(api, path);
|
||||
}
|
||||
|
||||
/** Convenience POST */
|
||||
export function apiPost<T>(api: ApiBase, path: string, body: unknown): Promise<T> {
|
||||
return apiFetch<T>(api, path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience PUT */
|
||||
export function apiPut<T>(api: ApiBase, path: string, body: unknown): Promise<T> {
|
||||
return apiFetch<T>(api, path, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience DELETE */
|
||||
export function apiDelete<T>(api: ApiBase, path: string): Promise<T> {
|
||||
return apiFetch<T>(api, path, { method: 'DELETE' });
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Typed TanStack Query hooks for all API domains.
|
||||
* Requirements: 13.1, 13.2
|
||||
*/
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPut } from './client';
|
||||
import type { ApiBase } from './client';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useGet<T>(key: unknown[], api: ApiBase, path: string, enabled = true) {
|
||||
return useQuery<T>({ queryKey: key, queryFn: () => apiGet<T>(api, path), enabled });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Companies (Query API)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Company {
|
||||
id: string;
|
||||
ticker: string;
|
||||
legal_name: string;
|
||||
exchange: string | null;
|
||||
sector: string | null;
|
||||
industry: string | null;
|
||||
market_cap_bucket: string | null;
|
||||
active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
aliases?: Alias[];
|
||||
active_source_count?: number;
|
||||
}
|
||||
|
||||
export interface Alias {
|
||||
id: string;
|
||||
alias: string;
|
||||
alias_type: string;
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
id: string;
|
||||
source_type: string;
|
||||
source_name: string;
|
||||
config?: Record<string, unknown>;
|
||||
credibility_score: number;
|
||||
retention_days?: number;
|
||||
access_policy?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function useCompanies(params?: { active?: boolean; sector?: string; ticker?: string }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.active !== undefined) qs.set('active', String(params.active));
|
||||
if (params?.sector) qs.set('sector', params.sector);
|
||||
if (params?.ticker) qs.set('ticker', params.ticker);
|
||||
const path = `/api/companies${qs.toString() ? '?' + qs : ''}`;
|
||||
return useGet<Company[]>(['companies', params], 'query', path);
|
||||
}
|
||||
|
||||
export function useCompany(id: string | undefined) {
|
||||
return useGet<Company>(['company', id], 'query', `/api/companies/${id}`, !!id);
|
||||
}
|
||||
|
||||
export function useCompanySources(companyId: string | undefined) {
|
||||
return useGet<Source[]>(['company-sources', companyId], 'query', `/api/companies/${companyId}/sources`, !!companyId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Symbol Registry (CRUD)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useRegistryCompanies(active = true) {
|
||||
return useGet<Company[]>(['registry-companies', active], 'registry', `/companies?active=${active}`);
|
||||
}
|
||||
|
||||
export function useCreateCompany() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { ticker: string; legal_name: string; sector?: string; industry?: string; exchange?: string; market_cap_bucket?: string }) =>
|
||||
apiPost<Company>('registry', '/companies', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['companies'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSource(companyId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { source_type: string; source_name: string; config?: Record<string, unknown>; credibility_score?: number; retention_days?: number; access_policy?: string }) =>
|
||||
apiPost<Source>('registry', `/companies/${companyId}/sources`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['company-sources', companyId] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAlias(companyId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { alias: string; alias_type?: string }) =>
|
||||
apiPost<Alias>('registry', `/companies/${companyId}/aliases`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['company', companyId] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Watchlists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Watchlist {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function useWatchlists() {
|
||||
return useGet<Watchlist[]>(['watchlists'], 'registry', '/watchlists');
|
||||
}
|
||||
|
||||
export function useCreateWatchlist() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { name: string; description?: string }) =>
|
||||
apiPost<Watchlist>('registry', '/watchlists', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['watchlists'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWatchlistMembers(watchlistId: string | undefined) {
|
||||
return useGet<Company[]>(['watchlist-members', watchlistId], 'registry', `/watchlists/${watchlistId}/members`, !!watchlistId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Documents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
document_type: string;
|
||||
source_type: string;
|
||||
publisher: string | null;
|
||||
url: string | null;
|
||||
title: string | null;
|
||||
published_at: string | null;
|
||||
retrieved_at: string | null;
|
||||
language: string | null;
|
||||
content_hash: string | null;
|
||||
parse_quality_score: number | null;
|
||||
parse_confidence: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DocumentDetail extends Document {
|
||||
canonical_url: string | null;
|
||||
raw_storage_ref: string | null;
|
||||
normalized_storage_ref: string | null;
|
||||
company_mentions: Array<{ company_id: string; ticker: string; mention_type: string; confidence: number; legal_name: string }>;
|
||||
intelligence: DocumentIntelligence | null;
|
||||
}
|
||||
|
||||
export interface DocumentIntelligence {
|
||||
id: string;
|
||||
summary: string | null;
|
||||
macro_themes: string[] | null;
|
||||
novelty_score: number | null;
|
||||
source_credibility: number | null;
|
||||
extraction_warnings: string[] | null;
|
||||
confidence: number | null;
|
||||
model_provider: string | null;
|
||||
model_name: string | null;
|
||||
prompt_version: string | null;
|
||||
schema_version: string | null;
|
||||
validation_status: string | null;
|
||||
company_impacts?: CompanyImpact[];
|
||||
}
|
||||
|
||||
export interface CompanyImpact {
|
||||
company_id: string;
|
||||
ticker: string;
|
||||
legal_name: string;
|
||||
relevance: number;
|
||||
sentiment: string;
|
||||
impact_score: number;
|
||||
impact_horizon: string;
|
||||
catalyst_type: string;
|
||||
key_facts: string[] | null;
|
||||
risks: string[] | null;
|
||||
evidence_spans: string[] | null;
|
||||
}
|
||||
|
||||
export function useDocuments(params?: { ticker?: string; document_type?: string; status?: string; since?: string; limit?: number; offset?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.ticker) qs.set('ticker', params.ticker);
|
||||
if (params?.document_type) qs.set('document_type', params.document_type);
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.since) qs.set('since', params.since);
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.offset) qs.set('offset', String(params.offset));
|
||||
const path = `/api/documents${qs.toString() ? '?' + qs : ''}`;
|
||||
return useGet<Document[]>(['documents', params], 'query', path);
|
||||
}
|
||||
|
||||
export function useDocument(id: string | undefined) {
|
||||
return useGet<DocumentDetail>(['document', id], 'query', `/api/documents/${id}`, !!id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trends
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TrendSummary {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
window: string;
|
||||
trend_direction: string;
|
||||
trend_strength: number;
|
||||
confidence: number;
|
||||
top_supporting_evidence: string[] | null;
|
||||
top_opposing_evidence: string[] | null;
|
||||
dominant_catalysts: string[] | null;
|
||||
material_risks: string[] | null;
|
||||
contradiction_score: number;
|
||||
market_context: Record<string, unknown> | null;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export function useTrends(params?: { ticker?: string; window?: string; limit?: number; offset?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.ticker) qs.set('ticker', params.ticker);
|
||||
if (params?.window) qs.set('window', params.window);
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.offset) qs.set('offset', String(params.offset));
|
||||
const path = `/api/trends${qs.toString() ? '?' + qs : ''}`;
|
||||
return useGet<TrendSummary[]>(['trends', params], 'query', path);
|
||||
}
|
||||
|
||||
export function useTrend(id: string | undefined) {
|
||||
return useGet<TrendSummary>(['trend', id], 'query', `/api/trends/${id}`, !!id);
|
||||
}
|
||||
|
||||
export function useTrendEvidence(id: string | undefined) {
|
||||
return useGet<{ trend: TrendSummary; evidence: unknown[] }>(['trend-evidence', id], 'query', `/api/trends/${id}/evidence`, !!id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recommendations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
ticker: string;
|
||||
action: string;
|
||||
mode: string;
|
||||
confidence: number;
|
||||
time_horizon: string;
|
||||
thesis: string | null;
|
||||
invalidation_conditions: string[] | null;
|
||||
portfolio_pct: number | null;
|
||||
max_loss_pct: number | null;
|
||||
model_version: string | null;
|
||||
risk_classification: string | null;
|
||||
generated_at: string;
|
||||
}
|
||||
|
||||
export interface RecommendationDetail extends Recommendation {
|
||||
company_id: string | null;
|
||||
evidence: Array<{ id: string; document_id: string; intelligence_id: string; evidence_type: string; weight: number; title: string; document_type: string; source_type: string; publisher: string; url: string; published_at: string }>;
|
||||
risk_evaluation: { id: string; eligible: boolean; allowed_mode: string; rejection_reasons: string[] | null; risk_checks: Record<string, unknown> | null; evaluated_at: string } | null;
|
||||
}
|
||||
|
||||
export function useRecommendations(params?: { ticker?: string; action?: string; mode?: string; since?: string; limit?: number; offset?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.ticker) qs.set('ticker', params.ticker);
|
||||
if (params?.action) qs.set('action', params.action);
|
||||
if (params?.mode) qs.set('mode', params.mode);
|
||||
if (params?.since) qs.set('since', params.since);
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.offset) qs.set('offset', String(params.offset));
|
||||
const path = `/api/recommendations${qs.toString() ? '?' + qs : ''}`;
|
||||
return useGet<Recommendation[]>(['recommendations', params], 'query', path);
|
||||
}
|
||||
|
||||
export function useRecommendation(id: string | undefined) {
|
||||
return useGet<RecommendationDetail>(['recommendation', id], 'query', `/api/recommendations/${id}`, !!id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
recommendation_id: string | null;
|
||||
broker_account_id: string | null;
|
||||
ticker: string;
|
||||
side: string;
|
||||
order_type: string;
|
||||
quantity: number;
|
||||
limit_price: number | null;
|
||||
stop_price: number | null;
|
||||
status: string;
|
||||
broker_order_id: string | null;
|
||||
submitted_at: string | null;
|
||||
fill_price: number | null;
|
||||
fill_quantity: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface OrderDetail extends Order {
|
||||
idempotency_key: string | null;
|
||||
decision_trace: Record<string, unknown> | null;
|
||||
events: Array<{ id: string; event_type: string; data: unknown; broker_timestamp: string | null; created_at: string }>;
|
||||
audit_trail: unknown[];
|
||||
}
|
||||
|
||||
export function useOrders(params?: { ticker?: string; status?: string; side?: string; since?: string; limit?: number; offset?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.ticker) qs.set('ticker', params.ticker);
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.side) qs.set('side', params.side);
|
||||
if (params?.since) qs.set('since', params.since);
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
if (params?.offset) qs.set('offset', String(params.offset));
|
||||
const path = `/api/orders${qs.toString() ? '?' + qs : ''}`;
|
||||
return useGet<Order[]>(['orders', params], 'query', path);
|
||||
}
|
||||
|
||||
export function useOrder(id: string | undefined) {
|
||||
return useGet<OrderDetail>(['order', id], 'query', `/api/orders/${id}`, !!id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Positions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Position {
|
||||
id: string;
|
||||
broker_account_id: string | null;
|
||||
ticker: string;
|
||||
quantity: number;
|
||||
avg_entry_price: number;
|
||||
current_price: number | null;
|
||||
unrealized_pnl: number | null;
|
||||
realized_pnl: number | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function usePositions(ticker?: string) {
|
||||
const path = ticker ? `/api/positions?ticker=${ticker}` : '/api/positions';
|
||||
return useGet<Position[]>(['positions', ticker], 'query', path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin: Trading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TradingConfig {
|
||||
id?: string;
|
||||
name?: string;
|
||||
trading_mode: string;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Approval {
|
||||
id: string;
|
||||
order_job: unknown;
|
||||
recommendation_id: string | null;
|
||||
ticker: string;
|
||||
side: string;
|
||||
quantity: number;
|
||||
estimated_value: number | null;
|
||||
status: string;
|
||||
requested_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface Lockout {
|
||||
id: string;
|
||||
ticker: string;
|
||||
lockout_type: string;
|
||||
reason: string;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function useTradingConfig() {
|
||||
return useGet<TradingConfig>(['trading-config'], 'query', '/api/admin/trading/config');
|
||||
}
|
||||
|
||||
export function useSetTradingMode() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (mode: string) => apiPut<unknown>('query', `/api/admin/trading/mode?mode=${mode}`, {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['trading-config'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePendingApprovals() {
|
||||
return useGet<Approval[]>(['pending-approvals'], 'query', '/api/admin/trading/approvals');
|
||||
}
|
||||
|
||||
export function useReviewApproval() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, approved, review_note }: { id: string; approved: boolean; review_note?: string }) =>
|
||||
apiPut<unknown>('query', `/api/admin/trading/approvals/${id}?approved=${approved}&reviewed_by=operator&review_note=${encodeURIComponent(review_note ?? '')}`, {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['pending-approvals'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveLockouts() {
|
||||
return useGet<Lockout[]>(['lockouts'], 'query', '/api/admin/trading/lockouts');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin: Sources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SourceHealth {
|
||||
source_id: string;
|
||||
source_type: string;
|
||||
source_name: string;
|
||||
credibility_score: number;
|
||||
active: boolean;
|
||||
ticker: string;
|
||||
legal_name: string;
|
||||
company_id: string;
|
||||
last_run_status: string | null;
|
||||
last_run_at: string | null;
|
||||
last_error: string | null;
|
||||
total_runs_24h: number;
|
||||
failed_runs_24h: number;
|
||||
total_items_24h: number;
|
||||
}
|
||||
|
||||
export function useSourceHealth(params?: { source_type?: string; company_id?: string }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.source_type) qs.set('source_type', params.source_type);
|
||||
if (params?.company_id) qs.set('company_id', params.company_id);
|
||||
const path = `/api/admin/sources/health${qs.toString() ? '?' + qs : ''}`;
|
||||
return useGet<SourceHealth[]>(['source-health', params], 'query', path);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ops: Pipeline, Ingestion, Model, Coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function usePipelineHealth(hours = 24) {
|
||||
return useGet<Record<string, unknown>>(['pipeline-health', hours], 'query', `/api/ops/pipeline/health?hours=${hours}`);
|
||||
}
|
||||
|
||||
export function useIngestionSummary(hours = 24) {
|
||||
return useGet<Record<string, unknown>>(['ingestion-summary', hours], 'query', `/api/ops/ingestion/summary?hours=${hours}`);
|
||||
}
|
||||
|
||||
export function useIngestionThroughput(hours = 24, bucket = '1h') {
|
||||
return useGet<unknown[]>(['ingestion-throughput', hours, bucket], 'query', `/api/ops/ingestion/throughput?hours=${hours}&bucket=${bucket}`);
|
||||
}
|
||||
|
||||
export function useModelPerformance(hours = 24) {
|
||||
return useGet<Record<string, unknown>>(['model-performance', hours], 'query', `/api/ops/model/performance?hours=${hours}`);
|
||||
}
|
||||
|
||||
export function useModelFailures(hours = 24) {
|
||||
return useGet<unknown[]>(['model-failures', hours], 'query', `/api/ops/model/failures?hours=${hours}`);
|
||||
}
|
||||
|
||||
export function useCoverageGaps() {
|
||||
return useGet<{ missing_source_types: unknown[]; stale_sources: unknown[] }>(['coverage-gaps'], 'query', '/api/ops/sources/coverage-gaps');
|
||||
}
|
||||
|
||||
export function useSymbolCoverage() {
|
||||
return useGet<unknown[]>(['symbol-coverage'], 'query', '/api/admin/companies/coverage');
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,105 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link, useRouterState } from '@tanstack/react-router';
|
||||
import {
|
||||
Home,
|
||||
Building2,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Lightbulb,
|
||||
ShoppingCart,
|
||||
Wallet,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
Download,
|
||||
Cpu,
|
||||
Radar,
|
||||
Terminal,
|
||||
LayoutDashboard,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: '/', label: 'Home', icon: <Home size={18} /> },
|
||||
{ to: '/companies', label: 'Companies', icon: <Building2 size={18} />, group: 'Data' },
|
||||
{ to: '/watchlists', label: 'Watchlists', icon: <List size={18} />, group: 'Data' },
|
||||
{ to: '/documents', label: 'Documents', icon: <FileText size={18} />, group: 'Data' },
|
||||
{ to: '/trends', label: 'Trends', icon: <TrendingUp size={18} />, group: 'Intelligence' },
|
||||
{ to: '/recommendations', label: 'Recommendations', icon: <Lightbulb size={18} />, group: 'Intelligence' },
|
||||
{ to: '/orders', label: 'Orders', icon: <ShoppingCart size={18} />, group: 'Trading' },
|
||||
{ to: '/positions', label: 'Positions', icon: <Wallet size={18} />, group: 'Trading' },
|
||||
{ to: '/trading', label: 'Trading Controls', icon: <ShieldCheck size={18} />, group: 'Trading' },
|
||||
{ to: '/ops/pipeline', label: 'Pipeline', icon: <Activity size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/ingestion', label: 'Ingestion', icon: <Download size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/model', label: 'Model Perf', icon: <Cpu size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/coverage', label: 'Coverage', icon: <Radar size={18} />, group: 'Ops' },
|
||||
{ to: '/analytics/query', label: 'SQL Explorer', icon: <Terminal size={18} />, group: 'Analytics' },
|
||||
{ to: '/analytics/dashboards', label: 'Dashboards', icon: <LayoutDashboard size={18} />, group: 'Analytics' },
|
||||
];
|
||||
|
||||
export function AppLayout({ children }: { children: ReactNode }) {
|
||||
const routerState = useRouterState();
|
||||
const currentPath = routerState.location.pathname;
|
||||
|
||||
let lastGroup: string | undefined;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<nav
|
||||
className="w-56 shrink-0 bg-surface-900 border-r border-surface-700 flex flex-col"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<div className="px-4 py-4 border-b border-surface-700">
|
||||
<span className="text-lg font-bold tracking-tight text-brand-400">
|
||||
Stonks Oracle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
|
||||
{navItems.map((item) => {
|
||||
const showGroup = item.group && item.group !== lastGroup;
|
||||
lastGroup = item.group;
|
||||
const active =
|
||||
item.to === '/'
|
||||
? currentPath === '/'
|
||||
: currentPath.startsWith(item.to);
|
||||
|
||||
return (
|
||||
<div key={item.to}>
|
||||
{showGroup && (
|
||||
<div className="px-2 pt-4 pb-1 text-[11px] font-semibold uppercase tracking-wider text-gray-500">
|
||||
{item.group}
|
||||
</div>
|
||||
)}
|
||||
<Link
|
||||
to={item.to}
|
||||
className={`flex items-center gap-2.5 px-2.5 py-1.5 rounded-md text-sm transition-colors ${
|
||||
active
|
||||
? 'bg-brand-600/20 text-brand-300'
|
||||
: 'text-gray-400 hover:bg-surface-800 hover:text-gray-200'
|
||||
}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-y-auto bg-surface-950 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState, useMemo, type ReactNode } from 'react';
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (row: T) => ReactNode;
|
||||
sortable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
keyField: keyof T & string;
|
||||
pageSize?: number;
|
||||
onRowClick?: (row: T) => void;
|
||||
filterFn?: (row: T, query: string) => boolean;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function DataTable<T extends object>({
|
||||
data,
|
||||
columns,
|
||||
keyField,
|
||||
pageSize = 25,
|
||||
onRowClick,
|
||||
filterFn,
|
||||
emptyMessage = 'No data',
|
||||
}: Props<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter || !filterFn) return data;
|
||||
return data.filter((row) => filterFn(row, filter));
|
||||
}, [data, filter, filterFn]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey) return filtered;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = (a as Record<string, unknown>)[sortKey];
|
||||
const bv = (b as Record<string, unknown>)[sortKey];
|
||||
if (av == null && bv == null) return 0;
|
||||
if (av == null) return 1;
|
||||
if (bv == null) return -1;
|
||||
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
}, [filtered, sortKey, sortDir]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
|
||||
|
||||
function toggleSort(key: string) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filterFn && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter…"
|
||||
value={filter}
|
||||
onChange={(e) => { setFilter(e.target.value); setPage(0); }}
|
||||
className="mb-3 w-full max-w-xs rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
||||
aria-label="Filter table"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-lg border border-surface-700">
|
||||
<table className="w-full text-sm" role="grid">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 bg-surface-900">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3 py-2 text-left font-medium text-gray-400 ${col.className ?? ''} ${col.sortable !== false ? 'cursor-pointer select-none hover:text-gray-200' : ''}`}
|
||||
onClick={() => col.sortable !== false && toggleSort(col.key)}
|
||||
aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.header}
|
||||
{col.sortable !== false && (
|
||||
sortKey === col.key
|
||||
? (sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} />)
|
||||
: <ChevronsUpDown size={14} className="opacity-30" />
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-8 text-center text-gray-500">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paged.map((row) => (
|
||||
<tr
|
||||
key={String((row as Record<string, unknown>)[keyField])}
|
||||
className={`border-b border-surface-700/50 ${onRowClick ? 'cursor-pointer hover:bg-surface-800/50' : ''}`}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={`px-3 py-2 text-gray-300 ${col.className ?? ''}`}>
|
||||
{col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key] ?? '—')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{sorted.length} rows</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0} className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30" aria-label="Previous page">
|
||||
Prev
|
||||
</button>
|
||||
<span className="py-1">{page + 1} / {totalPages}</span>
|
||||
<button onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1} className="rounded px-2 py-1 hover:bg-surface-800 disabled:opacity-30" aria-label="Next page">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Shared reusable UI components: StatusBadge, ConfidenceBar, TrendArrow,
|
||||
* DateRangeSelector, TickerFilter, LoadingSpinner, ErrorBoundary.
|
||||
* Requirements: 13.1, 13.2
|
||||
*/
|
||||
import { Component, type ReactNode, type ChangeEvent } from 'react';
|
||||
import { TrendingUp, TrendingDown, Minus, Loader2 } from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatusBadge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
completed: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
success: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
valid: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
active: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
approved: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
filled: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
running: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
|
||||
pending: 'bg-yellow-900/40 text-yellow-400 border-yellow-700/50',
|
||||
failed: 'bg-red-900/40 text-red-400 border-red-700/50',
|
||||
rejected: 'bg-red-900/40 text-red-400 border-red-700/50',
|
||||
cancelled: 'bg-gray-800/40 text-gray-400 border-gray-700/50',
|
||||
disabled: 'bg-gray-800/40 text-gray-400 border-gray-700/50',
|
||||
paper: 'bg-purple-900/40 text-purple-400 border-purple-700/50',
|
||||
live: 'bg-orange-900/40 text-orange-400 border-orange-700/50',
|
||||
buy: 'bg-green-900/40 text-green-400 border-green-700/50',
|
||||
sell: 'bg-red-900/40 text-red-400 border-red-700/50',
|
||||
hold: 'bg-yellow-900/40 text-yellow-400 border-yellow-700/50',
|
||||
watch: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
|
||||
};
|
||||
|
||||
export function StatusBadge({ status }: { status: string | null | undefined }) {
|
||||
const s = (status ?? 'unknown').toLowerCase();
|
||||
const cls = statusColors[s] ?? 'bg-gray-800/40 text-gray-400 border-gray-700/50';
|
||||
return (
|
||||
<span className={`inline-block rounded-full border px-2 py-0.5 text-xs font-medium ${cls}`}>
|
||||
{s}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ConfidenceBar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ConfidenceBar({ value, className = '' }: { value: number | null | undefined; className?: string }) {
|
||||
const pct = Math.round((value ?? 0) * 100);
|
||||
const color = pct >= 70 ? 'bg-green-500' : pct >= 40 ? 'bg-yellow-500' : 'bg-red-500';
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`} role="meter" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label={`Confidence ${pct}%`}>
|
||||
<div className="h-1.5 w-20 rounded-full bg-surface-700">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TrendArrow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TrendArrow({ direction }: { direction: string | null | undefined }) {
|
||||
const d = (direction ?? 'neutral').toLowerCase();
|
||||
if (d === 'bullish') return <TrendingUp size={16} className="text-green-400" aria-label="Bullish" />;
|
||||
if (d === 'bearish') return <TrendingDown size={16} className="text-red-400" aria-label="Bearish" />;
|
||||
return <Minus size={16} className="text-gray-500" aria-label={d === 'mixed' ? 'Mixed' : 'Neutral'} />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DateRangeSelector
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DateRangeSelector({ value, onChange }: { value: number; onChange: (hours: number) => void }) {
|
||||
const options = [
|
||||
{ label: '1h', hours: 1 },
|
||||
{ label: '6h', hours: 6 },
|
||||
{ label: '24h', hours: 24 },
|
||||
{ label: '7d', hours: 168 },
|
||||
];
|
||||
return (
|
||||
<div className="inline-flex rounded-md border border-surface-700" role="group" aria-label="Time range">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.hours}
|
||||
onClick={() => onChange(opt.hours)}
|
||||
className={`px-3 py-1 text-xs font-medium transition-colors first:rounded-l-md last:rounded-r-md ${
|
||||
value === opt.hours ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'
|
||||
}`}
|
||||
aria-pressed={value === opt.hours}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TickerFilter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TickerFilter({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ticker…"
|
||||
value={value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value.toUpperCase())}
|
||||
className="w-24 rounded-md border border-surface-700 bg-surface-900 px-2 py-1 text-xs text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
||||
aria-label="Filter by ticker"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LoadingSpinner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function LoadingSpinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center py-12 ${className}`} role="status" aria-label="Loading">
|
||||
<Loader2 size={24} className="animate-spin text-brand-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ErrorBoundary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EBProps { children: ReactNode; fallback?: ReactNode }
|
||||
interface EBState { error: Error | null }
|
||||
|
||||
export class ErrorBoundary extends Component<EBProps, EBState> {
|
||||
state: EBState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return this.props.fallback ?? (
|
||||
<div className="rounded-lg border border-red-700/50 bg-red-900/20 p-4 text-sm text-red-400" role="alert">
|
||||
Something went wrong: {this.state.error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card — generic container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Card({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`rounded-lg border border-surface-700 bg-surface-900 p-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-brand-50: #eff6ff;
|
||||
--color-brand-100: #dbeafe;
|
||||
--color-brand-200: #bfdbfe;
|
||||
--color-brand-300: #93c5fd;
|
||||
--color-brand-400: #60a5fa;
|
||||
--color-brand-500: #3b82f6;
|
||||
--color-brand-600: #2563eb;
|
||||
--color-brand-700: #1d4ed8;
|
||||
--color-brand-800: #1e40af;
|
||||
--color-brand-900: #1e3a8a;
|
||||
|
||||
--color-surface-50: #f8fafc;
|
||||
--color-surface-100: #f1f5f9;
|
||||
--color-surface-700: #334155;
|
||||
--color-surface-800: #1e293b;
|
||||
--color-surface-850: #172033;
|
||||
--color-surface-900: #0f172a;
|
||||
--color-surface-950: #020617;
|
||||
|
||||
--color-success: #22c55e;
|
||||
--color-warning: #f59e0b;
|
||||
--color-danger: #ef4444;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import { router } from './routes';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useCompanies } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { StatusBadge, LoadingSpinner } from '../components/ui';
|
||||
import type { Company } from '../api/hooks';
|
||||
|
||||
const columns: Column<Company>[] = [
|
||||
{ key: 'ticker', header: 'Ticker', className: 'font-mono font-semibold text-brand-300' },
|
||||
{ key: 'legal_name', header: 'Name' },
|
||||
{ key: 'sector', header: 'Sector' },
|
||||
{
|
||||
key: 'active',
|
||||
header: 'Status',
|
||||
render: (r) => <StatusBadge status={r.active ? 'active' : 'disabled'} />,
|
||||
},
|
||||
{
|
||||
key: 'active_source_count',
|
||||
header: 'Sources',
|
||||
render: (r) => <span>{r.active_source_count ?? '—'}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
export function CompaniesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error } = useCompanies();
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (error) return <div className="text-red-400">Failed to load companies</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-4 text-xl font-semibold text-gray-100">Companies</h1>
|
||||
<DataTable<Company>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/companies/$id', params: { id: row.id } })}
|
||||
filterFn={(row, q) => {
|
||||
const lq = q.toLowerCase();
|
||||
return (
|
||||
row.ticker.toLowerCase().includes(lq) ||
|
||||
(row.legal_name ?? '').toLowerCase().includes(lq) ||
|
||||
(row.sector ?? '').toLowerCase().includes(lq)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useCompany, useCompanySources, useCreateAlias, useCreateSource } from '../api/hooks';
|
||||
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import type { Source } from '../api/hooks';
|
||||
import type { Alias } from '../api/hooks';
|
||||
|
||||
const sourceCols: Column<Source>[] = [
|
||||
{ key: 'source_type', header: 'Type' },
|
||||
{ key: 'source_name', header: 'Name' },
|
||||
{ key: 'credibility_score', header: 'Credibility', render: (r) => <span>{(r.credibility_score * 100).toFixed(0)}%</span> },
|
||||
{ key: 'active', header: 'Status', render: (r) => <StatusBadge status={r.active ? 'active' : 'disabled'} /> },
|
||||
];
|
||||
|
||||
export function CompanyDetailPage() {
|
||||
const { id } = useParams({ from: '/companies/$id' });
|
||||
const { data: company, isLoading } = useCompany(id);
|
||||
const { data: sources } = useCompanySources(id);
|
||||
const [tab, setTab] = useState<'aliases' | 'sources'>('sources');
|
||||
|
||||
if (isLoading || !company) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{company.ticker}</h1>
|
||||
<StatusBadge status={company.active ? 'active' : 'disabled'} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-gray-500">Name</dt><dd className="text-gray-200">{company.legal_name}</dd></div>
|
||||
<div><dt className="text-gray-500">Exchange</dt><dd className="text-gray-200">{company.exchange ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Sector</dt><dd className="text-gray-200">{company.sector ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Industry</dt><dd className="text-gray-200">{company.industry ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Market Cap</dt><dd className="text-gray-200">{company.market_cap_bucket ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Sources</dt><dd className="text-gray-200">{company.active_source_count ?? 0}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 border-b border-surface-700">
|
||||
{(['sources', 'aliases'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`pb-2 text-sm font-medium capitalize transition-colors ${tab === t ? 'border-b-2 border-brand-500 text-brand-300' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'sources' && (
|
||||
<div className="space-y-4">
|
||||
<DataTable<Source> data={sources ?? []} columns={sourceCols} keyField="id" />
|
||||
<AddSourceForm companyId={id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'aliases' && (
|
||||
<div className="space-y-4">
|
||||
<AliasesList aliases={company.aliases ?? []} />
|
||||
<AddAliasForm companyId={id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AliasesList({ aliases }: { aliases: Alias[] }) {
|
||||
if (aliases.length === 0) return <p className="text-sm text-gray-500">No aliases configured</p>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{aliases.map((a) => (
|
||||
<span key={a.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
|
||||
{a.alias} <span className="text-gray-500">({a.alias_type})</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddAliasForm({ companyId }: { companyId: string }) {
|
||||
const [alias, setAlias] = useState('');
|
||||
const mutation = useCreateAlias(companyId);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (alias.trim()) mutation.mutate({ alias: alias.trim() }, { onSuccess: () => setAlias('') });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add alias…"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
className="rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none"
|
||||
aria-label="New alias"
|
||||
/>
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSourceForm({ companyId }: { companyId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sourceType, setSourceType] = useState('market_api');
|
||||
const [sourceName, setSourceName] = useState('');
|
||||
const [credibility, setCredibility] = useState(0.5);
|
||||
const mutation = useCreateSource(companyId);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button onClick={() => setOpen(true)} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">
|
||||
Add Source
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate(
|
||||
{ source_type: sourceType, source_name: sourceName, credibility_score: credibility },
|
||||
{ onSuccess: () => { setOpen(false); setSourceName(''); } },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-type">Type</label>
|
||||
<select
|
||||
id="source-type"
|
||||
value={sourceType}
|
||||
onChange={(e) => setSourceType(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200"
|
||||
>
|
||||
{['market_api', 'news_api', 'filings_api', 'web_scrape', 'broker'].map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500" htmlFor="source-name">Name</label>
|
||||
<input
|
||||
id="source-name"
|
||||
type="text"
|
||||
value={sourceName}
|
||||
onChange={(e) => setSourceName(e.target.value)}
|
||||
className="w-full rounded-md border border-surface-700 bg-surface-900 px-2 py-1.5 text-sm text-gray-200 placeholder-gray-500"
|
||||
placeholder="Source name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500" htmlFor="credibility">Credibility: {(credibility * 100).toFixed(0)}%</label>
|
||||
<input
|
||||
id="credibility"
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={credibility}
|
||||
onChange={(e) => setCredibility(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useState } from 'react';
|
||||
import { useCompanies, useTrends, useRecommendations, usePositions } from '../api/hooks';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiPost } from '../api/client';
|
||||
import { TrendArrow, StatusBadge, ConfidenceBar, LoadingSpinner, DateRangeSelector, TickerFilter, Card } from '../components/ui';
|
||||
import {
|
||||
LineChart, Line, BarChart, Bar, ScatterChart, Scatter,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
||||
} from 'recharts';
|
||||
|
||||
type DashboardId = 'gallery' | 'symbol-overview' | 'sentiment-heatmap' | 'prediction-accuracy' | 'paper-pnl' | 'model-quality';
|
||||
|
||||
const dashboards: Array<{ id: DashboardId; title: string; description: string }> = [
|
||||
{ id: 'symbol-overview', title: 'Symbol Overview', description: 'Company cards with trend direction, latest recommendation, position status' },
|
||||
{ id: 'sentiment-heatmap', title: 'Sentiment Heatmap', description: 'Sector × time matrix colored by aggregated sentiment' },
|
||||
{ id: 'prediction-accuracy', title: 'Prediction Accuracy', description: 'Predicted confidence vs realized price move' },
|
||||
{ id: 'paper-pnl', title: 'Paper Trading PnL', description: 'Equity curve, daily PnL bars, win rate metrics' },
|
||||
{ id: 'model-quality', title: 'Model Quality', description: 'Extraction success rate, latency distribution, retry rate' },
|
||||
];
|
||||
|
||||
export function DashboardsPage() {
|
||||
const [active, setActive] = useState<DashboardId>('gallery');
|
||||
const [hours, setHours] = useState(168);
|
||||
const [ticker, setTicker] = useState('');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Dashboards</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
<DateRangeSelector value={hours} onChange={setHours} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery / nav */}
|
||||
{active === 'gallery' ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{dashboards.map((d) => (
|
||||
<Card key={d.id} className="cursor-pointer transition-colors hover:border-brand-500/50">
|
||||
<button className="w-full text-left" onClick={() => setActive(d.id)}>
|
||||
<div className="font-medium text-gray-200">{d.title}</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{d.description}</div>
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<button onClick={() => setActive('gallery')} className="mb-3 text-sm text-brand-400 hover:underline">
|
||||
← Back to gallery
|
||||
</button>
|
||||
{active === 'symbol-overview' && <SymbolOverview ticker={ticker} />}
|
||||
{active === 'sentiment-heatmap' && <SentimentHeatmap hours={hours} />}
|
||||
{active === 'prediction-accuracy' && <PredictionAccuracy hours={hours} />}
|
||||
{active === 'paper-pnl' && <PaperPnl hours={hours} />}
|
||||
{active === 'model-quality' && <ModelQuality hours={hours} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Symbol Overview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SymbolOverview({ ticker }: { ticker: string }) {
|
||||
const { data: companies, isLoading: cLoading } = useCompanies({ ticker: ticker || undefined });
|
||||
const { data: trends } = useTrends({ ticker: ticker || undefined, window: '7d', limit: 100 });
|
||||
const { data: recs } = useRecommendations({ ticker: ticker || undefined, limit: 100 });
|
||||
const { data: positions } = usePositions(ticker || undefined);
|
||||
|
||||
if (cLoading) return <LoadingSpinner />;
|
||||
|
||||
const trendMap = new Map((trends ?? []).map((t) => [t.entity_id, t]));
|
||||
const recMap = new Map((recs ?? []).map((r) => [r.ticker, r]));
|
||||
const posMap = new Map((positions ?? []).map((p) => [p.ticker, p]));
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(companies ?? []).map((c) => {
|
||||
const trend = trendMap.get(c.ticker);
|
||||
const rec = recMap.get(c.ticker);
|
||||
const pos = posMap.get(c.ticker);
|
||||
return (
|
||||
<Card key={c.id}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono font-semibold text-brand-300">{c.ticker}</span>
|
||||
{trend && <TrendArrow direction={trend.trend_direction} />}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">{c.legal_name}</div>
|
||||
{trend && (
|
||||
<div className="mt-2 flex items-center gap-3 text-xs">
|
||||
<span className="text-gray-500">Strength</span>
|
||||
<ConfidenceBar value={trend.trend_strength} />
|
||||
</div>
|
||||
)}
|
||||
{rec && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<StatusBadge status={rec.action} />
|
||||
<ConfidenceBar value={rec.confidence} />
|
||||
</div>
|
||||
)}
|
||||
{pos && (
|
||||
<div className="mt-2 text-xs">
|
||||
<span className="text-gray-500">Position: </span>
|
||||
<span className="text-gray-300">{pos.quantity} @ ${pos.avg_entry_price.toFixed(2)}</span>
|
||||
{pos.unrealized_pnl != null && (
|
||||
<span className={pos.unrealized_pnl >= 0 ? 'ml-2 text-green-400' : 'ml-2 text-red-400'}>
|
||||
{pos.unrealized_pnl >= 0 ? '+' : ''}{pos.unrealized_pnl.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trino-backed dashboards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useTrinoQuery(sql: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['trino-dashboard', sql],
|
||||
queryFn: () => apiPost<{ columns: Array<{ name: string }>; rows: unknown[][] }>('query', '/api/analytics/query', { sql, limit: 5000 }),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
function SentimentHeatmap({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT entity_id AS ticker, window, trend_direction, trend_strength, confidence, generated_at FROM trend_windows WHERE entity_type = 'company' AND generated_at >= current_timestamp - interval '${days}' day ORDER BY generated_at DESC LIMIT 500`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No sentiment data available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
ticker: String(r[0]),
|
||||
window: String(r[1]),
|
||||
strength: Number(r[3]) || 0,
|
||||
direction: String(r[2]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Sentiment by Symbol</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="ticker" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="strength" fill="#3b82f6" name="Trend Strength" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PredictionAccuracy({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT predicted_confidence, realized_move_pct, ticker, prediction_date FROM prediction_vs_outcome WHERE prediction_date >= current_date - interval '${days}' day ORDER BY prediction_date DESC LIMIT 1000`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No prediction data available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
confidence: Number(r[0]) || 0,
|
||||
realized: Number(r[1]) || 0,
|
||||
ticker: String(r[2]),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Predicted Confidence vs Realized Move</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="confidence" name="Confidence" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis dataKey="realized" name="Realized %" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Scatter data={chartData} fill="#3b82f6" />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PaperPnl({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT dt, daily_pnl, cumulative_pnl, win_count, loss_count FROM pnl_daily WHERE dt >= current_date - interval '${days}' day ORDER BY dt ASC LIMIT 365`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No PnL data available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
date: String(r[0]),
|
||||
daily: Number(r[1]) || 0,
|
||||
cumulative: Number(r[2]) || 0,
|
||||
wins: Number(r[3]) || 0,
|
||||
losses: Number(r[4]) || 0,
|
||||
}));
|
||||
|
||||
const totalWins = chartData.reduce((s, d) => s + d.wins, 0);
|
||||
const totalLosses = chartData.reduce((s, d) => s + d.losses, 0);
|
||||
const winRate = totalWins + totalLosses > 0 ? ((totalWins / (totalWins + totalLosses)) * 100).toFixed(1) : '—';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-gray-100">{winRate}%</div>
|
||||
<div className="text-xs text-gray-500">Win Rate</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-green-400">{totalWins}</div>
|
||||
<div className="text-xs text-gray-500">Wins</div>
|
||||
</Card>
|
||||
<Card className="text-center">
|
||||
<div className="text-xl font-bold text-red-400">{totalLosses}</div>
|
||||
<div className="text-xs text-gray-500">Losses</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Equity Curve</h2>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Line type="monotone" dataKey="cumulative" stroke="#3b82f6" dot={false} name="Cumulative PnL" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Daily PnL</h2>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="daily" name="Daily PnL" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelQuality({ hours }: { hours: number }) {
|
||||
const days = Math.ceil(hours / 24);
|
||||
const { data, isLoading } = useTrinoQuery(
|
||||
`SELECT date_trunc('hour', recorded_at) AS hour, count(*) AS total, count(*) filter (where success = true) AS successes, avg(total_duration_ms) AS avg_latency, avg(retry_count) AS avg_retries FROM model_performance_metrics WHERE recorded_at >= current_timestamp - interval '${days}' day GROUP BY 1 ORDER BY 1 ASC LIMIT 500`
|
||||
);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!data?.rows.length) return <p className="text-sm text-gray-500">No model metrics available</p>;
|
||||
|
||||
const chartData = data.rows.map((r) => ({
|
||||
hour: String(r[0]).slice(11, 16),
|
||||
total: Number(r[1]) || 0,
|
||||
successes: Number(r[2]) || 0,
|
||||
rate: Number(r[1]) > 0 ? ((Number(r[2]) / Number(r[1])) * 100) : 0,
|
||||
latency: Math.round(Number(r[3]) || 0),
|
||||
retries: Number(r[4]) || 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Success Rate Over Time</h2>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} domain={[0, 100]} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Line type="monotone" dataKey="rate" stroke="#22c55e" dot={false} name="Success %" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Latency & Retries</h2>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Legend />
|
||||
<Bar dataKey="latency" fill="#3b82f6" name="Avg Latency (ms)" />
|
||||
<Bar dataKey="retries" fill="#f59e0b" name="Avg Retries" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useDocument } from '../api/hooks';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function DocumentDetailPage() {
|
||||
const { id } = useParams({ from: '/documents/$id' });
|
||||
const { data: doc, isLoading } = useDocument(id);
|
||||
|
||||
if (isLoading || !doc) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-100">{doc.title ?? 'Untitled Document'}</h1>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-gray-500">
|
||||
<StatusBadge status={doc.status} />
|
||||
<span>{doc.document_type}</span>
|
||||
<span>{doc.source_type}</span>
|
||||
{doc.publisher && <span>{doc.publisher}</span>}
|
||||
{doc.published_at && <span>{new Date(doc.published_at).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Metadata</h2>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-3">
|
||||
<div><dt className="text-gray-500">URL</dt><dd className="truncate text-gray-300">{doc.url ? <a href={doc.url} target="_blank" rel="noopener noreferrer" className="text-brand-400 hover:underline">{doc.url}</a> : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Language</dt><dd className="text-gray-300">{doc.language ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Content Hash</dt><dd className="truncate font-mono text-xs text-gray-400">{doc.content_hash ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Parse Quality</dt><dd className="text-gray-300">{doc.parse_quality_score?.toFixed(2) ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Parse Confidence</dt><dd><StatusBadge status={doc.parse_confidence ?? 'unknown'} /></dd></div>
|
||||
<div><dt className="text-gray-500">Retrieved</dt><dd className="text-gray-300">{doc.retrieved_at ? new Date(doc.retrieved_at).toLocaleString() : '—'}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Company Mentions */}
|
||||
{doc.company_mentions.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Company Mentions</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{doc.company_mentions.map((m, i) => (
|
||||
<span key={i} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
|
||||
<span className="font-mono font-semibold text-brand-300">{m.ticker}</span> {m.legal_name}
|
||||
<span className="ml-1 text-gray-500">({m.mention_type}, {(m.confidence * 100).toFixed(0)}%)</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Intelligence Extraction */}
|
||||
{doc.intelligence ? (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Intelligence Extraction</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-gray-500">Summary</div>
|
||||
<p className="text-sm text-gray-200">{doc.intelligence.summary ?? '—'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Confidence</div>
|
||||
<ConfidenceBar value={doc.intelligence.confidence} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Validation</div>
|
||||
<StatusBadge status={doc.intelligence.validation_status} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-500">Model</div>
|
||||
<span className="text-xs text-gray-400">{doc.intelligence.model_name ?? '—'} ({doc.intelligence.prompt_version})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.intelligence.macro_themes && doc.intelligence.macro_themes.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-gray-500">Macro Themes</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{doc.intelligence.macro_themes.map((t, i) => (
|
||||
<span key={i} className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-300">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{doc.intelligence.extraction_warnings && doc.intelligence.extraction_warnings.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-gray-500">Warnings</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{doc.intelligence.extraction_warnings.map((w, i) => (
|
||||
<span key={i} className="rounded bg-yellow-900/30 px-2 py-0.5 text-xs text-yellow-400">{w}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company Impacts */}
|
||||
{doc.intelligence.company_impacts && doc.intelligence.company_impacts.length > 0 && (
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-gray-500">Company Impacts</div>
|
||||
<div className="space-y-3">
|
||||
{doc.intelligence.company_impacts.map((imp, i) => (
|
||||
<div key={i} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-brand-300">{imp.ticker}</span>
|
||||
<StatusBadge status={imp.sentiment} />
|
||||
<ConfidenceBar value={imp.impact_score} />
|
||||
<span className="text-xs text-gray-500">{imp.catalyst_type}</span>
|
||||
<span className="text-xs text-gray-500">{imp.impact_horizon}</span>
|
||||
</div>
|
||||
{imp.key_facts && imp.key_facts.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-500">Key Facts</div>
|
||||
<ul className="ml-4 list-disc text-xs text-gray-300">
|
||||
{imp.key_facts.map((f, j) => <li key={j}>{f}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{imp.risks && imp.risks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-500">Risks</div>
|
||||
<ul className="ml-4 list-disc text-xs text-red-400">
|
||||
{imp.risks.map((r, j) => <li key={j}>{r}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500">No intelligence extraction available</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Storage References */}
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Storage References</h2>
|
||||
<dl className="space-y-1 text-sm">
|
||||
<div><dt className="inline text-gray-500">Raw: </dt><dd className="inline truncate font-mono text-xs text-gray-400">{doc.raw_storage_ref ?? '—'}</dd></div>
|
||||
<div><dt className="inline text-gray-500">Normalized: </dt><dd className="inline truncate font-mono text-xs text-gray-400">{doc.normalized_storage_ref ?? '—'}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useDocuments } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { StatusBadge, LoadingSpinner, TickerFilter } from '../components/ui';
|
||||
import type { Document } from '../api/hooks';
|
||||
|
||||
export function DocumentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [ticker, setTicker] = useState('');
|
||||
const { data, isLoading, error } = useDocuments({ ticker: ticker || undefined, limit: 100 });
|
||||
|
||||
const columns: Column<Document>[] = [
|
||||
{ key: 'title', header: 'Title', render: (r) => <span className="line-clamp-1 max-w-xs">{r.title ?? '—'}</span> },
|
||||
{ key: 'document_type', header: 'Type' },
|
||||
{ key: 'source_type', header: 'Source' },
|
||||
{ key: 'published_at', header: 'Published', render: (r) => <span className="text-xs">{r.published_at ? new Date(r.published_at).toLocaleDateString() : '—'}</span> },
|
||||
{ key: 'parse_confidence', header: 'Parse Quality', render: (r) => <StatusBadge status={r.parse_confidence ?? 'unknown'} /> },
|
||||
{ key: 'status', header: 'Status', render: (r) => <StatusBadge status={r.status} /> },
|
||||
];
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (error) return <div className="text-red-400">Failed to load documents</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Documents</h1>
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
</div>
|
||||
<DataTable<Document>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/documents/$id', params: { id: row.id } })}
|
||||
filterFn={(row, q) => {
|
||||
const lq = q.toLowerCase();
|
||||
return (row.title ?? '').toLowerCase().includes(lq) || row.document_type.toLowerCase().includes(lq);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { usePipelineHealth, useIngestionSummary, useRecommendations, useCompanies, useCoverageGaps } from '../api/hooks';
|
||||
import { LoadingSpinner, Card } from '../components/ui';
|
||||
import {
|
||||
Building2, FileText, TrendingUp, Lightbulb, ShoppingCart,
|
||||
Wallet, ShieldCheck, Activity, Download, Cpu, Radar,
|
||||
Terminal, LayoutDashboard, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const quickNav = [
|
||||
{ to: '/companies', label: 'Companies', icon: <Building2 size={20} />, color: 'text-blue-400' },
|
||||
{ to: '/documents', label: 'Documents', icon: <FileText size={20} />, color: 'text-green-400' },
|
||||
{ to: '/trends', label: 'Trends', icon: <TrendingUp size={20} />, color: 'text-purple-400' },
|
||||
{ to: '/recommendations', label: 'Recommendations', icon: <Lightbulb size={20} />, color: 'text-yellow-400' },
|
||||
{ to: '/orders', label: 'Orders', icon: <ShoppingCart size={20} />, color: 'text-orange-400' },
|
||||
{ to: '/positions', label: 'Positions', icon: <Wallet size={20} />, color: 'text-cyan-400' },
|
||||
{ to: '/trading', label: 'Trading', icon: <ShieldCheck size={20} />, color: 'text-red-400' },
|
||||
{ to: '/ops/pipeline', label: 'Pipeline', icon: <Activity size={20} />, color: 'text-emerald-400' },
|
||||
{ to: '/ops/ingestion', label: 'Ingestion', icon: <Download size={20} />, color: 'text-teal-400' },
|
||||
{ to: '/ops/model', label: 'Model Perf', icon: <Cpu size={20} />, color: 'text-indigo-400' },
|
||||
{ to: '/ops/coverage', label: 'Coverage', icon: <Radar size={20} />, color: 'text-pink-400' },
|
||||
{ to: '/analytics/query', label: 'SQL Explorer', icon: <Terminal size={20} />, color: 'text-amber-400' },
|
||||
{ to: '/analytics/dashboards', label: 'Dashboards', icon: <LayoutDashboard size={20} />, color: 'text-violet-400' },
|
||||
];
|
||||
|
||||
export function HomePage() {
|
||||
const { data: pipeline, isLoading: pLoading } = usePipelineHealth(24);
|
||||
const { data: ingestion } = useIngestionSummary(24);
|
||||
const { data: recs } = useRecommendations({ limit: 5 });
|
||||
const { data: companies } = useCompanies();
|
||||
const { data: gaps } = useCoverageGaps();
|
||||
|
||||
if (pLoading) return <LoadingSpinner />;
|
||||
|
||||
const ing = (ingestion ?? {}) as Record<string, unknown>;
|
||||
const staleCount = (gaps?.stale_sources?.length ?? 0) + (gaps?.missing_source_types?.length ?? 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-100">Stonks Oracle</h1>
|
||||
|
||||
{/* Alert banner */}
|
||||
{staleCount > 0 && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-yellow-700/50 bg-yellow-900/20 p-3" role="alert">
|
||||
<AlertTriangle size={18} className="text-yellow-400" />
|
||||
<span className="text-sm text-yellow-300">
|
||||
{staleCount} coverage issue{staleCount > 1 ? 's' : ''} detected — check{' '}
|
||||
<Link to="/ops/coverage" className="underline hover:text-yellow-200">Source Coverage</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key metrics */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard label="Active Companies" value={companies?.length ?? 0} />
|
||||
<MetricCard label="Ingestion Runs (24h)" value={ing.total_runs} />
|
||||
<MetricCard label="Items Fetched (24h)" value={ing.total_items_fetched} />
|
||||
<MetricCard label="Recommendations Today" value={recs?.length ?? 0} />
|
||||
</div>
|
||||
|
||||
{/* Pipeline status */}
|
||||
{pipeline && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Pipeline Status (24h)</h2>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
{((pipeline.document_stages ?? []) as Array<{ status: string; doc_count: number }>).map((s) => (
|
||||
<div key={s.status} className="text-center">
|
||||
<div className="text-lg font-bold text-gray-100">{s.doc_count}</div>
|
||||
<div className="text-xs capitalize text-gray-500">{s.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent activity */}
|
||||
{recs && recs.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Recent Recommendations</h2>
|
||||
<div className="space-y-2">
|
||||
{recs.map((r) => (
|
||||
<Link key={r.id} to="/recommendations/$id" params={{ id: r.id }} className="flex items-center justify-between rounded border border-surface-700 bg-surface-950 p-2 hover:bg-surface-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold text-brand-300">{r.ticker}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${r.action === 'buy' ? 'bg-green-900/40 text-green-400' : r.action === 'sell' ? 'bg-red-900/40 text-red-400' : 'bg-gray-800/40 text-gray-400'}`}>
|
||||
{r.action}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 line-clamp-1 max-w-xs">{r.thesis}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{new Date(r.generated_at).toLocaleString()}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick nav */}
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 lg:grid-cols-5">
|
||||
{quickNav.map((item) => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-surface-700 bg-surface-900 p-3 text-center transition-colors hover:border-brand-500/50 hover:bg-surface-800"
|
||||
>
|
||||
<span className={item.color}>{item.icon}</span>
|
||||
<span className="text-xs text-gray-400">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: unknown }) {
|
||||
return (
|
||||
<Card className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-100">{value != null ? String(value) : '—'}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCoverageGaps, useSymbolCoverage } from '../api/hooks';
|
||||
import { LoadingSpinner, StatusBadge, Card } from '../components/ui';
|
||||
|
||||
export function OpsCoveragePage() {
|
||||
const { data: gaps, isLoading: gapsLoading } = useCoverageGaps();
|
||||
const { data: coverage, isLoading: covLoading } = useSymbolCoverage();
|
||||
|
||||
if (gapsLoading || covLoading) return <LoadingSpinner />;
|
||||
|
||||
const missing = (gaps?.missing_source_types ?? []) as Array<Record<string, unknown>>;
|
||||
const stale = (gaps?.stale_sources ?? []) as Array<Record<string, unknown>>;
|
||||
const matrix = (coverage ?? []) as Array<Record<string, unknown>>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Source Coverage</h1>
|
||||
|
||||
{/* Coverage Matrix */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Company × Source Type Matrix</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 text-left text-gray-500">
|
||||
<th className="px-3 py-2">Ticker</th>
|
||||
<th className="px-3 py-2">Name</th>
|
||||
<th className="px-3 py-2 text-center">Market</th>
|
||||
<th className="px-3 py-2 text-center">News</th>
|
||||
<th className="px-3 py-2 text-center">Filings</th>
|
||||
<th className="px-3 py-2 text-center">Web</th>
|
||||
<th className="px-3 py-2 text-center">Broker</th>
|
||||
<th className="px-3 py-2 text-center">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrix.map((row, i) => (
|
||||
<tr key={i} className="border-b border-surface-700/50">
|
||||
<td className="px-3 py-2 font-mono font-semibold text-brand-300">{row.ticker as string}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{row.legal_name as string}</td>
|
||||
<CoverageCell count={row.market_sources as number} />
|
||||
<CoverageCell count={row.news_sources as number} />
|
||||
<CoverageCell count={row.filings_sources as number} />
|
||||
<CoverageCell count={row.web_scrape_sources as number} />
|
||||
<CoverageCell count={row.broker_sources as number} />
|
||||
<td className="px-3 py-2 text-center text-gray-300">{String(row.active_sources)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Missing Source Types */}
|
||||
{missing.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Missing Source Types ({missing.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{missing.map((m, i) => {
|
||||
const activeTypes = (m.active_types as string[]) ?? [];
|
||||
const expected = (m.expected_types as string[]) ?? [];
|
||||
const missingTypes = expected.filter((t) => !activeTypes.includes(t));
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 rounded border border-yellow-700/30 bg-yellow-900/10 p-2">
|
||||
<span className="font-mono font-semibold text-brand-300">{m.ticker as string}</span>
|
||||
<span className="text-xs text-gray-500">missing:</span>
|
||||
{missingTypes.map((t) => (
|
||||
<StatusBadge key={t} status={t} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Stale Sources */}
|
||||
{stale.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Stale Sources ({stale.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{stale.map((s, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded border border-red-700/30 bg-red-900/10 p-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-brand-300">{s.ticker as string}</span>
|
||||
<StatusBadge status={s.source_type as string} />
|
||||
<span className="text-xs text-gray-400">{s.source_name as string}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Last success: {s.last_success ? new Date(s.last_success as string).toLocaleString() : 'never'}
|
||||
{s.recent_failures ? ` | ${s.recent_failures} failures (24h)` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverageCell({ count }: { count: number }) {
|
||||
const color = count > 0 ? 'text-green-400' : 'text-red-400';
|
||||
return <td className={`px-3 py-2 text-center font-mono ${color}`}>{count}</td>;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import { useIngestionThroughput, useIngestionSummary } from '../api/hooks';
|
||||
import { LoadingSpinner, DateRangeSelector, Card } from '../components/ui';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
|
||||
export function OpsIngestionPage() {
|
||||
const [hours, setHours] = useState(24);
|
||||
const [bucket, setBucket] = useState('1h');
|
||||
const { data: throughput, isLoading: tpLoading } = useIngestionThroughput(hours, bucket);
|
||||
const { data: summary } = useIngestionSummary(hours);
|
||||
|
||||
if (tpLoading) return <LoadingSpinner />;
|
||||
|
||||
const chartData = (throughput ?? []).map((row: unknown) => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return {
|
||||
time: r.bucket_start ? new Date(r.bucket_start as string).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
|
||||
completed: Number(r.completed ?? 0),
|
||||
failed: Number(r.failed ?? 0),
|
||||
items: Number(r.items_fetched ?? 0),
|
||||
};
|
||||
});
|
||||
|
||||
const s = (summary ?? {}) as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Ingestion Monitor</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<DateRangeSelector value={hours} onChange={setHours} />
|
||||
<div className="inline-flex rounded-md border border-surface-700" role="group">
|
||||
{['15m', '1h', '6h', '1d'].map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setBucket(b)}
|
||||
className={`px-2 py-1 text-xs font-medium first:rounded-l-md last:rounded-r-md ${bucket === b ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
{b}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<StatCard label="Total Runs" value={s.total_runs} />
|
||||
<StatCard label="Completed" value={s.completed} color="text-green-400" />
|
||||
<StatCard label="Failed" value={s.failed} color="text-red-400" />
|
||||
<StatCard label="Items Fetched" value={s.total_items_fetched} />
|
||||
<StatCard label="New Items" value={s.total_items_new} />
|
||||
</div>
|
||||
|
||||
{/* Throughput chart */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Throughput</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis dataKey="time" tick={{ fill: '#6b7280', fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 11 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} labelStyle={{ color: '#9ca3af' }} />
|
||||
<Legend />
|
||||
<Bar dataKey="completed" fill="#22c55e" name="Completed" />
|
||||
<Bar dataKey="failed" fill="#ef4444" name="Failed" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
|
||||
{/* By source type */}
|
||||
{s.by_source_type && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">By Source Type</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 text-left text-gray-500">
|
||||
<th className="px-3 py-2">Type</th>
|
||||
<th className="px-3 py-2">Runs</th>
|
||||
<th className="px-3 py-2">Completed</th>
|
||||
<th className="px-3 py-2">Failed</th>
|
||||
<th className="px-3 py-2">Items</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s.by_source_type as Array<Record<string, unknown>>).map((row, i) => (
|
||||
<tr key={i} className="border-b border-surface-700/50">
|
||||
<td className="px-3 py-2 text-gray-300">{row.source_type as string}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{String(row.runs)}</td>
|
||||
<td className="px-3 py-2 text-green-400">{String(row.completed)}</td>
|
||||
<td className="px-3 py-2 text-red-400">{String(row.failed)}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{String(row.items_fetched)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color = 'text-gray-100' }: { label: string; value: unknown; color?: string }) {
|
||||
return (
|
||||
<Card className="text-center">
|
||||
<div className={`text-xl font-bold ${color}`}>{value != null ? String(value) : '—'}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useModelPerformance, useModelFailures } from '../api/hooks';
|
||||
import { LoadingSpinner, DateRangeSelector, StatusBadge, Card } from '../components/ui';
|
||||
|
||||
export function OpsModelPage() {
|
||||
const [hours, setHours] = useState(24);
|
||||
const { data: perf, isLoading } = useModelPerformance(hours);
|
||||
const { data: failures } = useModelFailures(hours);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
const p = (perf ?? {}) as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Model Performance</h1>
|
||||
<DateRangeSelector value={hours} onChange={setHours} />
|
||||
</div>
|
||||
|
||||
{/* Key metrics */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<StatCard label="Total Extractions" value={p.total_extractions} />
|
||||
<StatCard label="Success Rate" value={p.success_rate != null ? `${((p.success_rate as number) * 100).toFixed(1)}%` : '—'} color="text-green-400" />
|
||||
<StatCard label="Avg Latency" value={p.avg_duration_ms != null ? `${Math.round(p.avg_duration_ms as number)}ms` : '—'} />
|
||||
<StatCard label="Retry Rate" value={p.retry_rate != null ? `${((p.retry_rate as number) * 100).toFixed(1)}%` : '—'} color="text-yellow-400" />
|
||||
<StatCard label="Avg Confidence" value={p.avg_confidence != null ? ((p.avg_confidence as number) * 100).toFixed(0) + '%' : '—'} />
|
||||
</div>
|
||||
|
||||
{/* Recent Failures */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">
|
||||
Recent Failures ({(failures as unknown[])?.length ?? 0})
|
||||
</h2>
|
||||
{!(failures as unknown[])?.length ? (
|
||||
<p className="text-sm text-gray-500">No recent failures</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(failures as Array<Record<string, unknown>>).map((f, i) => (
|
||||
<div key={i} className="rounded border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-brand-300">{f.ticker as string}</span>
|
||||
<StatusBadge status="failed" />
|
||||
<span className="text-xs text-gray-500">{f.model_name as string}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{f.recorded_at ? new Date(f.recorded_at as string).toLocaleString() : ''}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
{f.document_title as string} ({f.document_type as string})
|
||||
</div>
|
||||
{f.validation_errors && (
|
||||
<div className="mt-1 text-xs text-red-400">
|
||||
{JSON.stringify(f.validation_errors)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color = 'text-gray-100' }: { label: string; value: unknown; color?: string }) {
|
||||
return (
|
||||
<Card className="text-center">
|
||||
<div className={`text-xl font-bold ${color}`}>{value != null ? String(value) : '—'}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react';
|
||||
import { usePipelineHealth } from '../api/hooks';
|
||||
import { LoadingSpinner, DateRangeSelector, Card } from '../components/ui';
|
||||
|
||||
export function OpsPipelinePage() {
|
||||
const [hours, setHours] = useState(24);
|
||||
const { data, isLoading } = usePipelineHealth(hours);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
const stages = (data?.document_stages as Array<{ status: string; doc_count: number }>) ?? [];
|
||||
const parsing = (data?.parsing ?? {}) as Record<string, unknown>;
|
||||
const extraction = (data?.extraction ?? {}) as Record<string, unknown>;
|
||||
const aggregation = (data?.aggregation ?? {}) as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Pipeline Health</h1>
|
||||
<DateRangeSelector value={hours} onChange={setHours} />
|
||||
</div>
|
||||
|
||||
{/* Document Stage Counts */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Document Stages</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stages.map((s) => (
|
||||
<div key={s.status} className="rounded-lg border border-surface-700 bg-surface-950 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-gray-100">{s.doc_count}</div>
|
||||
<div className="text-xs capitalize text-gray-500">{s.status}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Parsing Quality */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Parsing Quality</h2>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
|
||||
<Stat label="Total Parsed" value={parsing.total_parsed} />
|
||||
<Stat label="High Confidence" value={parsing.high_confidence} color="text-green-400" />
|
||||
<Stat label="Medium" value={parsing.medium_confidence} color="text-yellow-400" />
|
||||
<Stat label="Low" value={parsing.low_confidence} color="text-red-400" />
|
||||
<Stat label="Avg Quality" value={parsing.avg_quality_score} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Extraction Stats */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Extraction Validation</h2>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-5">
|
||||
<Stat label="Total" value={extraction.total_extractions} />
|
||||
<Stat label="Valid" value={extraction.valid} color="text-green-400" />
|
||||
<Stat label="Failed" value={extraction.failed} color="text-red-400" />
|
||||
<Stat label="Avg Confidence" value={extraction.avg_confidence} />
|
||||
<Stat label="Avg Retries" value={extraction.avg_retries} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Aggregation */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Trend Generation</h2>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<Stat label="Trends Generated" value={aggregation.trends_generated} />
|
||||
<Stat label="Symbols Covered" value={aggregation.symbols_covered} />
|
||||
<Stat label="Avg Confidence" value={aggregation.avg_trend_confidence} />
|
||||
<Stat label="Avg Contradiction" value={aggregation.avg_contradiction} />
|
||||
</dl>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, color = 'text-gray-100' }: { label: string; value: unknown; color?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-surface-700 bg-surface-950 p-3 text-center">
|
||||
<div className={`text-xl font-bold ${color}`}>{value != null ? String(value) : '—'}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useOrder } from '../api/hooks';
|
||||
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function OrderDetailPage() {
|
||||
const { id } = useParams({ from: '/orders/$id' });
|
||||
const { data: order, isLoading } = useOrder(id);
|
||||
|
||||
if (isLoading || !order) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{order.ticker}</h1>
|
||||
<StatusBadge status={order.side} />
|
||||
<StatusBadge status={order.status} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-gray-500">Type</dt><dd className="text-gray-200">{order.order_type}</dd></div>
|
||||
<div><dt className="text-gray-500">Quantity</dt><dd className="text-gray-200">{order.quantity}</dd></div>
|
||||
<div><dt className="text-gray-500">Limit Price</dt><dd className="text-gray-200">{order.limit_price != null ? `$${order.limit_price.toFixed(2)}` : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Fill Price</dt><dd className="text-gray-200">{order.fill_price != null ? `$${order.fill_price.toFixed(2)}` : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Fill Qty</dt><dd className="text-gray-200">{order.fill_quantity ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Broker Order</dt><dd className="truncate font-mono text-xs text-gray-400">{order.broker_order_id ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Idempotency Key</dt><dd className="truncate font-mono text-xs text-gray-400">{order.idempotency_key ?? '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Created</dt><dd className="text-gray-300">{new Date(order.created_at).toLocaleString()}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Decision Trace */}
|
||||
{order.decision_trace && Object.keys(order.decision_trace).length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Decision Trace</h2>
|
||||
<pre className="overflow-x-auto rounded bg-surface-950 p-3 text-xs text-gray-300">
|
||||
{JSON.stringify(order.decision_trace, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Order Events Timeline */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Events ({order.events.length})</h2>
|
||||
{order.events.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No events</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{order.events.map((ev) => (
|
||||
<div key={ev.id} className="flex items-start gap-3 rounded border border-surface-700 bg-surface-950 p-2">
|
||||
<StatusBadge status={ev.event_type} />
|
||||
<div className="flex-1">
|
||||
<div className="text-xs text-gray-400">{new Date(ev.created_at).toLocaleString()}</div>
|
||||
{ev.data && (
|
||||
<pre className="mt-1 text-xs text-gray-500">{JSON.stringify(ev.data, null, 2)}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Audit Trail */}
|
||||
{order.audit_trail && (order.audit_trail as unknown[]).length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Audit Trail</h2>
|
||||
<div className="space-y-1">
|
||||
{(order.audit_trail as Array<Record<string, unknown>>).map((entry, i) => (
|
||||
<div key={i} className="flex gap-3 text-xs">
|
||||
<span className="text-gray-500">{entry.created_at ? new Date(entry.created_at as string).toLocaleString() : ''}</span>
|
||||
<span className="text-gray-400">{entry.event_type as string}</span>
|
||||
<span className="text-gray-300">{entry.description as string}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useOrders } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { StatusBadge, LoadingSpinner, TickerFilter } from '../components/ui';
|
||||
import type { Order } from '../api/hooks';
|
||||
|
||||
export function OrdersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [ticker, setTicker] = useState('');
|
||||
const { data, isLoading } = useOrders({ ticker: ticker || undefined, limit: 100 });
|
||||
|
||||
const columns: Column<Order>[] = [
|
||||
{ key: 'ticker', header: 'Ticker', className: 'font-mono font-semibold text-brand-300' },
|
||||
{ key: 'side', header: 'Side', render: (r) => <StatusBadge status={r.side} /> },
|
||||
{ key: 'order_type', header: 'Type' },
|
||||
{ key: 'quantity', header: 'Qty' },
|
||||
{ key: 'status', header: 'Status', render: (r) => <StatusBadge status={r.status} /> },
|
||||
{ key: 'fill_price', header: 'Fill Price', render: (r) => <span>{r.fill_price != null ? `$${r.fill_price.toFixed(2)}` : '—'}</span> },
|
||||
{ key: 'created_at', header: 'Created', render: (r) => <span className="text-xs">{new Date(r.created_at).toLocaleString()}</span> },
|
||||
];
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Orders</h1>
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
</div>
|
||||
<DataTable<Order>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/orders/$id', params: { id: row.id } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function PlaceholderPage({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-gray-200">{title}</h1>
|
||||
<p className="mt-2 text-gray-500">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { usePositions } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { LoadingSpinner } from '../components/ui';
|
||||
import type { Position } from '../api/hooks';
|
||||
|
||||
function fmtUsd(v: number | null | undefined) {
|
||||
if (v == null) return '—';
|
||||
return `$${v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function pnlColor(v: number | null | undefined) {
|
||||
if (v == null) return 'text-gray-400';
|
||||
return v >= 0 ? 'text-green-400' : 'text-red-400';
|
||||
}
|
||||
|
||||
const columns: Column<Position>[] = [
|
||||
{ key: 'ticker', header: 'Ticker', className: 'font-mono font-semibold text-brand-300' },
|
||||
{ key: 'quantity', header: 'Qty' },
|
||||
{ key: 'avg_entry_price', header: 'Entry', render: (r) => <span>{fmtUsd(r.avg_entry_price)}</span> },
|
||||
{ key: 'current_price', header: 'Current', render: (r) => <span>{fmtUsd(r.current_price)}</span> },
|
||||
{ key: 'unrealized_pnl', header: 'Unrealized P&L', render: (r) => <span className={pnlColor(r.unrealized_pnl)}>{fmtUsd(r.unrealized_pnl)}</span> },
|
||||
{ key: 'realized_pnl', header: 'Realized P&L', render: (r) => <span className={pnlColor(r.realized_pnl)}>{fmtUsd(r.realized_pnl)}</span> },
|
||||
{ key: 'updated_at', header: 'Updated', render: (r) => <span className="text-xs">{new Date(r.updated_at).toLocaleString()}</span> },
|
||||
];
|
||||
|
||||
export function PositionsPage() {
|
||||
const { data, isLoading } = usePositions();
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-4 text-xl font-semibold text-gray-100">Positions</h1>
|
||||
<DataTable<Position>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useRecommendation } from '../api/hooks';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function RecommendationDetailPage() {
|
||||
const { id } = useParams({ from: '/recommendations/$id' });
|
||||
const { data: rec, isLoading } = useRecommendation(id);
|
||||
|
||||
if (isLoading || !rec) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{rec.ticker}</h1>
|
||||
<StatusBadge status={rec.action} />
|
||||
<StatusBadge status={rec.mode} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-gray-500">Confidence</dt><dd><ConfidenceBar value={rec.confidence} /></dd></div>
|
||||
<div><dt className="text-gray-500">Horizon</dt><dd className="text-gray-200">{rec.time_horizon}</dd></div>
|
||||
<div><dt className="text-gray-500">Risk</dt><dd><StatusBadge status={rec.risk_classification} /></dd></div>
|
||||
<div><dt className="text-gray-500">Generated</dt><dd className="text-gray-300">{new Date(rec.generated_at).toLocaleString()}</dd></div>
|
||||
<div><dt className="text-gray-500">Portfolio %</dt><dd className="text-gray-200">{rec.portfolio_pct != null ? `${(rec.portfolio_pct * 100).toFixed(1)}%` : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Max Loss %</dt><dd className="text-gray-200">{rec.max_loss_pct != null ? `${(rec.max_loss_pct * 100).toFixed(2)}%` : '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Model</dt><dd className="text-xs text-gray-400">{rec.model_version ?? '—'}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{rec.thesis && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Thesis</h2>
|
||||
<p className="text-sm text-gray-200">{rec.thesis}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{rec.invalidation_conditions && rec.invalidation_conditions.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Invalidation Conditions</h2>
|
||||
<ul className="ml-4 list-disc text-sm text-yellow-400">
|
||||
{rec.invalidation_conditions.map((c, i) => <li key={i}>{c}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Risk Evaluation */}
|
||||
{rec.risk_evaluation && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Risk Evaluation</h2>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<StatusBadge status={rec.risk_evaluation.eligible ? 'approved' : 'rejected'} />
|
||||
<span className="text-gray-400">Allowed mode: {rec.risk_evaluation.allowed_mode}</span>
|
||||
</div>
|
||||
{rec.risk_evaluation.rejection_reasons && rec.risk_evaluation.rejection_reasons.length > 0 && (
|
||||
<ul className="mt-2 ml-4 list-disc text-sm text-red-400">
|
||||
{rec.risk_evaluation.rejection_reasons.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Evidence */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Evidence ({rec.evidence.length})</h2>
|
||||
{rec.evidence.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No evidence linked</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rec.evidence.map((ev) => (
|
||||
<div key={ev.id} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={ev.evidence_type} />
|
||||
<span className="text-sm text-gray-200">{ev.title ?? 'Untitled'}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-500">weight: {ev.weight.toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-4 text-xs text-gray-500">
|
||||
<span>{ev.document_type}</span>
|
||||
<span>{ev.source_type}</span>
|
||||
{ev.publisher && <span>{ev.publisher}</span>}
|
||||
{ev.published_at && <span>{new Date(ev.published_at).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useRecommendations } from '../api/hooks';
|
||||
import { DataTable, type Column } from '../components/DataTable';
|
||||
import { StatusBadge, ConfidenceBar, LoadingSpinner, TickerFilter } from '../components/ui';
|
||||
import type { Recommendation } from '../api/hooks';
|
||||
|
||||
export function RecommendationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [ticker, setTicker] = useState('');
|
||||
const { data, isLoading } = useRecommendations({ ticker: ticker || undefined, limit: 100 });
|
||||
|
||||
const columns: Column<Recommendation>[] = [
|
||||
{ key: 'ticker', header: 'Ticker', className: 'font-mono font-semibold text-brand-300' },
|
||||
{ key: 'action', header: 'Action', render: (r) => <StatusBadge status={r.action} /> },
|
||||
{ key: 'mode', header: 'Mode', render: (r) => <StatusBadge status={r.mode} /> },
|
||||
{ key: 'confidence', header: 'Confidence', render: (r) => <ConfidenceBar value={r.confidence} /> },
|
||||
{ key: 'thesis', header: 'Thesis', render: (r) => <span className="line-clamp-1 max-w-xs text-xs text-gray-400">{r.thesis ?? '—'}</span> },
|
||||
{ key: 'generated_at', header: 'Generated', render: (r) => <span className="text-xs">{new Date(r.generated_at).toLocaleString()}</span> },
|
||||
];
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Recommendations</h1>
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
</div>
|
||||
<DataTable<Recommendation>
|
||||
data={data ?? []}
|
||||
columns={columns}
|
||||
keyField="id"
|
||||
onRowClick={(row) => navigate({ to: '/recommendations/$id', params: { id: row.id } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { apiGet, apiPost, apiDelete } from '../api/client';
|
||||
import { LoadingSpinner, Card } from '../components/ui';
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, ScatterChart, Scatter,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
|
||||
interface QueryResult {
|
||||
columns: Array<{ name: string; type: string }>;
|
||||
rows: unknown[][];
|
||||
row_count: number;
|
||||
elapsed_ms: number;
|
||||
}
|
||||
|
||||
interface SavedQuery {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
sql_text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface SchemaInfo {
|
||||
catalog: string;
|
||||
schema: string;
|
||||
tables: Array<{ name: string; columns: Array<{ name: string; type: string }> }>;
|
||||
}
|
||||
|
||||
type ChartType = 'none' | 'bar' | 'line' | 'scatter';
|
||||
|
||||
export function SqlExplorerPage() {
|
||||
const qc = useQueryClient();
|
||||
const [sql, setSql] = useState('SELECT 1 AS test');
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [chartType, setChartType] = useState<ChartType>('none');
|
||||
const [xCol, setXCol] = useState(0);
|
||||
const [yCol, setYCol] = useState(1);
|
||||
|
||||
const { data: schema } = useQuery<SchemaInfo>({
|
||||
queryKey: ['trino-schema'],
|
||||
queryFn: () => apiGet<SchemaInfo>('query', '/api/analytics/schema'),
|
||||
});
|
||||
|
||||
const { data: savedQueries } = useQuery<SavedQuery[]>({
|
||||
queryKey: ['saved-queries'],
|
||||
queryFn: () => apiGet<SavedQuery[]>('query', '/api/analytics/saved-queries'),
|
||||
});
|
||||
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: (sqlText: string) => apiPost<QueryResult>('query', '/api/analytics/query', { sql: sqlText, limit: 1000 }),
|
||||
onSuccess: (data) => { setResult(data); setError(null); },
|
||||
onError: (err: Error) => { setError(err.message); setResult(null); },
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (body: { name: string; sql_text: string }) => apiPost<SavedQuery>('query', '/api/analytics/saved-queries', body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-queries'] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiDelete<unknown>('query', `/api/analytics/saved-queries/${id}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['saved-queries'] }),
|
||||
});
|
||||
|
||||
const handleExecute = useCallback(() => {
|
||||
if (sql.trim()) executeMutation.mutate(sql);
|
||||
}, [sql, executeMutation]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const name = prompt('Query name:');
|
||||
if (name) saveMutation.mutate({ name, sql_text: sql });
|
||||
}, [sql, saveMutation]);
|
||||
|
||||
// Build chart data from result
|
||||
const chartData = result && result.columns.length >= 2
|
||||
? result.rows.map((row) => ({
|
||||
x: row[xCol],
|
||||
y: Number(row[yCol]) || 0,
|
||||
label: String(row[xCol]),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3rem)] gap-4">
|
||||
{/* Schema browser sidebar */}
|
||||
<div className="w-56 shrink-0 overflow-y-auto rounded-lg border border-surface-700 bg-surface-900 p-3">
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Schema</h2>
|
||||
{schema?.tables.map((t) => (
|
||||
<div key={t.name} className="mb-2">
|
||||
<button
|
||||
className="text-xs font-medium text-brand-300 hover:underline"
|
||||
onClick={() => setSql((prev) => prev + ` ${t.name}`)}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
<div className="ml-2 space-y-0.5">
|
||||
{t.columns.map((c) => (
|
||||
<div key={c.name} className="flex justify-between text-[10px]">
|
||||
<span className="text-gray-400">{c.name}</span>
|
||||
<span className="text-gray-600">{c.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Saved queries */}
|
||||
<h2 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wider text-gray-500">Saved Queries</h2>
|
||||
{(savedQueries ?? []).map((sq) => (
|
||||
<div key={sq.id} className="mb-1 flex items-center justify-between">
|
||||
<button
|
||||
className="truncate text-xs text-gray-300 hover:text-brand-300"
|
||||
onClick={() => setSql(sq.sql_text)}
|
||||
title={sq.description || sq.name}
|
||||
>
|
||||
{sq.name}
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-gray-600 hover:text-red-400"
|
||||
onClick={() => deleteMutation.mutate(sq.id)}
|
||||
aria-label={`Delete ${sq.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-hidden">
|
||||
{/* Editor */}
|
||||
<div className="h-48 shrink-0 overflow-hidden rounded-lg border border-surface-700">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="sql"
|
||||
value={sql}
|
||||
onChange={(v) => setSql(v ?? '')}
|
||||
theme="vs-dark"
|
||||
options={{ minimap: { enabled: false }, fontSize: 13, lineNumbers: 'on', scrollBeyondLastLine: false }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executeMutation.isPending}
|
||||
className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{executeMutation.isPending ? 'Running…' : 'Execute'}
|
||||
</button>
|
||||
<button onClick={handleSave} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">
|
||||
Save
|
||||
</button>
|
||||
{result && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{result.row_count} rows in {result.elapsed_ms}ms
|
||||
</span>
|
||||
)}
|
||||
{error && <span className="text-xs text-red-400">{error}</span>}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{executeMutation.isPending && <LoadingSpinner />}
|
||||
|
||||
{result && (
|
||||
<div className="flex-1 overflow-auto rounded-lg border border-surface-700">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-surface-900">
|
||||
<tr>
|
||||
{result.columns.map((col, i) => (
|
||||
<th key={i} className="px-2 py-1.5 text-left font-medium text-gray-400">
|
||||
{col.name} <span className="text-gray-600">({col.type})</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((row, ri) => (
|
||||
<tr key={ri} className="border-t border-surface-700/30 hover:bg-surface-800/30">
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-2 py-1 text-gray-300">
|
||||
{cell == null ? <span className="text-gray-600">NULL</span> : String(cell)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart builder */}
|
||||
{result && result.columns.length >= 2 && (
|
||||
<Card className="shrink-0">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">Chart:</span>
|
||||
{(['none', 'bar', 'line', 'scatter'] as ChartType[]).map((ct) => (
|
||||
<button
|
||||
key={ct}
|
||||
onClick={() => setChartType(ct)}
|
||||
className={`rounded px-2 py-0.5 text-xs ${chartType === ct ? 'bg-brand-600 text-white' : 'text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
{ct === 'none' ? 'Off' : ct}
|
||||
</button>
|
||||
))}
|
||||
{chartType !== 'none' && (
|
||||
<>
|
||||
<select value={xCol} onChange={(e) => setXCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="X axis column">
|
||||
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
|
||||
</select>
|
||||
<span className="text-xs text-gray-600">→</span>
|
||||
<select value={yCol} onChange={(e) => setYCol(Number(e.target.value))} className="rounded border border-surface-700 bg-surface-900 px-1 py-0.5 text-xs text-gray-300" aria-label="Y axis column">
|
||||
{result.columns.map((c, i) => <option key={i} value={i}>{c.name}</option>)}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{chartType !== 'none' && chartData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
{chartType === 'bar' ? (
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Bar dataKey="y" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
) : chartType === 'line' ? (
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="label" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Line type="monotone" dataKey="y" stroke="#3b82f6" dot={false} />
|
||||
</LineChart>
|
||||
) : (
|
||||
<ScatterChart>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="x" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[xCol]?.name} />
|
||||
<YAxis dataKey="y" tick={{ fill: '#6b7280', fontSize: 10 }} name={result.columns[yCol]?.name} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Scatter data={chartData} fill="#3b82f6" />
|
||||
</ScatterChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useTradingConfig,
|
||||
useSetTradingMode,
|
||||
usePendingApprovals,
|
||||
useReviewApproval,
|
||||
useActiveLockouts,
|
||||
} from '../api/hooks';
|
||||
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function TradingPage() {
|
||||
const { data: config, isLoading: configLoading } = useTradingConfig();
|
||||
const { data: approvals } = usePendingApprovals();
|
||||
const { data: lockouts } = useActiveLockouts();
|
||||
const setMode = useSetTradingMode();
|
||||
const reviewApproval = useReviewApproval();
|
||||
const [confirmMode, setConfirmMode] = useState<string | null>(null);
|
||||
|
||||
if (configLoading) return <LoadingSpinner />;
|
||||
|
||||
const currentMode = config?.trading_mode ?? 'paper';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Trading Controls</h1>
|
||||
|
||||
{/* Trading Mode */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Trading Mode</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{['paper', 'live', 'disabled'].map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
if (mode === currentMode) return;
|
||||
if (mode === 'live') { setConfirmMode(mode); return; }
|
||||
setMode.mutate(mode);
|
||||
}}
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium capitalize transition-colors ${
|
||||
currentMode === mode
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'border border-surface-700 bg-surface-900 text-gray-400 hover:bg-surface-800'
|
||||
}`}
|
||||
aria-pressed={currentMode === mode}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog for live mode */}
|
||||
{confirmMode && (
|
||||
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
|
||||
<p className="text-sm text-orange-300">
|
||||
Are you sure you want to switch to <span className="font-semibold">{confirmMode}</span> mode?
|
||||
This enables real order execution.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => { setMode.mutate(confirmMode); setConfirmMode(null); }}
|
||||
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmMode(null)}
|
||||
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Pending Approvals */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">
|
||||
Pending Approvals ({approvals?.length ?? 0})
|
||||
</h2>
|
||||
{!approvals?.length ? (
|
||||
<p className="text-sm text-gray-500">No pending approvals</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{approvals.map((a) => (
|
||||
<ApprovalRow key={a.id} approval={a} onReview={(approved, note) => reviewApproval.mutate({ id: a.id, approved, review_note: note })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Active Lockouts */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">
|
||||
Active Lockouts ({lockouts?.length ?? 0})
|
||||
</h2>
|
||||
{!lockouts?.length ? (
|
||||
<p className="text-sm text-gray-500">No active lockouts</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{lockouts.map((l) => {
|
||||
const expiresIn = l.expires_at ? Math.max(0, Math.round((new Date(l.expires_at).getTime() - Date.now()) / 60000)) : 0;
|
||||
return (
|
||||
<div key={l.id} className="flex items-center justify-between rounded border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-brand-300">{l.ticker}</span>
|
||||
<StatusBadge status={l.lockout_type} />
|
||||
<span className="text-sm text-gray-400">{l.reason}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{expiresIn > 0 ? `${expiresIn}m remaining` : 'expired'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalRow({ approval, onReview }: {
|
||||
approval: { id: string; ticker: string; side: string; quantity: number; estimated_value: number | null; requested_at: string };
|
||||
onReview: (approved: boolean, note: string) => void;
|
||||
}) {
|
||||
const [note, setNote] = useState('');
|
||||
|
||||
return (
|
||||
<div className="rounded border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono font-semibold text-brand-300">{approval.ticker}</span>
|
||||
<StatusBadge status={approval.side} />
|
||||
<span className="text-sm text-gray-300">qty: {approval.quantity}</span>
|
||||
{approval.estimated_value != null && (
|
||||
<span className="text-sm text-gray-500">${approval.estimated_value.toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{new Date(approval.requested_at).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Review note…"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
className="flex-1 rounded-md border border-surface-700 bg-surface-900 px-2 py-1 text-xs text-gray-200 placeholder-gray-500"
|
||||
aria-label="Review note"
|
||||
/>
|
||||
<button onClick={() => onReview(true, note)} className="rounded bg-green-700 px-3 py-1 text-xs font-medium text-white hover:bg-green-600">
|
||||
Approve
|
||||
</button>
|
||||
<button onClick={() => onReview(false, note)} className="rounded bg-red-700 px-3 py-1 text-xs font-medium text-white hover:bg-red-600">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useTrend, useTrendEvidence } from '../api/hooks';
|
||||
import { TrendArrow, ConfidenceBar, StatusBadge, LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function TrendDetailPage() {
|
||||
const { id } = useParams({ from: '/trends/$id' });
|
||||
const { data: trend, isLoading } = useTrend(id);
|
||||
const { data: evidenceData } = useTrendEvidence(id);
|
||||
|
||||
if (isLoading || !trend) return <LoadingSpinner />;
|
||||
|
||||
const evidence = (evidenceData?.evidence ?? []) as Array<Record<string, unknown>>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{trend.entity_id}</h1>
|
||||
<TrendArrow direction={trend.trend_direction} />
|
||||
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{trend.window}</span>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 text-sm sm:grid-cols-4">
|
||||
<div>
|
||||
<dt className="text-gray-500">Direction</dt>
|
||||
<dd className="flex items-center gap-1 text-gray-200">
|
||||
<TrendArrow direction={trend.trend_direction} /> {trend.trend_direction}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Strength</dt>
|
||||
<dd><ConfidenceBar value={trend.trend_strength} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Confidence</dt>
|
||||
<dd><ConfidenceBar value={trend.confidence} /></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Contradiction</dt>
|
||||
<dd className={`font-mono ${trend.contradiction_score > 0.5 ? 'text-yellow-400' : 'text-gray-300'}`}>
|
||||
{(trend.contradiction_score * 100).toFixed(0)}%
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500">Generated</dt>
|
||||
<dd className="text-gray-300">{new Date(trend.generated_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{trend.dominant_catalysts && trend.dominant_catalysts.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Dominant Catalysts</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trend.dominant_catalysts.map((c, i) => (
|
||||
<span key={i} className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-300">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{trend.material_risks && trend.material_risks.length > 0 && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Material Risks</h2>
|
||||
<ul className="ml-4 list-disc text-sm text-red-400">
|
||||
{trend.material_risks.map((r, i) => <li key={i}>{r}</li>)}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Evidence drill-down */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Contributing Evidence ({evidence.length})</h2>
|
||||
{evidence.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No evidence records</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{evidence.map((ev, i) => (
|
||||
<div key={i} className="rounded-lg border border-surface-700 bg-surface-950 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={ev.evidence_type as string} />
|
||||
<span className="text-sm text-gray-200">{(ev.title as string) ?? 'Untitled'}</span>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-gray-500">rank: {((ev.rank_score as number) ?? 0).toFixed(3)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-4 text-xs text-gray-500">
|
||||
<span>{ev.document_type as string}</span>
|
||||
<span>{ev.source_type as string}</span>
|
||||
{ev.publisher && <span>{ev.publisher as string}</span>}
|
||||
{ev.published_at && <span>{new Date(ev.published_at as string).toLocaleDateString()}</span>}
|
||||
</div>
|
||||
{ev.intelligence && (
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
<span className="text-gray-500">Summary: </span>
|
||||
{((ev.intelligence as Record<string, unknown>).summary as string) ?? '—'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { useTrends } from '../api/hooks';
|
||||
import { TrendArrow, ConfidenceBar, LoadingSpinner, TickerFilter, Card } from '../components/ui';
|
||||
import type { TrendSummary } from '../api/hooks';
|
||||
|
||||
const WINDOWS = ['intraday', '1d', '7d', '30d', '90d'];
|
||||
|
||||
export function TrendsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [ticker, setTicker] = useState('');
|
||||
const [window, setWindow] = useState<string | undefined>(undefined);
|
||||
const { data, isLoading } = useTrends({ ticker: ticker || undefined, window, limit: 100 });
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Trends</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<TickerFilter value={ticker} onChange={setTicker} />
|
||||
<div className="inline-flex rounded-md border border-surface-700" role="group" aria-label="Window selector">
|
||||
<button
|
||||
onClick={() => setWindow(undefined)}
|
||||
className={`px-2 py-1 text-xs font-medium first:rounded-l-md last:rounded-r-md ${!window ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{WINDOWS.map((w) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => setWindow(w)}
|
||||
className={`px-2 py-1 text-xs font-medium last:rounded-r-md ${window === w ? 'bg-brand-600 text-white' : 'bg-surface-900 text-gray-400 hover:bg-surface-800'}`}
|
||||
>
|
||||
{w}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(data ?? []).map((trend) => (
|
||||
<TrendCard key={trend.id} trend={trend} onClick={() => navigate({ to: '/trends/$id', params: { id: trend.id } })} />
|
||||
))}
|
||||
{data?.length === 0 && <p className="col-span-full text-center text-gray-500">No trends found</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendCard({ trend, onClick }: { trend: TrendSummary; onClick: () => void }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Card className="cursor-pointer transition-colors hover:border-brand-500/50">
|
||||
<div onClick={onClick}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-semibold text-brand-300">{trend.entity_id}</span>
|
||||
<TrendArrow direction={trend.trend_direction} />
|
||||
</div>
|
||||
<span className="rounded bg-surface-800 px-2 py-0.5 text-xs text-gray-400">{trend.window}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Strength</span>
|
||||
<div className="h-1.5 w-24 rounded-full bg-surface-700">
|
||||
<div className="h-full rounded-full bg-brand-500" style={{ width: `${trend.trend_strength * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Confidence</span>
|
||||
<ConfidenceBar value={trend.confidence} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">Contradiction</span>
|
||||
<span className={`font-mono ${trend.contradiction_score > 0.5 ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
{(trend.contradiction_score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{trend.dominant_catalysts && trend.dominant_catalysts.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{trend.dominant_catalysts.map((c, i) => (
|
||||
<span key={i} className="rounded bg-surface-800 px-1.5 py-0.5 text-[10px] text-gray-400">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable evidence preview */}
|
||||
{(trend.top_supporting_evidence?.length || trend.top_opposing_evidence?.length) && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
||||
className="mt-2 text-xs text-brand-400 hover:underline"
|
||||
>
|
||||
{expanded ? 'Hide' : 'Show'} evidence ({(trend.top_supporting_evidence?.length ?? 0) + (trend.top_opposing_evidence?.length ?? 0)})
|
||||
</button>
|
||||
)}
|
||||
{expanded && (
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
{trend.top_supporting_evidence?.map((e, i) => (
|
||||
<div key={i} className="truncate text-green-400">+ {e}</div>
|
||||
))}
|
||||
{trend.top_opposing_evidence?.map((e, i) => (
|
||||
<div key={i} className="truncate text-red-400">− {e}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { useWatchlists, useWatchlistMembers, useCreateWatchlist } from '../api/hooks';
|
||||
import { LoadingSpinner, Card } from '../components/ui';
|
||||
|
||||
export function WatchlistsPage() {
|
||||
const { data: watchlists, isLoading } = useWatchlists();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-gray-100">Watchlists</h1>
|
||||
<button onClick={() => setShowCreate(true)} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">
|
||||
New Watchlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCreate && <CreateWatchlistForm onClose={() => setShowCreate(false)} />}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{(watchlists ?? []).map((wl) => (
|
||||
<Card
|
||||
key={wl.id}
|
||||
className={`cursor-pointer transition-colors hover:border-brand-500/50 ${selected === wl.id ? 'border-brand-500' : ''}`}
|
||||
>
|
||||
<button className="w-full text-left" onClick={() => setSelected(selected === wl.id ? null : wl.id)}>
|
||||
<div className="font-medium text-gray-200">{wl.name}</div>
|
||||
{wl.description && <div className="mt-1 text-xs text-gray-500">{wl.description}</div>}
|
||||
</button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selected && <WatchlistMembers watchlistId={selected} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WatchlistMembers({ watchlistId }: { watchlistId: string }) {
|
||||
const { data: members, isLoading } = useWatchlistMembers(watchlistId);
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (!members?.length) return <p className="text-sm text-gray-500">No members in this watchlist</p>;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Members</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{members.map((m) => (
|
||||
<span key={m.id} className="rounded-full border border-surface-700 bg-surface-800 px-3 py-1 text-xs text-gray-300">
|
||||
{m.ticker} — {m.legal_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateWatchlistForm({ onClose }: { onClose: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const mutation = useCreateWatchlist();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) mutation.mutate({ name: name.trim(), description: desc || undefined }, { onSuccess: onClose });
|
||||
}}
|
||||
>
|
||||
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} required className="rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none" aria-label="Watchlist name" />
|
||||
<input type="text" placeholder="Description (optional)" value={desc} onChange={(e) => setDesc(e.target.value)} className="flex-1 rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none" aria-label="Watchlist description" />
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">Create</button>
|
||||
<button type="button" onClick={onClose} className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
Outlet,
|
||||
} from '@tanstack/react-router';
|
||||
import { AppLayout } from './components/AppLayout';
|
||||
|
||||
import { CompaniesPage } from './pages/Companies';
|
||||
import { CompanyDetailPage } from './pages/CompanyDetail';
|
||||
import { WatchlistsPage } from './pages/Watchlists';
|
||||
import { DocumentsPage } from './pages/Documents';
|
||||
import { DocumentDetailPage } from './pages/DocumentDetail';
|
||||
import { TrendsPage } from './pages/Trends';
|
||||
import { TrendDetailPage } from './pages/TrendDetail';
|
||||
import { RecommendationsPage } from './pages/Recommendations';
|
||||
import { RecommendationDetailPage } from './pages/RecommendationDetail';
|
||||
import { OrdersPage } from './pages/Orders';
|
||||
import { OrderDetailPage } from './pages/OrderDetail';
|
||||
import { PositionsPage } from './pages/Positions';
|
||||
import { TradingPage } from './pages/Trading';
|
||||
import { OpsPipelinePage } from './pages/OpsPipeline';
|
||||
import { OpsIngestionPage } from './pages/OpsIngestion';
|
||||
import { OpsModelPage } from './pages/OpsModel';
|
||||
import { OpsCoveragePage } from './pages/OpsCoverage';
|
||||
import { SqlExplorerPage } from './pages/SqlExplorer';
|
||||
import { DashboardsPage } from './pages/Dashboards';
|
||||
import { HomePage } from './pages/Home';
|
||||
|
||||
// Root route wraps everything in the app shell layout
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<AppLayout>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
),
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: HomePage,
|
||||
});
|
||||
|
||||
const companiesRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/companies',
|
||||
component: CompaniesPage,
|
||||
});
|
||||
const companyDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/companies/$id',
|
||||
component: CompanyDetailPage,
|
||||
});
|
||||
const watchlistsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/watchlists',
|
||||
component: WatchlistsPage,
|
||||
});
|
||||
const documentsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/documents',
|
||||
component: DocumentsPage,
|
||||
});
|
||||
const documentDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/documents/$id',
|
||||
component: DocumentDetailPage,
|
||||
});
|
||||
const trendsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/trends',
|
||||
component: TrendsPage,
|
||||
});
|
||||
const trendDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/trends/$id',
|
||||
component: TrendDetailPage,
|
||||
});
|
||||
const recommendationsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/recommendations',
|
||||
component: RecommendationsPage,
|
||||
});
|
||||
const recommendationDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/recommendations/$id',
|
||||
component: RecommendationDetailPage,
|
||||
});
|
||||
const ordersRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/orders',
|
||||
component: OrdersPage,
|
||||
});
|
||||
const orderDetailRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/orders/$id',
|
||||
component: OrderDetailPage,
|
||||
});
|
||||
const positionsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/positions',
|
||||
component: PositionsPage,
|
||||
});
|
||||
const tradingRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/trading',
|
||||
component: TradingPage,
|
||||
});
|
||||
const opsPipelineRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ops/pipeline',
|
||||
component: OpsPipelinePage,
|
||||
});
|
||||
const opsIngestionRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ops/ingestion',
|
||||
component: OpsIngestionPage,
|
||||
});
|
||||
const opsModelRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ops/model',
|
||||
component: OpsModelPage,
|
||||
});
|
||||
const opsCoverageRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ops/coverage',
|
||||
component: OpsCoveragePage,
|
||||
});
|
||||
const analyticsQueryRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/analytics/query',
|
||||
component: SqlExplorerPage,
|
||||
});
|
||||
const analyticsDashboardsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/analytics/dashboards',
|
||||
component: DashboardsPage,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
companiesRoute,
|
||||
companyDetailRoute,
|
||||
watchlistsRoute,
|
||||
documentsRoute,
|
||||
documentDetailRoute,
|
||||
trendsRoute,
|
||||
trendDetailRoute,
|
||||
recommendationsRoute,
|
||||
recommendationDetailRoute,
|
||||
ordersRoute,
|
||||
orderDetailRoute,
|
||||
positionsRoute,
|
||||
tradingRoute,
|
||||
opsPipelineRoute,
|
||||
opsIngestionRoute,
|
||||
opsModelRoute,
|
||||
opsCoverageRoute,
|
||||
analyticsQueryRoute,
|
||||
analyticsDashboardsRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user