diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index af7ae2d..a624608 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -236,6 +236,15 @@ export function useTrends(params?: { ticker?: string; window?: string; limit?: n return useGet(['trends', params], 'query', path); } +export function useTrendHistory(params?: { ticker?: string; window?: string; limit?: 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 ?? 200)); + const path = `/api/trends/history${qs.toString() ? '?' + qs : ''}`; + return useGet(['trend-history', params], 'query', path); +} + export function useTrend(id: string | undefined) { return useGet(['trend', id], 'query', `/api/trends/${id}`, !!id); } diff --git a/frontend/src/pages/CompanyDetail.tsx b/frontend/src/pages/CompanyDetail.tsx index 54309bd..456a075 100644 --- a/frontend/src/pages/CompanyDetail.tsx +++ b/frontend/src/pages/CompanyDetail.tsx @@ -12,6 +12,7 @@ import { useCompetitiveSignals, useCorporateDecisions, useTrends, + useTrendHistory, } from '../api/hooks'; import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui'; import { DataTable, type Column } from '../components/DataTable'; @@ -40,6 +41,7 @@ export function CompanyDetailPage() { const { data: signals } = useCompetitiveSignals(company?.ticker); const { data: decisions } = useCorporateDecisions(company?.ticker); const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 }); + const { data: trendHistory } = useTrendHistory({ ticker: company?.ticker, limit: 500 }); const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends'); if (isLoading || !company) return ; @@ -78,7 +80,7 @@ export function CompanyDetailPage() { {tab === 'trends' && ( - + )} {tab === 'sources' && ( @@ -360,7 +362,7 @@ function CompetitorsPanel({ competitors, onInfer, isInferring }: { {competitors.map((c) => (
- {c.ticker ?? c.company_b_id.slice(0, 8)} + {c.ticker ?? c.legal_name ?? 'Unknown'}
@@ -563,10 +565,10 @@ interface ChartPoint { window: string; } -function TrendHistoryChart({ trends, ticker }: { trends: TrendSummary[]; ticker: string }) { +function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string }) { const [selectedWindow, setSelectedWindow] = useState('7d'); - // Filter trends for this ticker and selected window, sorted by time + // Use history data for charts const filtered = (trends ?? []) .filter((t) => t.entity_id === ticker && t.window === selectedWindow) .sort((a, b) => new Date(a.generated_at).getTime() - new Date(b.generated_at).getTime()); @@ -582,10 +584,17 @@ function TrendHistoryChart({ trends, ticker }: { trends: TrendSummary[]; ticker: window: t.window, })); - // Available windows from the data - const availableWindows = [...new Set((trends ?? []).filter((t) => t.entity_id === ticker).map((t) => t.window))]; + // Available windows from the data (check both history and latest) + const allTrends = [...(trends ?? []), ...(latestTrends ?? [])]; + const availableWindows = [...new Set(allTrends.filter((t) => t.entity_id === ticker).map((t) => t.window))]; availableWindows.sort((a, b) => WINDOW_ORDER.indexOf(a) - WINDOW_ORDER.indexOf(b)); + // Use latest trends for the summary card + const latestForWindow = (latestTrends ?? []) + .filter((t) => t.entity_id === ticker && t.window === selectedWindow) + .sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime()); + const latest = latestForWindow[0] ?? (filtered.length > 0 ? filtered[filtered.length - 1] : null); + return (
{/* Window selector */} @@ -705,8 +714,7 @@ function TrendHistoryChart({ trends, ticker }: { trends: TrendSummary[]; ticker: {/* Latest trend summary */}

Latest Trend

- {filtered.length > 0 && (() => { - const latest = filtered[filtered.length - 1]; + {latest && (() => { return (
diff --git a/infra/migrations/024_trend_history.sql b/infra/migrations/024_trend_history.sql new file mode 100644 index 0000000..f5643d7 --- /dev/null +++ b/infra/migrations/024_trend_history.sql @@ -0,0 +1,37 @@ +-- Trend history table for time-series charting. +-- trend_windows stores the latest snapshot per (entity, window) via upsert. +-- trend_history stores every snapshot so the frontend can plot trend evolution. + +CREATE TABLE IF NOT EXISTS trend_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type VARCHAR(50) NOT NULL DEFAULT 'company', + entity_id VARCHAR(100) NOT NULL, + "window" VARCHAR(20) NOT NULL, + trend_direction VARCHAR(20) NOT NULL DEFAULT 'neutral', + trend_strength FLOAT DEFAULT 0.5, + confidence FLOAT DEFAULT 0.5, + contradiction_score FLOAT DEFAULT 0.0, + dominant_catalysts JSONB DEFAULT '[]', + material_risks JSONB DEFAULT '[]', + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_trend_history_lookup + ON trend_history (entity_id, "window", generated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_trend_history_generated + ON trend_history (generated_at DESC); + +-- Seed history from existing trend_windows so charts aren't empty +-- on first deploy. This gives at least one data point per ticker/window. +INSERT INTO trend_history ( + entity_type, entity_id, "window", trend_direction, + trend_strength, confidence, contradiction_score, + dominant_catalysts, material_risks, generated_at +) +SELECT + entity_type, entity_id, "window", trend_direction, + trend_strength, confidence, contradiction_score, + dominant_catalysts, material_risks, generated_at +FROM trend_windows +ON CONFLICT DO NOTHING; diff --git a/services/aggregation/worker.py b/services/aggregation/worker.py index 05fd528..929acc2 100644 --- a/services/aggregation/worker.py +++ b/services/aggregation/worker.py @@ -742,11 +742,23 @@ RETURNING id """ +_INSERT_TREND_HISTORY = """ +INSERT INTO trend_history ( + entity_type, entity_id, "window", trend_direction, + trend_strength, confidence, contradiction_score, + dominant_catalysts, material_risks, generated_at +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10) +""" + + async def persist_trend_summary( pool: asyncpg.Pool, summary: TrendSummary, ) -> str: - """Insert a trend summary row and return its UUID.""" + """Insert a trend summary row and return its UUID. + + Also appends a snapshot to trend_history for time-series charting. + """ row = await pool.fetchrow( _UPSERT_TREND, summary.entity_type, @@ -764,7 +776,28 @@ async def persist_trend_summary( json.dumps(summary.market_context.model_dump() if summary.market_context else {}, default=str), summary.generated_at, ) - return str(row["id"]) + trend_id = str(row["id"]) + + # Append to trend_history for time-series charts + try: + await pool.execute( + _INSERT_TREND_HISTORY, + summary.entity_type, + summary.entity_id, + summary.window.value, + summary.trend_direction.value, + summary.trend_strength, + summary.confidence, + summary.contradiction_score, + json.dumps(summary.dominant_catalysts), + json.dumps(summary.material_risks), + summary.generated_at, + ) + except Exception: + # Don't fail the main upsert if history insert fails (table may not exist yet) + logger.debug("Could not insert trend history for %s/%s", summary.entity_id, summary.window.value, exc_info=True) + + return trend_id # --------------------------------------------------------------------------- diff --git a/services/api/app.py b/services/api/app.py index 2ce1cf3..328e844 100644 --- a/services/api/app.py +++ b/services/api/app.py @@ -415,6 +415,56 @@ async def list_trends( return results +@app.get("/api/trends/history") +async def list_trend_history( + ticker: Optional[str] = None, + window: Optional[str] = None, + limit: int = Query(default=200, le=1000), +): + """Return historical trend snapshots for charting. + + Unlike /api/trends which returns the latest snapshot per entity/window, + this endpoint returns the time series from the trend_history table. + """ + conditions: list[str] = [] + params: list[Any] = [] + idx = 1 + + if ticker: + conditions.append(f"entity_id = ${idx}") + params.append(ticker.upper()) + idx += 1 + if window: + conditions.append(f"\"window\" = ${idx}") + params.append(window) + idx += 1 + + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + + try: + rows = await pool.fetch( + f"""SELECT id, entity_type, entity_id, "window", trend_direction, + trend_strength, confidence, contradiction_score, + dominant_catalysts, material_risks, generated_at + FROM trend_history + {where} + ORDER BY generated_at ASC + LIMIT ${idx}""", + *params, limit, + ) + except Exception: + # Table may not exist yet (pre-migration 024) + return [] + + results = [] + for r in rows: + d = _row_to_dict(r) + d["dominant_catalysts"] = _parse_jsonb(d.get("dominant_catalysts")) + d["material_risks"] = _parse_jsonb(d.get("material_risks")) + results.append(d) + return results + + @app.get("/api/trends/{trend_id}") async def get_trend(trend_id: str): """Get a single trend summary by ID.""" diff --git a/services/symbol_registry/competitors.py b/services/symbol_registry/competitors.py index d9714ba..6e8be08 100644 --- a/services/symbol_registry/competitors.py +++ b/services/symbol_registry/competitors.py @@ -53,6 +53,9 @@ class CompetitorRelationship(BaseModel): active: bool created_at: datetime updated_at: datetime + # Enriched from companies table + ticker: str | None = None + legal_name: str | None = None def _row_dict(row: asyncpg.Record) -> dict[str, Any]: @@ -128,18 +131,31 @@ async def create_competitor(company_id: str, body: CompetitorRelationshipCreate, @router.get("/companies/{company_id}/competitors", response_model=List[CompetitorRelationship]) async def list_competitors(company_id: str, request: Request): - """List active competitor relationships for a company, ordered by strength descending.""" + """List active competitor relationships for a company, ordered by strength descending. + + Enriches each relationship with the ticker and legal_name of the + *other* company (the one that isn't company_id). + """ pool = _get_pool(request) if not await _company_exists(pool, company_id): raise HTTPException(404, "Company not found") rows = await pool.fetch( - """SELECT id, company_a_id, company_b_id, relationship_type, strength, - bidirectional, source, active, created_at, updated_at - FROM competitor_relationships - WHERE (company_a_id = $1 OR company_b_id = $1) AND active = TRUE - ORDER BY strength DESC""", + """SELECT cr.id, cr.company_a_id, cr.company_b_id, + cr.relationship_type, cr.strength, + cr.bidirectional, cr.source, cr.active, + cr.created_at, cr.updated_at, + c.ticker, c.legal_name + FROM competitor_relationships cr + LEFT JOIN companies c + ON c.id = CASE + WHEN cr.company_a_id = $1 THEN cr.company_b_id + ELSE cr.company_a_id + END + WHERE (cr.company_a_id = $1 OR cr.company_b_id = $1) + AND cr.active = TRUE + ORDER BY cr.strength DESC""", company_id, ) return [_row_dict(r) for r in rows]