{"openapi":"3.1.0","info":{"title":"TrendMatrix API","description":"Quantitative stock analysis API. 10-dimension scoring, BUY/SELL verdicts, price targets for 2,236 US stocks. Updated nightly.","version":"0.1.0"},"paths":{"/api/v1/events":{"post":{"tags":["events"],"summary":"Post Event","description":"Ingest a single analytics event.","operationId":"post_event_api_v1_events_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventPayload"}}},"required":true},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/run-id":{"get":{"summary":"Get Run Id","description":"Canonical run_id ground-truth for cross-page freshness checks.\n\ngrowth/115a (2026-05-13): RunIdWatcher polls this endpoint (not /api/meta)\nso it sees fresh run_id within the endpoint's own TTL even when /api/meta\nis edge-cached for 5 min by 115b. Pure get_latest_run_id() + UTC clock —\nno DB blob fetch, no JSON parse, no Python heap allocation worth tracking.\n\nExcluded from TmUidMiddleware via CACHED_API_PATHS — no Set-Cookie under\nany condition (would break CF caching the moment 115b ships).","operationId":"get_run_id_api_v1_run_id_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/meta":{"get":{"summary":"Get Meta","description":"Latest run metadata + verdict summary + display thresholds.\n\n`config.thresholds` exposes the numeric bands the frontend needs to color\nmacro stats. They MUST come from verdict_config.py, not hard-coded in the\nclient — otherwise the header colors drift from the regime the model used\n(the \"three VIX band definitions\" bug). Never duplicate these values; the\ntest suite fails if MacroBar.tsx references magic numbers for these fields.\n\ngrowth/111 (2026-05-12): publish.py prebuilds the response under\n`data/api_cache/meta.json`. Handler stats-then-serves via FileResponse\n+ sendfile; ETag is (inode, mtime_ns) so 304 round-trip is free of\nper-request allocation. Falls through to live-build on cache miss.\n\nStep 108 (2026-05-10): conditional-GET via ETag (now in the disk-cache\npath). Run_id is the canonical freshness key — when it hasn't advanced,\npublish.py rewrites the file with new mtime, the ETag changes, and\nstale If-None-Match returns 200 with new bytes. Inside a publish cycle\nevery poll gets ~5ms 304.","operationId":"get_meta_api_meta_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/meta/score_components":{"get":{"summary":"Get Score Components","description":"Glossary of score-component sub-signals for the Rating Breakdown UI.\n\nSingle source of truth for component labels, units, and scoring rationale.\nFrontend fetches once per session and renders per-stock breakdowns using\nthis metadata. Adding a new scorer sub-signal? Register it in\n`pipeline/score_components.py` and the UI picks it up automatically.","operationId":"get_score_components_api_meta_score_components_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/stocks":{"get":{"summary":"Get Stocks","description":"List stocks with optional filters.\n\ngrowth/110: publish.py prebuilds the common query shapes to disk under\n`data/api_cache/`. The handler stat-then-serves via FileResponse +\nsendfile() for those shapes; everything else falls through to live\nbuild. Per-user cost on the hot path: ~microseconds + zero Python heap\nallocation (kernel page cache + sendfile is zero-copy). Memory:\n`feedback_kernel_page_cache_for_multi_worker_share.md`.","operationId":"get_stocks_api_stocks_get","parameters":[{"name":"verdict","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by verdict (comma-separated)","title":"Verdict"},"description":"Filter by verdict (comma-separated)"},{"name":"sector","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sector"}},{"name":"symbols","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated symbol allowlist (e.g. 'AAPL,MSFT,NVDA'). Capped at 100. Use for fast watchlist / saved-set fetches.","title":"Symbols"},"description":"Comma-separated symbol allowlist (e.g. 'AAPL,MSFT,NVDA'). Capped at 100. Use for fast watchlist / saved-set fetches."},{"name":"sort","in":"query","required":false,"schema":{"type":"string","default":"overall_score","title":"Sort"}},{"name":"dir","in":"query","required":false,"schema":{"type":"string","default":"DESC","title":"Dir"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":3000,"minimum":1,"default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"fields","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated fields to include (default: all)","title":"Fields"},"description":"Comma-separated fields to include (default: all)"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/stocks/{symbol}":{"get":{"summary":"Get Stock","description":"Get full data for a single stock.\n\nEnriches with `concentrations` + `edgar_8k_events` from the stock-intel\nskill's JSON output at request time (mtime-cached, sub-ms). This avoids\npushing those fields through publish.py / SQLite — see\n`plans/stock-intel-skill.md` section 12.\n\nCached as raw bytes per (symbol, run_id) via `_stock_cached_bytes`.\nCache HIT returns the pre-serialized response with zero `json.loads`\nof `data_json` — eliminates the ~10K Python allocations per request\nthat drove the 2026-05-06/07 memory canary fires (parse-and-throw at\nAPI boundary, see `plans/structural-parse-and-throw-fix.md`).","operationId":"get_stock_api_stocks__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/stocks/{symbol}/verdict-history":{"get":{"summary":"Get Stock Verdict History","description":"Reverse-chrono verdict history for a single stock.\n\nReads from the `verdict_history` table (populated on every nightly snapshot\nand on refresh when verdict/score/targets change). Public endpoint — powers\nthe \"Verdict History\" section on the stock detail page.\n\ngrowth/102 — the per-row `narrative` field is suppressed unless\n`TM_VERDICT_NARRATIVE_PUBLIC=1` is set in the environment. Default\nOFF protects users from any narrative that hasn't been admin-reviewed\nfor hallucinations. Admin /api/cx/{slug}/narratives is unaffected\n(always returns narratives — that's the review surface).","operationId":"get_stock_verdict_history_api_v1_stocks__symbol__verdict_history_get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/verdict-changes":{"get":{"summary":"Get Recent Verdict Changes","description":"Recent verdict-flip rows across the universe.\n\nPowers the Verdict Changes 24h zone on the home page (HOME-6).\nReturns rows where the verdict changed since the last write within\nthe requested window. Each row carries the symbol, name, detected\ntimestamp, current + previous verdict, and current + previous score.","operationId":"get_recent_verdict_changes_api_v1_verdict_changes_get","parameters":[{"name":"hours","in":"query","required":false,"schema":{"type":"integer","maximum":168,"minimum":1,"default":24,"title":"Hours"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/methodology/verdict-distribution":{"get":{"summary":"Get Methodology Verdict Distribution","description":"Verdict distribution + sparkline + avg-tenure for the methodology panel.\n\nPure aggregation on existing tables; no new compute path. The `today`\nblock is the latest run's verdict counts; `history` is one entry per\nretained nightly snapshot ordered chronologically; `avg_tenure_days` is\nthe mean number of days a verdict label persists before flipping.\n\nHistory depth is bounded by `keep_nightly_days` retention (currently\n14 days) — `months` is honored as a ceiling but the response will not\nextend past whatever nightlies are still on disk.","operationId":"get_methodology_verdict_distribution_api_v1_methodology_verdict_distribution_get","parameters":[{"name":"months","in":"query","required":false,"schema":{"type":"integer","maximum":24,"minimum":1,"default":12,"title":"Months"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/stocks/{symbol}/peers":{"get":{"summary":"Get Peers","description":"Get peer stocks in the same industry + similar market cap tier (SQL-optimized).\n\nReads only `industry`/`sector`/`market_cap` from the stocks table —\nthese live as physical columns, so the lookup avoids `json.loads` of\nthe full `data_json` blob that the prior `db.get_stock(symbol)` path\ntriggered for every peers request. Part of the structural fix\n(`plans/structural-parse-and-throw-fix.md`, Phase 1).","operationId":"get_peers_api_stocks__symbol__peers_get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":20,"minimum":1,"default":8,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/verdicts/{verdict}":{"get":{"summary":"Get By Verdict","description":"Get stocks by verdict type.","operationId":"get_by_verdict_api_verdicts__verdict__get","parameters":[{"name":"verdict","in":"path","required":true,"schema":{"type":"string","title":"Verdict"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":3000,"minimum":1,"default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/sectors":{"get":{"summary":"Get Sectors","description":"Sector breakdown with average scores.\n\nFilters out the synthetic \"Unknown\" bucket — stocks land there when\nyfinance/finnhub provide no sector classification, but the bucket has\nno corresponding rollup page (slug \"unknown\" → 404 by design — there's\nno industry primer or coherent thesis for an unclassified group).\nShowing it in the dropdown promised navigation we couldn't honor.\nThe 5-7 affected stocks are still served by `/api/stocks` itself.\n\ngrowth/111: disk-cache fast path via publish.py; falls through to the\nlegacy `_sectors_cached` LRU on cache miss.","operationId":"get_sectors_api_sectors_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/industry_primers/manifest":{"get":{"summary":"Get Industry Primers Manifest","description":"Index of all sector + industry primers (slug, name, count, primer_short).\n\nEnriched with buy_count, sell_count, avg_score per entry when the DB\nhas data — surfaces signal density on the /sectors and /industries\nhub pages so users can scan \"where are the BUYs?\" at a glance\n(FINDING-011 from 2026-04-25 design audit).","operationId":"get_industry_primers_manifest_api_industry_primers_manifest_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/sectors/{slug}/rollup":{"get":{"summary":"Get Sector Rollup","operationId":"get_sector_rollup_api_sectors__slug__rollup_get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/industries/{slug}/rollup":{"get":{"summary":"Get Industry Rollup","operationId":"get_industry_rollup_api_industries__slug__rollup_get","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","title":"Slug"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/search":{"get":{"summary":"Search","description":"Search stocks by symbol or name.","operationId":"search_api_search_get","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":1,"title":"Q"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/news":{"get":{"summary":"Get News","description":"Pre-ranked news from the latest analysis run (paginated).\n\nReads from the `top_news` SQL table directly with LIMIT/OFFSET. No\nPython-side cache, no full-array json.loads — paginated SELECT\nreturns only the requested window. `db.get_top_news` resolves\nrun_id internally via `get_latest_run_id()` (cheap indexed SELECT).\n\ngrowth/111: publish.py prebuilds the default page (limit=200, offset=0)\nunder `data/api_cache/news_l200_o0.json`. Any other pagination shape\nfalls through to live build (uncommon — dashboard polling uses default).","operationId":"get_news_api_news_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":2000,"minimum":1,"default":200,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/movers":{"get":{"summary":"Get Movers","description":"Today's top gainers and losers by daily price change.\n\ngrowth/111: disk-cache fast path via publish.py; falls through to\n`_movers_cached` LRU on cache miss.","operationId":"get_movers_api_movers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"EventPayload":{"properties":{"v":{"type":"integer","title":"V","description":"Schema version, must be 1"},"uid":{"type":"string","minLength":1,"title":"Uid","description":"Client visitor ID (tm_uid ULID)"},"ts":{"type":"string","minLength":1,"title":"Ts","description":"ISO 8601 timestamp from client"},"path":{"type":"string","minLength":1,"title":"Path","description":"Page path (e.g., /stocks/NVDA)"},"event":{"type":"string","minLength":1,"title":"Event","description":"Event name (e.g., page_view)"},"props":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Props","description":"Optional properties"}},"type":"object","required":["v","uid","ts","path","event"],"title":"EventPayload"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}}}}