From 1d087b54a43d3f49da5f5ec77ceb8fde31fe9b1c Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Wed, 6 May 2026 16:39:03 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20day=5Fgainers=205=EB=B6=84=EB=B4=89=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20+=20News=20v2=20ingest=20+=20overlay=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경사항: gainer snapshot (신규) - gainer_snapshots 테이블: 5분 단위 intraday top-100 day_gainers 스냅샷 - APScheduler CronTrigger (*/5 min, Mon-Fri ET) + 장중 guard (9:30~16:00) - alembic migration p7g8h9i0j1k2 /stocks/gainers 엔드포인트 (신규) - yfinance day_gainers preset, 실시간(캐시 없음) - yfinance_plus screen() wrapper — 모든 screen() 호출에 세션 풀 + 브라우저 지문 우회 적용 News v2 ingest (신규) - Alpaca News + StockTwits + Finnhub 수집 파이프라인 - news_headlines 테이블 + scheduler (5분 realtime poll, 일 1회 backfill) Overlay 정리 - 미사용 서브시스템 제거: wikimedia, youtube, google_trends, feature_builder, overlay_scorer - Yahoo RSS 어댑터 유지, 파이프라인 단순화 Co-Authored-By: Claude Sonnet 4.6 --- API_DOCUMENTATION.md | 260 +++++++ CHANGELOG.md | 26 + .../n5d6e7f8h9i0_add_news_headline_table.py | 54 ++ ...o6e7f8g9j0a1_drop_unused_overlay_tables.py | 43 ++ .../p7g8h9i0j1k2_add_gainer_snapshots.py | 52 ++ app/api/v1/api.py | 3 +- app/api/v1/endpoints/news_v2.py | 392 +++++++++++ app/api/v1/endpoints/overlay.py | 637 ++---------------- app/api/v1/endpoints/stocks.py | 24 + app/core/config.py | 19 +- app/core/overlay_config.py | 79 +-- app/main.py | 28 +- app/models/__init__.py | 21 +- app/models/gainer_snapshot.py | 43 ++ app/models/news_headline.py | 65 ++ app/models/overlay_feature.py | 62 +- app/models/overlay_raw_event.py | 65 +- app/models/overlay_registry.py | 53 +- app/schemas/overlay.py | 210 ------ app/services/gainers/__init__.py | 0 app/services/gainers/collector.py | 59 ++ app/services/gainers/scheduler.py | 81 +++ app/services/insider_transaction_service.py | 73 +- app/services/news/__init__.py | 0 app/services/news/alpaca_news_client.py | 208 ++++++ app/services/news/category_normalizer.py | 190 ++++++ app/services/news/finnhub_client.py | 111 +++ app/services/news/headline_ingest_service.py | 222 ++++++ app/services/news/scheduler.py | 310 +++++++++ app/services/news/session_aggregator.py | 227 +++++++ app/services/news/session_window.py | 150 +++++ app/services/news/stocktwits_client.py | 96 +++ app/services/news/stocktwits_universe.py | 216 ++++++ app/services/overlay/entity_resolver.py | 7 +- app/services/overlay/feature_builder.py | 285 -------- app/services/overlay/finra_overlay_loader.py | 85 --- app/services/overlay/google_trends_adapter.py | 96 --- app/services/overlay/overlay_pipeline.py | 237 +------ app/services/overlay/overlay_scorer.py | 104 --- app/services/overlay/scheduler.py | 39 +- app/services/overlay/wikimedia_adapter.py | 107 --- app/services/overlay/yahoo_rss_adapter.py | 212 +++--- app/services/overlay/youtube_adapter.py | 170 ----- app/services/screener_service.py | 66 +- app/services/sec_http_client.py | 5 +- app/services/sec_ingest/scheduler.py | 27 +- app/services/universe_service.py | 4 +- docker-compose.yml | 2 + docs/DATA_COVERAGE.md | 79 ++- docs/PYTHON_CLIENT.md | 65 ++ docs/openapi.json | 2 +- requirements-api.txt | 3 + scripts/news_backfill.py | 163 +++++ scripts/seed_company_aliases.py | 111 +++ stock_oracle_client.py | 99 ++- tests/test_news_v2_category_normalizer.py | 70 ++ tests/test_news_v2_ingest_transforms.py | 134 ++++ tests/test_news_v2_session_window.py | 95 +++ 58 files changed, 4016 insertions(+), 2330 deletions(-) create mode 100644 alembic/versions/n5d6e7f8h9i0_add_news_headline_table.py create mode 100644 alembic/versions/o6e7f8g9j0a1_drop_unused_overlay_tables.py create mode 100644 alembic/versions/p7g8h9i0j1k2_add_gainer_snapshots.py create mode 100644 app/api/v1/endpoints/news_v2.py create mode 100644 app/models/gainer_snapshot.py create mode 100644 app/models/news_headline.py delete mode 100644 app/schemas/overlay.py create mode 100644 app/services/gainers/__init__.py create mode 100644 app/services/gainers/collector.py create mode 100644 app/services/gainers/scheduler.py create mode 100644 app/services/news/__init__.py create mode 100644 app/services/news/alpaca_news_client.py create mode 100644 app/services/news/category_normalizer.py create mode 100644 app/services/news/finnhub_client.py create mode 100644 app/services/news/headline_ingest_service.py create mode 100644 app/services/news/scheduler.py create mode 100644 app/services/news/session_aggregator.py create mode 100644 app/services/news/session_window.py create mode 100644 app/services/news/stocktwits_client.py create mode 100644 app/services/news/stocktwits_universe.py delete mode 100644 app/services/overlay/feature_builder.py delete mode 100644 app/services/overlay/finra_overlay_loader.py delete mode 100644 app/services/overlay/google_trends_adapter.py delete mode 100644 app/services/overlay/overlay_scorer.py delete mode 100644 app/services/overlay/wikimedia_adapter.py delete mode 100644 app/services/overlay/youtube_adapter.py create mode 100644 scripts/news_backfill.py create mode 100644 scripts/seed_company_aliases.py create mode 100644 tests/test_news_v2_category_normalizer.py create mode 100644 tests/test_news_v2_ingest_transforms.py create mode 100644 tests/test_news_v2_session_window.py diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 136169b..24e87ec 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -389,6 +389,60 @@ GET /stocks/index/foo → 400 Bad Request --- +#### `GET /stocks/gainers` +오늘의 상승 상위 종목 (Yahoo Finance `day_gainers` preset). + +**조건**: 등락률 >3%, 시총 ≥$2B, 주가 ≥$5, 거래량 >15,000. 등락률 내림차순 정렬. + +**캐시 없음** — 실시간 데이터. + +**Query Parameters:** +- `page`: 페이지 번호 (1-based, default: 1) +- `page_size`: 페이지당 결과 수 (1–250, default: 25) + +**Response:** +```json +{ + "stocks": [ + { + "symbol": "HUT", + "name": "Hut 8 Corp.", + "exchange": "NASDAQ", + "market_cap": 3200000000, + "price": 106.46, + "change_percent": 32.26, + "volume": 18500000, + "avg_volume_3m": 4200000, + "pe_ratio": null, + "fifty_two_week_high": 138.0, + "fifty_two_week_low": 10.5 + } + ], + "total_available": 25, + "returned_count": 25, + "page": 1, + "page_size": 25, + "total_pages": 1, + "query_time_seconds": 1.82, + "metadata": { + "preset": "day_gainers", + "source": "yfinance_screen_preset" + } +} +``` + +**Examples:** +``` +GET /stocks/gainers → 상위 25개 +GET /stocks/gainers?page_size=10 → 상위 10개 +GET /stocks/gainers?page=2 → 2페이지 +``` + +**Error Codes:** +- `503`: Yahoo Finance rate limit — 30–60초 후 재시도 + +--- + #### `GET /stocks/most-active` Most actively traded stocks from Yahoo Finance. @@ -422,6 +476,34 @@ GET /stocks/most-active?limit=50&force_refresh=true --- +### Stock Screener + +#### `GET /screener/stocks` +조건 기반 종목 필터링 (EquityQuery). + +**Query Parameters:** +- `market_cap_min` / `market_cap_max`: 시총 범위 (USD) +- `exchange`: `NYSE`, `NASDAQ`, `AMEX`, `NYSE_ARCA` (콤마 구분) +- `min_avg_volume`: 3개월 평균 거래량 최솟값 +- `exclude_types`: 제외할 종목 유형 (예: `ETF,FUND`) +- `sector`: 섹터 (예: `Technology`, `Healthcare`) +- `pe_min` / `pe_max`: Trailing P/E 범위 +- `price_min` / `price_max`: 주가 범위 +- `page` / `page_size`: 페이지네이션 (max 250) +- `sort_by`: `market_cap`, `volume`, `price`, `change_percent` 등 +- `sort_ascending`: `true`/`false` (default: `false`) +- `force_refresh`: 캐시 무시 + +**Caching:** Redis 5분 + +**Examples:** +``` +GET /screener/stocks?market_cap_min=500000000&exchange=NYSE,NASDAQ&exclude_types=ETF,FUND +GET /screener/stocks?sector=Technology&pe_max=30&sort_by=change_percent +``` + +--- + ### News & Social Media 🆕 #### `GET /news/{ticker}` @@ -495,6 +577,184 @@ Social media posts only. --- +### News v2 — Multi-source Headlines & Session Aggregates 🆕 + +Structured, persisted, multi-source news/social ingest designed for backtest +and forward-test consumers (e.g. fithia2 V49 ORB). Distinct from the legacy +`/news/{ticker}` aggregator above, which is on-demand and not persisted. + +**Sources** — all enabled via `NEWS_INGEST_ENABLED=true` in `.env`: + +| Source | Status | History | Rate limit | Sentiment | +|---|---|---|---|---| +| `alpaca_benzinga` | P0 | ~30 days vendor cap (cumulative archive built daily) | 200 req/min | None on free tier | +| `stocktwits` | P1 | rolling | 200 req/hr/IP | Bullish/Bearish tag → ±1 | +| `finnhub` | P2 | ~12 months vendor cap | 60 calls/min free | None | +| `gdelt` | (separate) | 2017+ | varies | None | + +**Unified taxonomy** — vendor categories are normalized to a 22-term enum: +`analyst_rating_upgrade`, `analyst_rating_downgrade`, `analyst_rating_initiate`, +`earnings_release`, `earnings_preannouncement`, `guidance_update`, +`m_and_a`, `partnership`, `contract_award`, +`fda_approval`, `fda_rejection`, `clinical_trial`, +`litigation`, `regulatory_action`, `sec_filing`, +`insider_trading`, `secondary_offering`, `buyback`, +`management_change`, `restructuring`, `general`. + +Original vendor labels are preserved on `vendor_categories`. + +**Session windows** (NYSE / `pandas_market_calendars` XNYS, holidays + early closes honored): +- `premarket` = previous session's close → today 09:30 ET +- `intraday` = 09:30 → 16:00 ET +- `post` = 16:00 ET → next trading day 04:00 ET (disjoint from next premarket) +- `full_session` = previous close → next trading day 04:00 ET + +**PIT safety**: aggregates filter `ingested_at <= window_end_utc`, so backtests +never see headlines that arrived after the window closed in real time. + +#### `GET /news/v2/headlines` +Raw multi-source rows. + +**Query parameters:** +- `symbols` — CSV ticker list (max 50) +- `start`, `end` — UTC ISO datetime +- `sources` — CSV filter (subset of source names) +- `limit` — 1–500 (default 100) +- `cursor` — `published_at_lt` ISO datetime for pagination + +**Response:** +```json +{ + "items": [ + { + "source": "alpaca_benzinga", + "source_id": "12345", + "ticker": "AAPL", + "tickers_all": ["AAPL", "MSFT"], + "published_at": "2026-04-25T13:30:00+00:00", + "headline": "Apple announces $90B buyback", + "summary": "...", + "url": "https://...", + "language": "en", + "vendor_categories": ["Buybacks"], + "categories": ["buyback"], + "raw_sentiment": null, + "is_primary": true, + "ingested_at": "2026-04-25T13:32:11+00:00" + } + ], + "next_cursor": "2026-04-25T13:30:00+00:00" +} +``` + +#### `GET /news/v2/session_aggregate` +One-ticker, one-window aggregate. Redis-cached (10 min current session, 1 day past). + +**Query parameters:** +- `symbol` (required) +- `session_date` (required) — ET date YYYY-MM-DD +- `window` — premarket | intraday | post | full_session (default premarket) +- `sources` — CSV filter (optional) +- `force_refresh` — bypass cache + +**Response:** see batch response below (single object, not wrapped in `items`). + +#### `POST /news/v2/session_aggregate/batch` +Many tickers in one call (V49's hot path — 20 ticker batch per session). +**No server-side cache** — fithia2 maintains a client-side disk cache as the +primary defense; Oracle absorbs only burst load. + +**Body:** +```json +{ + "session_date": "2026-04-25", + "window": "premarket", + "symbols": ["AAPL", "MSFT", "NVDA"], + "sources": ["alpaca_benzinga", "stocktwits"] +} +``` + +**Response:** +```json +{ + "items": { + "AAPL": { + "ticker": "AAPL", + "session_date": "2026-04-25", + "window": "premarket", + "headline_count": 7, + "primary_count": 4, + "first_headline_at": "2026-04-24T20:15:00+00:00", + "last_headline_at": "2026-04-25T13:01:55+00:00", + "category_counts": { + "analyst_rating_upgrade": 2, + "earnings_release": 1, + "guidance_update": 1, + "general": 3 + }, + "sentiment_mean": 0.31, + "sentiment_recency_weighted": 0.45, + "social": { + "message_count": 142, + "bull_count": 98, + "bear_count": 31, + "bull_bear_ratio": 0.7597 + }, + "sources_present": ["alpaca_benzinga", "stocktwits"] + }, + "MSFT": { "...": "..." } + } +} +``` + +Tickers with no matching headlines are returned with zero-counts (not omitted). + +#### `GET /news/v2/coverage` +Per-source ingest depth probe. Use before backtest window selection to confirm +the historical archive is deep enough. + +**Query parameters:** +- `source` (required) — one of the source names +- `symbol` — optional ticker filter + +**Response:** +```json +{ + "source": "alpaca_benzinga", + "symbol": "AAPL", + "earliest": "2026-03-26T00:00:00+00:00", + "latest": "2026-04-25T13:42:00+00:00", + "ingested_count": 31204 +} +``` + +#### Operational notes + +- **Ingest is opt-in.** `NEWS_INGEST_ENABLED=false` (default) leaves the + scheduler off; endpoints still work and return empty results until data + flows in. +- **Fail-fast.** When `NEWS_INGEST_ENABLED=true` but neither + `ALPACA_API_KEY/SECRET` nor `FINNHUB_API_KEY` is set, the scheduler + refuses to start (the StockTwits-only configuration is too low-signal to + run silently). +- **Historical backfill.** Alpaca News only exposes the last ~30 days, so + cumulative depth is built by the daily backfill job from the moment ingest + is enabled. Finnhub's 12-month archive is loaded once via: + ```bash + docker exec stock_oracle_api python scripts/news_backfill.py \ + --source finnhub \ + --tickers AAPL,MSFT,NVDA \ + --start 2025-04-26 --end 2026-04-26 \ + --chunk monthly + ``` +- **StockTwits universe.** Computed daily at 09:00 ET as + `(last 14 days of UniverseSnapshot active tickers) ∪ (today's premarket + gap movers > STOCKTWITS_PREMARKET_GAP_THRESHOLD)`, capped at + `STOCKTWITS_UNIVERSE_MAX_SIZE` (default 300). Pollers read from Redis + key `news_v2:stocktwits:universe`. + +--- + ### ETF Holdings Temporarily unavailable. The ETF API is being redesigned. Previous endpoints under `/etf/*` have been removed and will return 404. See docs/ETF_API.md for historical reference only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cb35d6..870efca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to Stock Oracle API will be documented in this file. +## [3.1.1] - 2026-04-26 + +### Changed (breaking — News v2 only, pre-GA) +- **`SessionAggregateItem` social fields nested**: `social_message_count` / `social_bull_count` / `social_bear_count` removed; replaced by nested `social: {message_count, bull_count, bear_count, bull_bear_ratio}`. Adds derived `bull_bear_ratio = bull/(bull+bear)` (null when no directional messages). Restores spec compliance — fithia2 integration test caught the deviation pre-GA, no client traffic yet. + +## [3.1.0] - 2026-04-26 + +### Added +- **News v2 — multi-source structured ingest** (`/api/v1/news/v2/*`): premium news/social signal designed for backtest/forward-test consumers (fithia2 V49 ORB). + - Sources: Alpaca News (Benzinga backend, P0), StockTwits public API (P1), Finnhub free tier (P2). Existing `/news/{ticker}` aggregator unchanged for UI use. + - `GET /news/v2/headlines` — raw rows with symbols/start/end/sources/limit/cursor + - `GET /news/v2/session_aggregate` — single (ticker, session_date, window) Redis-cached aggregate + - `POST /news/v2/session_aggregate/batch` — many tickers in one call (no server cache; client disk-cache assumed) + - `GET /news/v2/coverage` — per-source ingest depth probe +- **Unified 22-term category taxonomy** with regex headline overrides for FDA approval/rejection split, analyst rating direction, etc. +- **Session windows** via `pandas_market_calendars` (XNYS) — premarket/intraday/post/full_session with NYSE holiday + early-close handling. `post` ends at next trading day's premarket start (04:00 ET) to remain disjoint from next session's premarket. +- **PIT safety**: aggregates filter `ingested_at <= window_end_utc` so backtests don't see lookahead headlines. +- **APScheduler jobs** (opt-in via `NEWS_INGEST_ENABLED=true`): Alpaca 5-min poll + 04:30 ET daily backfill, StockTwits 09:00 ET universe refresh + 5-min poll, Finnhub 05:00 ET daily backfill. +- **Fail-fast**: `NEWS_INGEST_ENABLED=true` with neither `ALPACA_API_KEY/SECRET` nor `FINNHUB_API_KEY` → scheduler refuses to start (StockTwits-only is too low-signal). +- **StockTwits dynamic universe**: `(last 14 days V49 union) ∪ (today's premarket gap movers > 2%)`, capped at 300 tickers. +- **Manual backfill script**: `scripts/news_backfill.py --source finnhub --tickers ... --start --end --chunk monthly`. +- **Stock Oracle Python client**: `get_news_headlines`, `get_news_session_aggregate`, `get_news_session_aggregate_batch`, `get_news_coverage`. +- **New table**: `news_headline` (UUID PK, ARRAY columns, `(source, source_id, ticker)` unique constraint for idempotent multi-source ingest). +- **New env vars**: `NEWS_INGEST_ENABLED`, `NEWS_INGEST_TIMEZONE`, `ALPACA_NEWS_BASE_URL`, `FINNHUB_API_KEY`, `FINNHUB_BASE_URL`, `STOCKTWITS_BASE_URL`, `STOCKTWITS_UNIVERSE_LOOKBACK_DAYS`, `STOCKTWITS_PREMARKET_GAP_THRESHOLD`, `STOCKTWITS_UNIVERSE_MAX_SIZE`. +- **New dependency**: `pandas_market_calendars>=4.3.0`. + ## [3.0.3] - 2026-03-18 ### Fixed diff --git a/alembic/versions/n5d6e7f8h9i0_add_news_headline_table.py b/alembic/versions/n5d6e7f8h9i0_add_news_headline_table.py new file mode 100644 index 0000000..bb32298 --- /dev/null +++ b/alembic/versions/n5d6e7f8h9i0_add_news_headline_table.py @@ -0,0 +1,54 @@ +"""add news_headline table + +Revision ID: n5d6e7f8h9i0 +Revises: m4e5f6g7h8i9 +Create Date: 2026-04-25 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "n5d6e7f8h9i0" +down_revision: Union[str, Sequence[str], None] = "m4e5f6g7h8i9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + conn = op.get_bind() + + if not conn.dialect.has_table(conn, "news_headline"): + op.create_table( + "news_headline", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("source", sa.String(30), nullable=False), + sa.Column("source_id", sa.String(100), nullable=False), + sa.Column("ticker", sa.String(10), nullable=False), + sa.Column("tickers_all", postgresql.ARRAY(sa.String(10)), nullable=True), + sa.Column("published_at", postgresql.TIMESTAMP(timezone=True), nullable=False), + sa.Column("headline", sa.Text(), nullable=False), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("url", sa.Text(), nullable=True), + sa.Column("language", sa.String(8), server_default="en"), + sa.Column("vendor_categories", postgresql.ARRAY(sa.String(50)), nullable=True), + sa.Column("categories", postgresql.ARRAY(sa.String(50)), nullable=True), + sa.Column("raw_sentiment", sa.Float(), nullable=True), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("ingested_at", postgresql.TIMESTAMP(timezone=True), nullable=False), + sa.Column("created_at", postgresql.TIMESTAMP(timezone=True)), + sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True)), + sa.UniqueConstraint( + "source", "source_id", "ticker", + name="uq_news_headline_source_ticker", + ), + ) + op.create_index("idx_news_ticker_published", "news_headline", ["ticker", "published_at"]) + op.create_index("idx_news_published", "news_headline", ["published_at"]) + op.create_index("idx_news_source_published", "news_headline", ["source", "published_at"]) + op.create_index("idx_news_ingested", "news_headline", ["ingested_at"]) + + +def downgrade() -> None: + op.drop_table("news_headline") diff --git a/alembic/versions/o6e7f8g9j0a1_drop_unused_overlay_tables.py b/alembic/versions/o6e7f8g9j0a1_drop_unused_overlay_tables.py new file mode 100644 index 0000000..fc57a9e --- /dev/null +++ b/alembic/versions/o6e7f8g9j0a1_drop_unused_overlay_tables.py @@ -0,0 +1,43 @@ +"""drop unused overlay tables + +Revision ID: o6e7f8g9j0a1 +Revises: n5d6e7f8h9i0 +Create Date: 2026-04-26 + +Removes overlay subsystem tables that are no longer fed (wikimedia / youtube / +google_trends / feature_build / scoring were retired). Keeps: + - overlay_headline_events + - overlay_job_log + - company_aliases +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "o6e7f8g9j0a1" +down_revision: Union[str, Sequence[str], None] = "n5d6e7f8h9i0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +_TABLES_TO_DROP = [ + "overlay_feature_records", + "overlay_video_events", + "overlay_wiki_pageviews", + "overlay_trend_observations", + "youtube_channel_registry", + "wiki_page_map", + "theme_topic_map", +] + + +def upgrade() -> None: + conn = op.get_bind() + for tbl in _TABLES_TO_DROP: + if conn.dialect.has_table(conn, tbl): + op.drop_table(tbl) + + +def downgrade() -> None: + # One-way drop; recreate would require restoring the deleted models. + pass diff --git a/alembic/versions/p7g8h9i0j1k2_add_gainer_snapshots.py b/alembic/versions/p7g8h9i0j1k2_add_gainer_snapshots.py new file mode 100644 index 0000000..5d18ad5 --- /dev/null +++ b/alembic/versions/p7g8h9i0j1k2_add_gainer_snapshots.py @@ -0,0 +1,52 @@ +"""add gainer_snapshots table + +Revision ID: p7g8h9i0j1k2 +Revises: o6e7f8g9j0a1 +Create Date: 2026-05-06 + +5-minute intraday snapshots of Yahoo Finance day_gainers for backtesting. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "p7g8h9i0j1k2" +down_revision: Union[str, Sequence[str], None] = "o6e7f8g9j0a1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "gainer_snapshots", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("snapshot_at", sa.TIMESTAMP(timezone=True), nullable=False), + sa.Column("rank", sa.Integer(), nullable=False), + sa.Column("symbol", sa.String(10), nullable=False), + sa.Column("name", sa.Text(), nullable=True), + sa.Column("exchange", sa.String(20), nullable=True), + sa.Column("price", sa.Float(), nullable=True), + sa.Column("change_percent", sa.Float(), nullable=True), + sa.Column("volume", sa.BigInteger(), nullable=True), + sa.Column("avg_volume_3m", sa.BigInteger(), nullable=True), + sa.Column("market_cap", sa.BigInteger(), nullable=True), + sa.Column("pe_ratio", sa.Float(), nullable=True), + sa.Column("forward_pe", sa.Float(), nullable=True), + sa.Column("eps_ttm", sa.Float(), nullable=True), + sa.Column("dividend_yield", sa.Float(), nullable=True), + sa.Column("fifty_two_week_high", sa.Float(), nullable=True), + sa.Column("fifty_two_week_low", sa.Float(), nullable=True), + sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("snapshot_at", "symbol", name="uq_gainer_snapshot_symbol"), + ) + op.create_index("idx_gainer_snapshot_at", "gainer_snapshots", ["snapshot_at"]) + op.create_index("idx_gainer_symbol_snapshot", "gainer_snapshots", ["symbol", "snapshot_at"]) + + +def downgrade() -> None: + op.drop_index("idx_gainer_symbol_snapshot", table_name="gainer_snapshots") + op.drop_index("idx_gainer_snapshot_at", table_name="gainer_snapshots") + op.drop_table("gainer_snapshots") diff --git a/app/api/v1/api.py b/app/api/v1/api.py index ff9cbd2..1adac2e 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -3,7 +3,7 @@ API v1 router """ from fastapi import APIRouter -from app.api.v1.endpoints import financial, price, catalog, health, migration, database, error_logs, request_logs, news, etf, stocks, fred, filings, alpaca, finra, overlay, screener, attention, insider, earnings, universe, dividends, company, ownership +from app.api.v1.endpoints import financial, price, catalog, health, migration, database, error_logs, request_logs, news, news_v2, etf, stocks, fred, filings, alpaca, finra, overlay, screener, attention, insider, earnings, universe, dividends, company, ownership api_router = APIRouter() @@ -14,6 +14,7 @@ api_router.include_router(price.router, prefix="/price", tags=["price"]) api_router.include_router(stocks.router, prefix="/stocks", tags=["stocks"]) api_router.include_router(fred.router, prefix="/fred", tags=["fred"]) api_router.include_router(news.router, prefix="/news", tags=["news"]) +api_router.include_router(news_v2.router, prefix="/news/v2", tags=["news-v2"]) api_router.include_router(etf.router, prefix="/etf", tags=["etf"]) api_router.include_router(filings.router, prefix="/filings", tags=["filings"]) api_router.include_router(catalog.router, prefix="/metadata", tags=["metadata"]) diff --git a/app/api/v1/endpoints/news_v2.py b/app/api/v1/endpoints/news_v2.py new file mode 100644 index 0000000..610041a --- /dev/null +++ b/app/api/v1/endpoints/news_v2.py @@ -0,0 +1,392 @@ +""" +News v2 endpoints — multi-source headline + session aggregate API. + +Mounted at `/api/v1/news/v2/*` to avoid conflict with the legacy +`GET /news/{ticker}` aggregator (which uses a wildcard path). + +Endpoints: + GET /headlines raw headline rows + GET /session_aggregate single (ticker, session_date, window) + POST /session_aggregate/batch many tickers in one shot + GET /coverage per-source ingest depth probe +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import date, datetime, timezone +from typing import Optional + +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from fastapi.responses import Response +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.news_headline import NewsHeadline +from app.services.news.session_aggregator import ( + aggregate_session, + aggregate_session_batch, +) +from app.services.news.session_window import session_window +from app.utils.cache import with_cache + +logger = logging.getLogger("app.api.v1.news_v2") + +router = APIRouter() + +# Concurrency gates — prevent event-loop saturation under bulk scans +_HEADLINES_SEMAPHORE = asyncio.Semaphore(8) +_AGG_SEMAPHORE = asyncio.Semaphore(8) +_SEMAPHORE_WAIT_TIMEOUT = 10.0 + +_VALID_SOURCES = {"alpaca_benzinga", "stocktwits", "finnhub", "gdelt"} +_VALID_WINDOWS = {"premarket", "intraday", "post", "full_session"} + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + +class HeadlineItem(BaseModel): + source: str + source_id: str + ticker: str + tickers_all: list[str] | None = None + published_at: str + headline: str + summary: str | None = None + url: str | None = None + language: str | None = None + vendor_categories: list[str] | None = None + categories: list[str] | None = None + raw_sentiment: float | None = None + is_primary: bool + ingested_at: str + + +class HeadlinesResponse(BaseModel): + items: list[HeadlineItem] + next_cursor: str | None = None + + +class SocialStatsItem(BaseModel): + message_count: int = 0 + bull_count: int = 0 + bear_count: int = 0 + bull_bear_ratio: float | None = None + + +class SessionAggregateItem(BaseModel): + ticker: str + session_date: str + window: str + headline_count: int = 0 + primary_count: int = 0 + first_headline_at: str | None = None + last_headline_at: str | None = None + category_counts: dict[str, int] = Field(default_factory=dict) + sentiment_mean: float | None = None + sentiment_recency_weighted: float | None = None + social: SocialStatsItem = Field(default_factory=SocialStatsItem) + sources_present: list[str] = Field(default_factory=list) + + +class SessionAggregateBatchRequest(BaseModel): + session_date: date + window: str + symbols: list[str] + sources: list[str] | None = None + + @field_validator("window") + @classmethod + def _validate_window(cls, v: str) -> str: + if v not in _VALID_WINDOWS: + raise ValueError(f"window must be one of {sorted(_VALID_WINDOWS)}") + return v + + @field_validator("symbols") + @classmethod + def _validate_symbols(cls, v: list[str]) -> list[str]: + if not v: + raise ValueError("symbols must not be empty") + if len(v) > 200: + raise ValueError("symbols max 200 per request") + return v + + +class SessionAggregateBatchResponse(BaseModel): + items: dict[str, SessionAggregateItem] + + +class CoverageResponse(BaseModel): + source: str + symbol: str | None = None + earliest: str | None = None + latest: str | None = None + ingested_count: int + + +# --------------------------------------------------------------------------- +# A. /headlines — raw rows +# --------------------------------------------------------------------------- + +@router.get( + "/headlines", + response_model=HeadlinesResponse, + summary="Raw multi-source news headlines", + description=( + "Multi-source raw headline rows. Filter by symbols, time window, and " + "source. Sources: `alpaca_benzinga`, `stocktwits`, `finnhub`, `gdelt`." + ), +) +@with_cache( + namespace="news_v2:headlines", + ttl=300, + key_params=["symbols", "start", "end", "sources", "limit", "cursor"], +) +async def get_headlines( + response: Response, + symbols: Optional[str] = Query(None, description="CSV ticker list, max 50 (e.g. AAPL,MSFT)"), + start: Optional[datetime] = Query(None, description="Start time (UTC ISO)"), + end: Optional[datetime] = Query(None, description="End time (UTC ISO)"), + sources: Optional[str] = Query(None, description=f"CSV source filter, subset of {sorted(_VALID_SOURCES)}"), + limit: int = Query(100, ge=1, le=500), + cursor: Optional[str] = Query(None, description="published_at_lt cursor (ISO datetime)"), + force_refresh: bool = Query(False), + db: AsyncSession = Depends(get_db), +): + sym_list = _csv_to_list(symbols, max_items=50, kind="symbols") + src_list = _csv_to_list(sources, max_items=10, kind="sources") + if src_list: + bad = [s for s in src_list if s not in _VALID_SOURCES] + if bad: + raise HTTPException(400, f"Unknown sources: {bad}") + + cursor_dt = _parse_cursor(cursor) + + try: + await asyncio.wait_for(_HEADLINES_SEMAPHORE.acquire(), timeout=_SEMAPHORE_WAIT_TIMEOUT) + except asyncio.TimeoutError: + raise HTTPException(429, "Server busy — try again later") + try: + stmt = select(NewsHeadline).order_by(NewsHeadline.published_at.desc()).limit(limit) + if sym_list: + stmt = stmt.where(NewsHeadline.ticker.in_([s.upper() for s in sym_list])) + if start is not None: + stmt = stmt.where(NewsHeadline.published_at >= _ensure_utc(start)) + if end is not None: + stmt = stmt.where(NewsHeadline.published_at < _ensure_utc(end)) + if src_list: + stmt = stmt.where(NewsHeadline.source.in_(src_list)) + if cursor_dt is not None: + stmt = stmt.where(NewsHeadline.published_at < cursor_dt) + + result = await db.execute(stmt) + rows = result.scalars().all() + finally: + _HEADLINES_SEMAPHORE.release() + + items = [_row_to_item(r) for r in rows] + next_cursor = items[-1].published_at if len(items) == limit else None + return HeadlinesResponse(items=items, next_cursor=next_cursor) + + +# --------------------------------------------------------------------------- +# B. /session_aggregate — single ticker +# --------------------------------------------------------------------------- + +@router.get( + "/session_aggregate", + response_model=SessionAggregateItem, + summary="Session-aggregated news for one ticker", +) +@with_cache( + namespace="news_v2:session_agg", + ttl=600, + key_params=["symbol", "session_date", "window", "sources"], +) +async def get_session_aggregate( + response: Response, + symbol: str = Query(..., description="Ticker symbol"), + session_date: date = Query(..., description="ET session date (YYYY-MM-DD)"), + window: str = Query("premarket", description=f"One of {sorted(_VALID_WINDOWS)}"), + sources: Optional[str] = Query(None, description=f"CSV source filter, subset of {sorted(_VALID_SOURCES)}"), + force_refresh: bool = Query(False), + db: AsyncSession = Depends(get_db), +): + if window not in _VALID_WINDOWS: + raise HTTPException(400, f"window must be one of {sorted(_VALID_WINDOWS)}") + + src_list = _csv_to_list(sources, max_items=10, kind="sources") + if src_list: + bad = [s for s in src_list if s not in _VALID_SOURCES] + if bad: + raise HTTPException(400, f"Unknown sources: {bad}") + + try: + await asyncio.wait_for(_AGG_SEMAPHORE.acquire(), timeout=_SEMAPHORE_WAIT_TIMEOUT) + except asyncio.TimeoutError: + raise HTTPException(429, "Server busy — try again later") + try: + try: + agg = await aggregate_session( + db=db, + ticker=symbol, + session_date=session_date, + window=window, # type: ignore[arg-type] + sources=src_list, + ) + except ValueError as e: + raise HTTPException(400, str(e)) + finally: + _AGG_SEMAPHORE.release() + + return SessionAggregateItem(**agg.to_dict()) + + +# --------------------------------------------------------------------------- +# C. POST /session_aggregate/batch — many tickers +# --------------------------------------------------------------------------- + +@router.post( + "/session_aggregate/batch", + response_model=SessionAggregateBatchResponse, + summary="Session-aggregated news for many tickers in one call", + description=( + "Batch variant. Caching is intentionally NOT applied at this layer — " + "fithia2 maintains a client-side disk cache as the primary defense; " + "Oracle absorbs only burst load. Use the GET single endpoint for " + "Redis-cached single-ticker reads." + ), +) +async def post_session_aggregate_batch( + payload: SessionAggregateBatchRequest = Body(...), + db: AsyncSession = Depends(get_db), +): + src_list = payload.sources or None + if src_list: + bad = [s for s in src_list if s not in _VALID_SOURCES] + if bad: + raise HTTPException(400, f"Unknown sources: {bad}") + + try: + await asyncio.wait_for(_AGG_SEMAPHORE.acquire(), timeout=_SEMAPHORE_WAIT_TIMEOUT) + except asyncio.TimeoutError: + raise HTTPException(429, "Server busy — try again later") + try: + try: + results = await aggregate_session_batch( + db=db, + tickers=payload.symbols, + session_date=payload.session_date, + window=payload.window, # type: ignore[arg-type] + sources=src_list, + ) + except ValueError as e: + raise HTTPException(400, str(e)) + finally: + _AGG_SEMAPHORE.release() + + return SessionAggregateBatchResponse( + items={t: SessionAggregateItem(**a.to_dict()) for t, a in results.items()} + ) + + +# --------------------------------------------------------------------------- +# D. /coverage — per-source ingest depth +# --------------------------------------------------------------------------- + +@router.get( + "/coverage", + response_model=CoverageResponse, + summary="Per-source ingest coverage probe", +) +@with_cache( + namespace="news_v2:coverage", + ttl=300, + key_params=["source", "symbol"], +) +async def get_coverage( + response: Response, + source: str = Query(..., description=f"One of {sorted(_VALID_SOURCES)}"), + symbol: Optional[str] = Query(None, description="Optional ticker filter"), + force_refresh: bool = Query(False), + db: AsyncSession = Depends(get_db), +): + if source not in _VALID_SOURCES: + raise HTTPException(400, f"Unknown source: {source}") + + stmt = select( + func.min(NewsHeadline.published_at), + func.max(NewsHeadline.published_at), + func.count(NewsHeadline.id), + ).where(NewsHeadline.source == source) + if symbol: + stmt = stmt.where(NewsHeadline.ticker == symbol.strip().upper()) + + result = await db.execute(stmt) + row = result.one() + earliest, latest, count = row + + return CoverageResponse( + source=source, + symbol=symbol.strip().upper() if symbol else None, + earliest=earliest.isoformat() if earliest else None, + latest=latest.isoformat() if latest else None, + ingested_count=int(count or 0), + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _csv_to_list(s: str | None, *, max_items: int, kind: str) -> list[str]: + if not s: + return [] + items = [x.strip() for x in s.split(",") if x.strip()] + if len(items) > max_items: + raise HTTPException(400, f"{kind} max {max_items} per request") + return items + + +def _parse_cursor(cursor: str | None) -> datetime | None: + if not cursor: + return None + try: + if cursor.endswith("Z"): + cursor = cursor[:-1] + "+00:00" + dt = datetime.fromisoformat(cursor) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + except Exception: + raise HTTPException(400, "cursor must be ISO datetime") + + +def _ensure_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _row_to_item(r: NewsHeadline) -> HeadlineItem: + return HeadlineItem( + source=r.source, + source_id=r.source_id, + ticker=r.ticker, + tickers_all=list(r.tickers_all) if r.tickers_all else None, + published_at=_ensure_utc(r.published_at).isoformat(), + headline=r.headline, + summary=r.summary, + url=r.url, + language=r.language, + vendor_categories=list(r.vendor_categories) if r.vendor_categories else None, + categories=list(r.categories) if r.categories else None, + raw_sentiment=r.raw_sentiment, + is_primary=bool(r.is_primary), + ingested_at=_ensure_utc(r.ingested_at).isoformat(), + ) diff --git a/app/api/v1/endpoints/overlay.py b/app/api/v1/endpoints/overlay.py index 6a37c50..c2eefa1 100644 --- a/app/api/v1/endpoints/overlay.py +++ b/app/api/v1/endpoints/overlay.py @@ -1,348 +1,59 @@ """ -Overlay API endpoints - attention overlay scores for retail-investor interest signals. +Overlay API — headline ingestion only. -Route ordering is intentional: static paths (/bulk, /top-movers, /admin/*) -must be registered BEFORE the parameterized /{symbol} routes to prevent -FastAPI from treating those literal path segments as symbol values. +Scope was reduced: wikimedia / youtube / google_trends / finra / feature_build +were removed. Only the Yahoo RSS headline collector remains. """ -import asyncio -import logging from datetime import datetime, timedelta, timezone from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, Query -from fastapi.responses import Response +from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc, and_, func +from sqlalchemy import select, desc -from app.core.config import settings from app.core.database import get_db -from app.schemas.overlay import ( - OverlayScoreResponse, - BulkOverlayResponse, - TopMoversResponse, - OverlayTopMover, - HeadlinesResponse, - HeadlineItem, - YouTubeResponse, - VideoItem, - WikiResponse, - WikiPageviewPoint, - CrowdingResponse, - TrendsResponse, - TrendPoint, - OverlayHistoryResponse, - OverlayHistoryPoint, - AdminHealthResponse, - SourceHealthItem, - TriggerPipelineResponse, - JobLogResponse, - JobLogEntry, - OverlayFeatures, - OverlaySourcePresence, - OverlaySourceDetails, - YahooSourceDetail, - YouTubeSourceDetail, - WikiSourceDetail, - FinraSourceDetail, - OverlayMetadata, -) -from app.models.overlay_feature import OverlayFeatureRecord, OverlayJobLog -from app.models.overlay_raw_event import ( - OverlayHeadlineEvent, - OverlayVideoEvent, - OverlayWikiPageview, - OverlayTrendObservation, -) -from app.models.overlay_registry import ThemeTopicMap -from app.services.overlay.overlay_pipeline import OverlayPipeline -from app.utils.cache import with_cache +from app.models.overlay_feature import OverlayJobLog +from app.models.overlay_raw_event import OverlayHeadlineEvent -logger = logging.getLogger(__name__) router = APIRouter() -def _utc(dt) -> datetime: - """Ensure datetime is UTC-aware (SQLite returns naive datetimes).""" - if dt is None: - return dt - if isinstance(dt, datetime) and dt.tzinfo is None: - return dt.replace(tzinfo=timezone.utc) - return dt - -# Shared pipeline instance (stateless — safe to share) -_pipeline = OverlayPipeline() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _overlay_enabled() -> bool: - return getattr(settings, "OVERLAY_ENABLED", True) - - -def _record_to_response(record: OverlayFeatureRecord) -> OverlayScoreResponse: - """Convert an OverlayFeatureRecord ORM object to the API response schema.""" - mask = record.source_presence_mask or {} - - source_presence = OverlaySourcePresence( - yahoo=mask.get("yahoo", False), - youtube=mask.get("youtube", False), - wikimedia=mask.get("wikimedia", False), - google_trends=mask.get("google_trends", False), - finra=mask.get("finra", False), - ) - - features = OverlayFeatures( - headline_burst_z=record.headline_burst_z, - youtube_influence_z=record.youtube_influence_z, - wiki_attention_z=record.wiki_attention_z, - theme_heat_z=record.theme_heat_z, - crowding_stress_z=record.crowding_stress_z, - ) - - yahoo_detail = ( - YahooSourceDetail( - headline_count_6h=record.headline_count_6h or 0, - headline_count_24h=record.headline_count_24h or 0, - publisher_breadth_24h=record.publisher_breadth_24h or 0, - ) - if source_presence.yahoo - else None - ) - - yt_detail = ( - YouTubeSourceDetail( - mentions_24h=record.youtube_mentions_24h or 0, - weighted_views_24h=record.youtube_weighted_views_24h or 0.0, - ) - if source_presence.youtube - else None - ) - - wiki_detail = ( - WikiSourceDetail( - page_views_1d=record.wiki_views_1d, - page_views_7d_avg=record.wiki_views_7d_avg, - ) - if source_presence.wikimedia - else None - ) - - finra_detail = ( - FinraSourceDetail( - short_volume_ratio=record.short_volume_ratio, - short_volume_spike_zscore=record.short_volume_spike_zscore, - ) - if source_presence.finra - else None - ) - - source_details = OverlaySourceDetails( - yahoo=yahoo_detail, - youtube=yt_detail, - wikimedia=wiki_detail, - finra=finra_detail, - ) - - next_update = ( - record.as_of_ts + timedelta(hours=24) if record.as_of_ts else None - ) - - return OverlayScoreResponse( - symbol=record.symbol, - as_of_ts=record.as_of_ts, - overlay_score=record.overlay_score, - overlay_confidence=record.overlay_confidence, - overlay_band=record.overlay_band, - hold_extension_hint=record.hold_extension_hint, - add_on_eligibility=record.add_on_eligibility, - features=features, - source_presence=source_presence, - source_details=source_details, - metadata=OverlayMetadata( - feature_version=record.feature_version or "v1", - data_freshness=record.as_of_ts, - next_update_expected=next_update, - ), - ) - - -# =========================================================================== -# Static routes (MUST come before /{symbol} to avoid path shadowing) -# =========================================================================== +class HeadlineItem(BaseModel): + title: str + publisher: Optional[str] = None + published_at: datetime + article_guid: str -@router.get( - "/bulk", - response_model=BulkOverlayResponse, - summary="Bulk overlay scores", - description="Comma-separated symbols (max 50). Returns overlay scores for each.", -) -@with_cache(namespace="overlay:bulk", ttl=1800, key_params=["symbols"]) -async def get_bulk_overlay( - symbols: str, - response: Response, - force_refresh: bool = Query(False), - db: AsyncSession = Depends(get_db), -): - if not _overlay_enabled(): - raise HTTPException(status_code=503, detail="Overlay feature is disabled") - sym_list = [s.strip().upper() for s in symbols.split(",") if s.strip()] - if len(sym_list) > 50: - raise HTTPException(status_code=400, detail="Max 50 symbols per request") - if not sym_list: - raise HTTPException(status_code=400, detail="No valid symbols provided") +class HeadlinesResponse(BaseModel): + symbol: str + headlines: List[HeadlineItem] + headline_count_6h: int + headline_count_24h: int + publisher_breadth_24h: int - results = [] - for sym in sym_list: - record = await _pipeline.get_or_build(db, sym) - if record: - results.append(_record_to_response(record)) - return BulkOverlayResponse( - results=results, - total_count=len(results), - metadata={"requested": len(sym_list), "returned": len(results)}, - ) +class JobLogEntry(BaseModel): + id: str + job_type: str + status: str + started_at: datetime + completed_at: Optional[datetime] = None + records_processed: int = 0 + error_message: Optional[str] = None -@router.get( - "/top-movers", - response_model=TopMoversResponse, - summary="Top overlay movers", - description="Symbols with highest overlay scores in the last 24 hours.", -) -@with_cache(namespace="overlay:top-movers", ttl=900, key_params=["limit"]) -async def get_top_movers( - response: Response, - limit: int = Query(20, ge=1, le=100), - force_refresh: bool = Query(False), - db: AsyncSession = Depends(get_db), -): - if not _overlay_enabled(): - raise HTTPException(status_code=503, detail="Overlay feature is disabled") +class JobLogResponse(BaseModel): + logs: List[JobLogEntry] + total_count: int - cutoff = datetime.now(timezone.utc) - timedelta(hours=24) - # Get the latest record per symbol, then rank by score - latest_per_symbol = ( - select( - OverlayFeatureRecord.symbol, - func.max(OverlayFeatureRecord.as_of_ts).label("max_ts"), - ) - .where(OverlayFeatureRecord.as_of_ts >= cutoff) - .group_by(OverlayFeatureRecord.symbol) - .subquery() - ) - result = await db.execute( - select(OverlayFeatureRecord) - .join( - latest_per_symbol, - and_( - OverlayFeatureRecord.symbol == latest_per_symbol.c.symbol, - OverlayFeatureRecord.as_of_ts == latest_per_symbol.c.max_ts, - ), - ) - .order_by(desc(OverlayFeatureRecord.overlay_score)) - .limit(limit) - ) - records = result.scalars().all() - - movers = [ - OverlayTopMover( - symbol=r.symbol, - overlay_score=r.overlay_score, - overlay_band=r.overlay_band, - as_of_ts=r.as_of_ts, - ) - for r in records - ] - return TopMoversResponse( - top_movers=movers, - total_count=len(movers), - metadata={"as_of": datetime.now(timezone.utc).isoformat()}, - ) - - -# --------------------------------------------------------------------------- -# Admin routes (static, before /{symbol}) -# --------------------------------------------------------------------------- - -@router.get( - "/admin/health", - response_model=AdminHealthResponse, - summary="Overlay system health", - tags=["overlay-admin"], -) -async def admin_health(db: AsyncSession = Depends(get_db)): - overlay_enabled = _overlay_enabled() - - # Last pipeline run - result = await db.execute( - select(OverlayJobLog).order_by(desc(OverlayJobLog.started_at)).limit(1) - ) - last_job = result.scalars().first() - - # Per-job-type status (collect_all runs all sources; feature_build computes scores) - sources = [] - for source_name in ["collect_all", "feature_build"]: - result_s = await db.execute( - select(OverlayJobLog) - .where(OverlayJobLog.job_type == source_name) - .order_by(desc(OverlayJobLog.started_at)) - .limit(1) - ) - job = result_s.scalars().first() - sources.append( - SourceHealthItem( - source=source_name, - last_collected_at=job.completed_at if job else None, - status=job.status if job else "never_run", - records_24h=job.records_processed if job else 0, - ) - ) - - return AdminHealthResponse( - overlay_enabled=overlay_enabled, - sources=sources, - last_pipeline_run=last_job.started_at if last_job else None, - metadata={"as_of": datetime.now(timezone.utc).isoformat()}, - ) - - -@router.post( - "/admin/trigger-pipeline", - response_model=TriggerPipelineResponse, - summary="Trigger overlay pipeline manually", - tags=["overlay-admin"], -) -async def trigger_pipeline(): - from app.core.database import AsyncSessionLocal - - async def _run(): - async with AsyncSessionLocal() as db: - await _pipeline.run_full_pipeline(db) - - asyncio.create_task(_run()) - return TriggerPipelineResponse( - status="triggered", - message="Overlay pipeline started in background (seeds topic maps, collects data, builds features)", - job_ids=[], - ) - - -@router.post( - "/admin/seed-topics", - summary="Seed ThemeTopicMap with default topic mappings", - tags=["overlay-admin"], -) -async def seed_topics(db: AsyncSession = Depends(get_db)): - """Create default ThemeTopicMap entries for all TOP_50_SYMBOLS (safe to re-run; skips existing).""" - inserted = await _pipeline.seed_topic_maps(db) - return {"status": "ok", "inserted": inserted, "message": f"Seeded {inserted} new topic mappings"} +def _utc(dt): + if isinstance(dt, datetime) and dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt @router.get( @@ -356,12 +67,9 @@ async def get_job_log( db: AsyncSession = Depends(get_db), ): result = await db.execute( - select(OverlayJobLog) - .order_by(desc(OverlayJobLog.started_at)) - .limit(limit) + select(OverlayJobLog).order_by(desc(OverlayJobLog.started_at)).limit(limit) ) logs = result.scalars().all() - entries = [ JobLogEntry( id=str(log.id), @@ -374,41 +82,7 @@ async def get_job_log( ) for log in logs ] - return JobLogResponse( - logs=entries, - total_count=len(entries), - metadata={}, - ) - - -# =========================================================================== -# Parameterized routes (/{symbol} and sub-paths) -# =========================================================================== - -@router.get( - "/{symbol}", - response_model=OverlayScoreResponse, - summary="Overlay score for a symbol", - description="Returns attention overlay score, z-scored features, and source details.", -) -@with_cache(namespace="overlay:score", ttl=1800, key_params=["symbol"]) -async def get_overlay_score( - symbol: str, - response: Response, - force_refresh: bool = Query(False, description="Bypass cache and trigger on-demand rebuild"), - db: AsyncSession = Depends(get_db), -): - if not _overlay_enabled(): - raise HTTPException(status_code=503, detail="Overlay feature is disabled") - - symbol = symbol.upper() - record = await _pipeline.get_or_build(db, symbol) - if record is None: - raise HTTPException( - status_code=404, - detail=f"No overlay data available for {symbol}. Data collection may not have run yet.", - ) - return _record_to_response(record) + return JobLogResponse(logs=entries, total_count=len(entries)) @router.get( @@ -416,12 +90,9 @@ async def get_overlay_score( response_model=HeadlinesResponse, summary="Recent headlines for a symbol", ) -@with_cache(namespace="overlay:headlines", ttl=600, key_params=["symbol", "hours"]) async def get_headlines( symbol: str, - response: Response, hours: int = Query(24, ge=1, le=168), - force_refresh: bool = Query(False), db: AsyncSession = Depends(get_db), ): symbol = symbol.upper() @@ -437,6 +108,9 @@ async def get_headlines( all_events = result.scalars().all() sym_events = [e for e in all_events if symbol in (e.matched_symbols or [])] + if not sym_events: + raise HTTPException(status_code=404, detail=f"No headlines for {symbol} in last {hours}h") + headlines = [ HeadlineItem( title=e.title, @@ -446,7 +120,6 @@ async def get_headlines( ) for e in sym_events ] - publishers = {e.publisher for e in sym_events if e.publisher} count_6h = sum(1 for e in sym_events if _utc(e.published_at) >= cutoff_6h) @@ -456,238 +129,4 @@ async def get_headlines( headline_count_6h=count_6h, headline_count_24h=len(sym_events), publisher_breadth_24h=len(publishers), - metadata={"hours_requested": hours}, - ) - - -@router.get( - "/{symbol}/youtube", - response_model=YouTubeResponse, - summary="YouTube mentions for a symbol", -) -@with_cache(namespace="overlay:youtube", ttl=1200, key_params=["symbol"]) -async def get_youtube( - symbol: str, - response: Response, - force_refresh: bool = Query(False), - db: AsyncSession = Depends(get_db), -): - symbol = symbol.upper() - cutoff_48h = datetime.now(timezone.utc) - timedelta(hours=48) - cutoff_24h = datetime.now(timezone.utc) - timedelta(hours=24) - - result = await db.execute( - select(OverlayVideoEvent) - .where(OverlayVideoEvent.published_at >= cutoff_48h) - .order_by(desc(OverlayVideoEvent.published_at)) - .limit(200) - ) - all_events = result.scalars().all() - sym_events = [e for e in all_events if symbol in (e.matched_symbols or [])] - events_24h = [e for e in sym_events if _utc(e.published_at) >= cutoff_24h] - - videos = [ - VideoItem( - video_id=e.video_id, - channel_id=e.channel_id, - title=e.title, - view_count=e.view_count, - comment_count=e.comment_count, - published_at=e.published_at, - channel_weight=e.channel_weight, - ) - for e in sym_events - ] - weighted_views = sum((e.view_count or 0) * (e.channel_weight or 0.5) for e in events_24h) - - return YouTubeResponse( - symbol=symbol, - videos=videos, - mentions_24h=len(events_24h), - weighted_views_24h=round(weighted_views, 2), - metadata={}, - ) - - -@router.get( - "/{symbol}/wiki", - response_model=WikiResponse, - summary="Wikipedia pageview time series for a symbol", -) -@with_cache(namespace="overlay:wiki", ttl=3600, key_params=["symbol", "days"]) -async def get_wiki( - symbol: str, - response: Response, - days: int = Query(30, ge=1, le=90), - force_refresh: bool = Query(False), - db: AsyncSession = Depends(get_db), -): - symbol = symbol.upper() - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - - result = await db.execute( - select(OverlayWikiPageview) - .where( - and_( - OverlayWikiPageview.mapped_symbol == symbol, - OverlayWikiPageview.date >= cutoff, - ) - ) - .order_by(OverlayWikiPageview.date) - ) - rows = result.scalars().all() - - pageviews = [ - WikiPageviewPoint(date=r.date, views=r.views, page_title=r.page_title) - for r in rows - ] - - views_1d = rows[-1].views if rows else None - recent_7 = rows[-7:] if len(rows) >= 7 else rows - views_7d_avg = sum(r.views for r in recent_7) / len(recent_7) if recent_7 else None - - return WikiResponse( - symbol=symbol, - pageviews=pageviews, - views_1d=views_1d, - views_7d_avg=round(views_7d_avg, 2) if views_7d_avg else None, - metadata={"days_requested": days}, - ) - - -@router.get( - "/{symbol}/crowding", - response_model=CrowdingResponse, - summary="FINRA crowding metrics for a symbol", -) -@with_cache(namespace="overlay:crowding", ttl=3600, key_params=["symbol"]) -async def get_crowding( - symbol: str, - response: Response, - force_refresh: bool = Query(False), - db: AsyncSession = Depends(get_db), -): - symbol = symbol.upper() - from app.services.overlay.finra_overlay_loader import FinraOverlayLoader - - loader = FinraOverlayLoader() - metrics = await loader.get_crowding_metrics(db, symbol) - - return CrowdingResponse( - symbol=symbol, - short_volume_ratio=metrics.get("short_volume_ratio"), - short_volume_spike_zscore=metrics.get("short_volume_spike_zscore"), - crowding_stress_z=metrics.get("crowding_stress_z"), - metadata={}, - ) - - -@router.get( - "/{symbol}/trends", - response_model=TrendsResponse, - summary="Google Trends data for a symbol", -) -@with_cache(namespace="overlay:trends", ttl=7200, key_params=["symbol"]) -async def get_trends( - symbol: str, - response: Response, - force_refresh: bool = Query(False), - db: AsyncSession = Depends(get_db), -): - symbol = symbol.upper() - cutoff = datetime.now(timezone.utc) - timedelta(days=30) - - # Resolve topic IDs for this symbol - result_topics = await db.execute( - select(ThemeTopicMap).where(ThemeTopicMap.active == True) - ) - all_topics = result_topics.scalars().all() - relevant_topics = { - t.topic_id: t.topic_label - for t in all_topics - if symbol in (t.mapped_symbols or []) - } - - if not relevant_topics: - return TrendsResponse( - symbol=symbol, - trends=[], - theme_heat_z=None, - metadata={"note": "No topic mappings found for this symbol"}, - ) - - result = await db.execute( - select(OverlayTrendObservation) - .where( - and_( - OverlayTrendObservation.topic_id.in_(list(relevant_topics.keys())), - OverlayTrendObservation.observed_at >= cutoff, - ) - ) - .order_by(OverlayTrendObservation.observed_at) - ) - rows = result.scalars().all() - - trends = [ - TrendPoint( - observed_at=r.observed_at, - interest_value=r.interest_value, - topic_id=r.topic_id, - topic_label=relevant_topics.get(r.topic_id), - ) - for r in rows - ] - - # Pull theme_heat_z from the latest feature record - latest = await _pipeline.get_latest_feature(db, symbol) - theme_heat_z = latest.theme_heat_z if latest else None - - return TrendsResponse( - symbol=symbol, - trends=trends, - theme_heat_z=theme_heat_z, - metadata={"topics": list(relevant_topics.keys())}, - ) - - -@router.get( - "/{symbol}/history", - response_model=OverlayHistoryResponse, - summary="Overlay score history for a symbol", - description="Time-series of overlay scores (useful for backtesting).", -) -async def get_history( - symbol: str, - days: int = Query(30, ge=1, le=365), - db: AsyncSession = Depends(get_db), -): - symbol = symbol.upper() - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - - result = await db.execute( - select(OverlayFeatureRecord) - .where( - and_( - OverlayFeatureRecord.symbol == symbol, - OverlayFeatureRecord.as_of_ts >= cutoff, - ) - ) - .order_by(OverlayFeatureRecord.as_of_ts) - ) - records = result.scalars().all() - - history = [ - OverlayHistoryPoint( - as_of_ts=r.as_of_ts, - overlay_score=r.overlay_score, - overlay_confidence=r.overlay_confidence, - overlay_band=r.overlay_band, - ) - for r in records - ] - - return OverlayHistoryResponse( - symbol=symbol, - history=history, - metadata={"days_requested": days, "data_points": len(history)}, ) diff --git a/app/api/v1/endpoints/stocks.py b/app/api/v1/endpoints/stocks.py index 41cd562..95b2b58 100644 --- a/app/api/v1/endpoints/stocks.py +++ b/app/api/v1/endpoints/stocks.py @@ -299,6 +299,30 @@ async def get_52week_gainers( ) +@router.get( + "/gainers", + summary="Today's top gaining stocks (Yahoo Finance day_gainers preset)", +) +async def get_day_gainers( + page: int = Query(1, ge=1, description="Page number (1-based)"), + page_size: int = Query(25, ge=1, le=250, description="Results per page (max 250)"), +): + """ + Top gaining stocks for today using Yahoo Finance's `day_gainers` preset. + + Criteria: price change > 3%, market cap >= $2B, price >= $5, volume > 15,000. + Sorted by percent change descending. Real-time — no cache. + """ + from app.services.screener_service import screener_service + try: + return await screener_service.screen_preset("day_gainers", page=page, page_size=page_size) + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + logger.error("Day gainers error: %s", e, exc_info=True) + raise HTTPException(status_code=500, detail=f"Screener query failed: {str(e)}") + + @router.get( "/trending", summary="Trending stocks combining most active and 52-week gainers", diff --git a/app/core/config.py b/app/core/config.py index a21524f..a7afbd7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -128,7 +128,24 @@ class Settings(BaseSettings): GOOGLE_TRENDS_ENABLED: bool = os.getenv("GOOGLE_TRENDS_ENABLED", "false").lower() == "true" OVERLAY_ENABLED: bool = os.getenv("OVERLAY_ENABLED", "true").lower() == "true" OVERLAY_STALE_HOURS: int = int(os.getenv("OVERLAY_STALE_HOURS", "8")) - + + # News v2 ingest (Alpaca News + StockTwits + Finnhub) + NEWS_INGEST_ENABLED: bool = os.getenv("NEWS_INGEST_ENABLED", "false").lower() == "true" + NEWS_INGEST_TIMEZONE: str = os.getenv("NEWS_INGEST_TIMEZONE", "America/New_York") + + # Alpaca News reuses ALPACA_API_KEY/SECRET; only the base URL is separable + ALPACA_NEWS_BASE_URL: str = os.getenv("ALPACA_NEWS_BASE_URL", "https://data.alpaca.markets") + + # Finnhub (free tier: 60 calls/min, 12 month historical) + FINNHUB_API_KEY: str = os.getenv("FINNHUB_API_KEY", "") + FINNHUB_BASE_URL: str = os.getenv("FINNHUB_BASE_URL", "https://finnhub.io/api/v1") + + # StockTwits public API (no auth; rate-limited per IP ~200 req/hr) + STOCKTWITS_BASE_URL: str = os.getenv("STOCKTWITS_BASE_URL", "https://api.stocktwits.com/api/2") + STOCKTWITS_UNIVERSE_LOOKBACK_DAYS: int = int(os.getenv("STOCKTWITS_UNIVERSE_LOOKBACK_DAYS", "14")) + STOCKTWITS_PREMARKET_GAP_THRESHOLD: float = float(os.getenv("STOCKTWITS_PREMARKET_GAP_THRESHOLD", "0.02")) + STOCKTWITS_UNIVERSE_MAX_SIZE: int = int(os.getenv("STOCKTWITS_UNIVERSE_MAX_SIZE", "300")) + class Config: case_sensitive = True env_file = ".env" diff --git a/app/core/overlay_config.py b/app/core/overlay_config.py index 7095004..ed3d6ce 100644 --- a/app/core/overlay_config.py +++ b/app/core/overlay_config.py @@ -1,84 +1,13 @@ """ -Overlay feature configuration - source weights, thresholds, and feature flags. +Overlay configuration — Yahoo RSS feed URLs and seed watchlist. """ -from app.core.config import settings - -# --------------------------------------------------------------------------- -# Source weights (used for weighted-average scoring; normalized internally) -# --------------------------------------------------------------------------- -SOURCE_WEIGHTS = { - "yahoo": 0.30, - "youtube": 0.25, - "wikimedia": 0.20, - "finra": 0.15, - "google_trends": 0.10, -} - -# --------------------------------------------------------------------------- -# Overlay band thresholds (overlay_score 0~1) -# --------------------------------------------------------------------------- -BAND_THRESHOLDS = { - "frenzied": 0.80, - "loud": 0.60, - "supportive": 0.40, - "tepid": 0.20, - "silent": 0.0, -} - -# --------------------------------------------------------------------------- -# Confidence -# --------------------------------------------------------------------------- -# Each present source adds this much to overlay_confidence (max 1.0) -CONFIDENCE_PER_SOURCE = 0.20 - -# Min sources for non-trivial confidence -MIN_SOURCES_STRONG_CONFIDENCE = 3 -MIN_SOURCES_DEGRADED_MODE = 1 - -# --------------------------------------------------------------------------- -# z-score normalization -# --------------------------------------------------------------------------- -ZSCORE_WINDOW_DAYS = 30 - -# Winsorization clamps -WINSOR_LOWER = -3.0 -WINSOR_UPPER = 3.0 - -# --------------------------------------------------------------------------- -# Staleness -# --------------------------------------------------------------------------- -# Hours before a feature record is considered stale and on-demand rebuild triggers -FEATURE_STALE_HOURS: int = int(getattr(settings, "OVERLAY_STALE_HOURS", 8)) - -# On-demand pipeline timeout (seconds) - prevents blocking API requests -ONDEMAND_TIMEOUT_SECONDS = 25 - -# --------------------------------------------------------------------------- -# Batch scheduling (UTC) -# --------------------------------------------------------------------------- -SCHEDULE_RSS_UTC = "23:30" # 18:30 ET -SCHEDULE_WIKI_YOUTUBE_UTC = "01:00" # 20:00 ET (next UTC day) -SCHEDULE_FEATURE_BUILD_UTC = "01:30" -SCHEDULE_SCORING_UTC = "02:00" - -# --------------------------------------------------------------------------- -# Hint thresholds -# --------------------------------------------------------------------------- -HOLD_EXTENSION_EXTEND_THRESHOLD = 0.65 -HOLD_EXTENSION_TRIM_THRESHOLD = 0.30 -ADD_ON_ELIGIBILITY_THRESHOLD = 0.55 - -# --------------------------------------------------------------------------- -# Yahoo RSS feed URLs -# --------------------------------------------------------------------------- YAHOO_RSS_FEEDS = [ - "https://finance.yahoo.com/rss/headline", + # Per-symbol feeds resolve to feeds.finance.yahoo.com after redirect. + # The general /rss/headline endpoint redirects to a Yahoo-side broken URL, + # so it is intentionally omitted here. ] -# --------------------------------------------------------------------------- -# Seed symbols (Top 50 US equities) -# --------------------------------------------------------------------------- TOP_50_SYMBOLS = [ "AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "BRK.B", "JPM", "JNJ", "V", "UNH", "XOM", "PG", "MA", "HD", "CVX", "LLY", diff --git a/app/main.py b/app/main.py index 33135dc..076ef65 100644 --- a/app/main.py +++ b/app/main.py @@ -15,6 +15,8 @@ from app.core.database import engine, Base from app.middleware.error_logger import ErrorLoggingMiddleware, start_request_log_flusher from app.models import error_log, request_log, fred_data, filing, finra_short_volume, alpaca_price # Import to register models from app.models import overlay_registry, overlay_raw_event, overlay_feature # Overlay models +from app.models import news_headline # News v2 model +from app.models import gainer_snapshot # Gainer intraday snapshots # Create database tables @asynccontextmanager @@ -36,6 +38,18 @@ async def lifespan(app: FastAPI): start_sec_ingest_scheduler() except Exception: pass + # Start News v2 ingest scheduler (Alpaca News + StockTwits + Finnhub) + try: + from app.services.news.scheduler import start_news_ingest_scheduler + start_news_ingest_scheduler() + except Exception: + pass + # Start gainer snapshot scheduler (5-min intraday during market hours) + try: + from app.services.gainers.scheduler import start_gainer_scheduler + start_gainer_scheduler() + except Exception: + pass yield # Shutdown try: @@ -48,6 +62,16 @@ async def lifespan(app: FastAPI): stop_sec_ingest_scheduler() except Exception: pass + try: + from app.services.news.scheduler import stop_news_ingest_scheduler + stop_news_ingest_scheduler() + except Exception: + pass + try: + from app.services.gainers.scheduler import stop_gainer_scheduler + stop_gainer_scheduler() + except Exception: + pass try: from app.core.http_client import close_http_session await close_http_session() @@ -133,11 +157,11 @@ app.openapi_tags = [ }, { "name": "overlay", - "description": "Attention Overlay - retail investor interest, media diffusion, crowding signals" + "description": "Overlay headlines — Yahoo RSS headline collector" }, { "name": "overlay-admin", - "description": "Overlay administrative endpoints (pipeline trigger, health, job log)" + "description": "Overlay job log" }, { "name": "screener", diff --git a/app/models/__init__.py b/app/models/__init__.py index 130a1be..a2ae6d1 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -4,13 +4,15 @@ from app.models.filing import SECFiling from app.models.filing_event import SECFilingEvent from app.models.finra_short_volume import FinraShortVolume from app.models.alpaca_price import AlpacaPriceData -from app.models.overlay_registry import CompanyAlias, YouTubeChannelRegistry, WikiPageMap, ThemeTopicMap -from app.models.overlay_raw_event import OverlayHeadlineEvent, OverlayVideoEvent, OverlayWikiPageview, OverlayTrendObservation -from app.models.overlay_feature import OverlayFeatureRecord, OverlayJobLog +from app.models.overlay_registry import CompanyAlias +from app.models.overlay_raw_event import OverlayHeadlineEvent +from app.models.overlay_feature import OverlayJobLog from app.models.insider_transaction import InsiderTransaction from app.models.earnings_surprise import EarningsSurprise from app.models.universe_snapshot import UniverseTickerRegistry, UniverseSnapshot from app.models.dividend_calendar import DividendCalendar +from app.models.news_headline import NewsHeadline +from app.models.gainer_snapshot import GainerSnapshot __all__ = [ "Company", @@ -27,16 +29,9 @@ __all__ = [ "SECFilingEvent", "FinraShortVolume", "AlpacaPriceData", - # Overlay + # Overlay (headline-only) "CompanyAlias", - "YouTubeChannelRegistry", - "WikiPageMap", - "ThemeTopicMap", "OverlayHeadlineEvent", - "OverlayVideoEvent", - "OverlayWikiPageview", - "OverlayTrendObservation", - "OverlayFeatureRecord", "OverlayJobLog", # Insider "InsiderTransaction", @@ -46,4 +41,8 @@ __all__ = [ "UniverseSnapshot", # Dividends "DividendCalendar", + # News + "NewsHeadline", + # Gainers + "GainerSnapshot", ] \ No newline at end of file diff --git a/app/models/gainer_snapshot.py b/app/models/gainer_snapshot.py new file mode 100644 index 0000000..767118e --- /dev/null +++ b/app/models/gainer_snapshot.py @@ -0,0 +1,43 @@ +""" +GainerSnapshot — 5-min snapshots of Yahoo Finance day_gainers during market hours. +Stored for backtesting: who was a top gainer at each intraday checkpoint. +""" +from datetime import datetime, timezone +import uuid + +from sqlalchemy import Column, String, Float, Integer, BigInteger, Text, Index, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP + +from app.core.database import Base + + +class GainerSnapshot(Base): + __tablename__ = "gainer_snapshots" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + snapshot_at = Column(TIMESTAMP(timezone=True), nullable=False) + rank = Column(Integer, nullable=False) + symbol = Column(String(10), nullable=False) + name = Column(Text, nullable=True) + exchange = Column(String(20), nullable=True) + price = Column(Float, nullable=True) + change_percent = Column(Float, nullable=True) + volume = Column(BigInteger, nullable=True) + avg_volume_3m = Column(BigInteger, nullable=True) + market_cap = Column(BigInteger, nullable=True) + pe_ratio = Column(Float, nullable=True) + forward_pe = Column(Float, nullable=True) + eps_ttm = Column(Float, nullable=True) + dividend_yield = Column(Float, nullable=True) + fifty_two_week_high = Column(Float, nullable=True) + fifty_two_week_low = Column(Float, nullable=True) + created_at = Column( + TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + __table_args__ = ( + # One symbol per snapshot timestamp (dedup on retry) + UniqueConstraint("snapshot_at", "symbol", name="uq_gainer_snapshot_symbol"), + Index("idx_gainer_snapshot_at", "snapshot_at"), + Index("idx_gainer_symbol_snapshot", "symbol", "snapshot_at"), + ) diff --git a/app/models/news_headline.py b/app/models/news_headline.py new file mode 100644 index 0000000..b6ee435 --- /dev/null +++ b/app/models/news_headline.py @@ -0,0 +1,65 @@ +""" +News headline raw table — multi-source append-only feed. + +Sources: alpaca_benzinga, stocktwits, finnhub, gdelt. +Per-ticker row split (one row per ticker × source × source_id) for indexed +queries, with `tickers_all` preserving full vendor symbol set. + +PIT safety: news facts are publish-time, so no `as_of_date` column. Queries +that need point-in-time correctness should filter `ingested_at <= cutoff`. +""" + +from datetime import datetime, timezone +import uuid + +from sqlalchemy import Boolean, Column, Float, Index, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import ARRAY, TIMESTAMP, UUID + +from app.core.database import Base + + +class NewsHeadline(Base): + __tablename__ = "news_headline" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + source = Column(String(30), nullable=False) + source_id = Column(String(100), nullable=False) + ticker = Column(String(10), nullable=False) + tickers_all = Column(ARRAY(String(10)), nullable=True) + + published_at = Column(TIMESTAMP(timezone=True), nullable=False) + headline = Column(Text, nullable=False) + summary = Column(Text, nullable=True) + url = Column(Text, nullable=True) + language = Column(String(8), server_default="en") + + vendor_categories = Column(ARRAY(String(50)), nullable=True) + categories = Column(ARRAY(String(50)), nullable=True) + raw_sentiment = Column(Float, nullable=True) + is_primary = Column(Boolean, nullable=False, server_default="false") + + ingested_at = Column( + TIMESTAMP(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + created_at = Column( + TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at = Column( + TIMESTAMP(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + __table_args__ = ( + UniqueConstraint( + "source", "source_id", "ticker", + name="uq_news_headline_source_ticker", + ), + Index("idx_news_ticker_published", "ticker", "published_at"), + Index("idx_news_published", "published_at"), + Index("idx_news_source_published", "source", "published_at"), + Index("idx_news_ingested", "ingested_at"), + ) diff --git a/app/models/overlay_feature.py b/app/models/overlay_feature.py index e70e9fe..bf4d2fe 100644 --- a/app/models/overlay_feature.py +++ b/app/models/overlay_feature.py @@ -1,8 +1,8 @@ """ -Overlay computed feature models - pre-computed scores served by the API +Overlay job log — tracks scheduler runs of the headline collector. """ -from sqlalchemy import Column, String, Float, Boolean, Index, UniqueConstraint, JSON, Integer, Text +from sqlalchemy import Column, String, Index, Integer, Text from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP from datetime import datetime, timezone import uuid @@ -10,66 +10,12 @@ import uuid from app.core.database import Base -class OverlayFeatureRecord(Base): - __tablename__ = "overlay_feature_records" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - symbol = Column(String(10), nullable=False) - as_of_ts = Column(TIMESTAMP(timezone=True), nullable=False) - feature_version = Column(String(20), default="v1") - - # z-scores per source - headline_burst_z = Column(Float, nullable=True) - youtube_influence_z = Column(Float, nullable=True) - wiki_attention_z = Column(Float, nullable=True) - theme_heat_z = Column(Float, nullable=True) - crowding_stress_z = Column(Float, nullable=True) - - # headline detail - headline_count_6h = Column(Integer, default=0) - headline_count_24h = Column(Integer, default=0) - publisher_breadth_24h = Column(Integer, default=0) - - # youtube detail - youtube_mentions_24h = Column(Integer, default=0) - youtube_weighted_views_24h = Column(Float, default=0.0) - - # wiki detail - wiki_views_1d = Column(Integer, nullable=True) - wiki_views_7d_avg = Column(Float, nullable=True) - - # finra crowding detail - short_volume_ratio = Column(Float, nullable=True) - short_volume_spike_zscore = Column(Float, nullable=True) - - # final scores - overlay_score = Column(Float, nullable=False, default=0.0) - overlay_confidence = Column(Float, nullable=False, default=0.0) - overlay_band = Column(String(20), nullable=True) # silent/tepid/supportive/loud/frenzied - source_presence_mask = Column(JSON, default=dict) - - # hints - hold_extension_hint = Column(String(10), nullable=True) # extend/neutral/trim - add_on_eligibility = Column(Boolean, nullable=True) - - created_at = Column( - TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - - __table_args__ = ( - UniqueConstraint("symbol", "as_of_ts", "feature_version", name="uq_overlay_feature_record"), - Index("idx_overlay_feature_symbol", "symbol"), - Index("idx_overlay_feature_as_of_ts", "as_of_ts"), - Index("idx_overlay_feature_score", "overlay_score"), - ) - - class OverlayJobLog(Base): __tablename__ = "overlay_job_log" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - job_type = Column(String(50), nullable=False) # rss_collect / wiki_collect / yt_collect / feature_build / score - status = Column(String(20), nullable=False) # running / completed / failed / partial + job_type = Column(String(50), nullable=False) + status = Column(String(20), nullable=False) started_at = Column(TIMESTAMP(timezone=True), nullable=False) completed_at = Column(TIMESTAMP(timezone=True), nullable=True) records_processed = Column(Integer, default=0) diff --git a/app/models/overlay_raw_event.py b/app/models/overlay_raw_event.py index 95ed437..feeabbc 100644 --- a/app/models/overlay_raw_event.py +++ b/app/models/overlay_raw_event.py @@ -1,8 +1,8 @@ """ -Overlay raw event models - time-series raw data from each source +Overlay raw event models — headline events from the Yahoo RSS adapter. """ -from sqlalchemy import Column, String, Float, Integer, Index, UniqueConstraint, JSON, Text +from sqlalchemy import Column, String, Index, JSON, Text from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP from datetime import datetime, timezone import uuid @@ -26,64 +26,3 @@ class OverlayHeadlineEvent(Base): __table_args__ = ( Index("idx_headline_published_at", "published_at"), ) - - -class OverlayVideoEvent(Base): - __tablename__ = "overlay_video_events" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - video_id = Column(String(100), unique=True, nullable=False) - channel_id = Column(String(100), nullable=False) - title = Column(Text, nullable=False) - view_count = Column(Integer, default=0) - comment_count = Column(Integer, default=0) - published_at = Column(TIMESTAMP(timezone=True), nullable=True) - matched_symbols = Column(JSON, default=list) - channel_weight = Column(Float, default=0.5) - created_at = Column( - TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - - __table_args__ = ( - Index("idx_video_channel_id", "channel_id"), - Index("idx_video_published_at", "published_at"), - ) - - -class OverlayWikiPageview(Base): - __tablename__ = "overlay_wiki_pageviews" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - page_title = Column(String(500), nullable=False) - date = Column(TIMESTAMP(timezone=True), nullable=False) - project = Column(String(50), default="en.wikipedia") - views = Column(Integer, nullable=False) - mapped_symbol = Column(String(10), nullable=True, index=True) - created_at = Column( - TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - - __table_args__ = ( - UniqueConstraint("page_title", "date", "project", name="uq_wiki_pageviews"), - Index("idx_wiki_pageviews_symbol", "mapped_symbol"), - Index("idx_wiki_pageviews_date", "date"), - ) - - -class OverlayTrendObservation(Base): - __tablename__ = "overlay_trend_observations" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - topic_id = Column(String(100), nullable=False) - observed_at = Column(TIMESTAMP(timezone=True), nullable=False) - geography = Column(String(10), default="US") - interest_value = Column(Integer, nullable=False) - created_at = Column( - TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - - __table_args__ = ( - UniqueConstraint("topic_id", "observed_at", "geography", name="uq_trend_observations"), - Index("idx_trend_topic_id", "topic_id"), - Index("idx_trend_observed_at", "observed_at"), - ) diff --git a/app/models/overlay_registry.py b/app/models/overlay_registry.py index eab432c..1683b40 100644 --- a/app/models/overlay_registry.py +++ b/app/models/overlay_registry.py @@ -1,8 +1,8 @@ """ -Overlay Registry models - lookup tables for entity resolution +Overlay registry — company alias lookup for entity resolution. """ -from sqlalchemy import Column, String, Float, Boolean, Index, UniqueConstraint, JSON +from sqlalchemy import Column, String, Float, Boolean, Index from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP from datetime import datetime, timezone import uuid @@ -15,7 +15,7 @@ class CompanyAlias(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) symbol = Column(String(10), nullable=False, index=True) - alias_type = Column(String(20), nullable=False) # canonical/short/alias/wiki + alias_type = Column(String(20), nullable=False) # canonical / short / alias / wiki alias_value = Column(String(255), nullable=False) confidence = Column(Float, default=1.0) active = Column(Boolean, default=True) @@ -27,50 +27,3 @@ class CompanyAlias(Base): Index("idx_company_aliases_symbol", "symbol"), Index("idx_company_aliases_value", "alias_value"), ) - - -class YouTubeChannelRegistry(Base): - __tablename__ = "youtube_channel_registry" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - channel_id = Column(String(100), unique=True, nullable=False) - channel_title = Column(String(255), nullable=False) - category = Column(String(50), nullable=True) - channel_weight = Column(Float, default=0.5) # 0~1 - active = Column(Boolean, default=True) - watch_mode = Column(String(20), default="recent") # recent / all - symbol_focus_tags = Column(JSON, default=list) - created_at = Column( - TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - - -class WikiPageMap(Base): - __tablename__ = "wiki_page_map" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - symbol = Column(String(10), nullable=False) - wiki_page_title = Column(String(500), nullable=False) - confidence = Column(Float, default=1.0) - active = Column(Boolean, default=True) - created_at = Column( - TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc) - ) - - __table_args__ = ( - UniqueConstraint("symbol", "wiki_page_title", name="uq_wiki_page_map"), - Index("idx_wiki_page_map_symbol", "symbol"), - ) - - -class ThemeTopicMap(Base): - __tablename__ = "theme_topic_map" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - topic_id = Column(String(100), nullable=False, unique=True) - topic_label = Column(String(255), nullable=False) - mapped_symbols = Column(JSON, default=list) # list of ticker strings - active = Column(Boolean, default=True) - created_at = Column( - TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc) - ) diff --git a/app/schemas/overlay.py b/app/schemas/overlay.py deleted file mode 100644 index 92ea13f..0000000 --- a/app/schemas/overlay.py +++ /dev/null @@ -1,210 +0,0 @@ -""" -Pydantic schemas for Overlay API responses -""" - -from datetime import datetime -from typing import Any, Dict, List, Optional -from pydantic import BaseModel, Field - - -class OverlayFeatures(BaseModel): - headline_burst_z: Optional[float] = None - youtube_influence_z: Optional[float] = None - wiki_attention_z: Optional[float] = None - theme_heat_z: Optional[float] = None - crowding_stress_z: Optional[float] = None - - -class OverlaySourcePresence(BaseModel): - yahoo: bool = False - youtube: bool = False - wikimedia: bool = False - google_trends: bool = False - finra: bool = False - - -class YahooSourceDetail(BaseModel): - headline_count_6h: int = 0 - headline_count_24h: int = 0 - publisher_breadth_24h: int = 0 - - -class YouTubeSourceDetail(BaseModel): - mentions_24h: int = 0 - weighted_views_24h: float = 0.0 - - -class WikiSourceDetail(BaseModel): - page_views_1d: Optional[int] = None - page_views_7d_avg: Optional[float] = None - - -class FinraSourceDetail(BaseModel): - short_volume_ratio: Optional[float] = None - short_volume_spike_zscore: Optional[float] = None - - -class OverlaySourceDetails(BaseModel): - yahoo: Optional[YahooSourceDetail] = None - youtube: Optional[YouTubeSourceDetail] = None - wikimedia: Optional[WikiSourceDetail] = None - finra: Optional[FinraSourceDetail] = None - - -class OverlayMetadata(BaseModel): - feature_version: str = "v1" - data_freshness: Optional[datetime] = None - next_update_expected: Optional[datetime] = None - - -class OverlayScoreResponse(BaseModel): - symbol: str - as_of_ts: Optional[datetime] = None - overlay_score: float = 0.0 - overlay_confidence: float = 0.0 - overlay_band: Optional[str] = None - hold_extension_hint: Optional[str] = None - add_on_eligibility: Optional[bool] = None - features: OverlayFeatures = Field(default_factory=OverlayFeatures) - source_presence: OverlaySourcePresence = Field(default_factory=OverlaySourcePresence) - source_details: OverlaySourceDetails = Field(default_factory=OverlaySourceDetails) - metadata: OverlayMetadata = Field(default_factory=OverlayMetadata) - - -class BulkOverlayResponse(BaseModel): - results: List[OverlayScoreResponse] - total_count: int - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class OverlayTopMover(BaseModel): - symbol: str - overlay_score: float - overlay_band: Optional[str] = None - as_of_ts: Optional[datetime] = None - - -class TopMoversResponse(BaseModel): - top_movers: List[OverlayTopMover] - total_count: int - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class HeadlineItem(BaseModel): - title: str - publisher: Optional[str] = None - published_at: datetime - article_guid: str - - -class HeadlinesResponse(BaseModel): - symbol: str - headlines: List[HeadlineItem] - headline_count_6h: int = 0 - headline_count_24h: int = 0 - publisher_breadth_24h: int = 0 - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class VideoItem(BaseModel): - video_id: str - channel_id: str - title: str - view_count: int = 0 - comment_count: int = 0 - published_at: Optional[datetime] = None - channel_weight: float = 0.5 - - -class YouTubeResponse(BaseModel): - symbol: str - videos: List[VideoItem] - mentions_24h: int = 0 - weighted_views_24h: float = 0.0 - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class WikiPageviewPoint(BaseModel): - date: datetime - views: int - page_title: str - - -class WikiResponse(BaseModel): - symbol: str - pageviews: List[WikiPageviewPoint] - views_1d: Optional[int] = None - views_7d_avg: Optional[float] = None - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class CrowdingResponse(BaseModel): - symbol: str - short_volume_ratio: Optional[float] = None - short_volume_spike_zscore: Optional[float] = None - crowding_stress_z: Optional[float] = None - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class TrendPoint(BaseModel): - observed_at: datetime - interest_value: int - topic_id: str - topic_label: Optional[str] = None - - -class TrendsResponse(BaseModel): - symbol: str - trends: List[TrendPoint] - theme_heat_z: Optional[float] = None - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class OverlayHistoryPoint(BaseModel): - as_of_ts: datetime - overlay_score: float - overlay_confidence: float - overlay_band: Optional[str] = None - - -class OverlayHistoryResponse(BaseModel): - symbol: str - history: List[OverlayHistoryPoint] - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class SourceHealthItem(BaseModel): - source: str - last_collected_at: Optional[datetime] = None - status: str = "unknown" - success_rate_24h: Optional[float] = None - records_24h: int = 0 - - -class AdminHealthResponse(BaseModel): - overlay_enabled: bool - sources: List[SourceHealthItem] - last_pipeline_run: Optional[datetime] = None - metadata: Dict[str, Any] = Field(default_factory=dict) - - -class TriggerPipelineResponse(BaseModel): - status: str - message: str - job_ids: List[str] = Field(default_factory=list) - - -class JobLogEntry(BaseModel): - id: str - job_type: str - status: str - started_at: datetime - completed_at: Optional[datetime] = None - records_processed: int = 0 - error_message: Optional[str] = None - - -class JobLogResponse(BaseModel): - logs: List[JobLogEntry] - total_count: int - metadata: Dict[str, Any] = Field(default_factory=dict) diff --git a/app/services/gainers/__init__.py b/app/services/gainers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/gainers/collector.py b/app/services/gainers/collector.py new file mode 100644 index 0000000..e8b3c96 --- /dev/null +++ b/app/services/gainers/collector.py @@ -0,0 +1,59 @@ +""" +Gainer snapshot collector — fetches top 100 day_gainers and stores per 5-min slot. +""" +import logging +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +def _floor_to_5min(dt: datetime) -> datetime: + """Round down to the nearest 5-minute boundary.""" + return dt.replace(minute=dt.minute - (dt.minute % 5), second=0, microsecond=0) + + +async def collect_gainer_snapshot() -> int: + """Fetch 100 day_gainers and bulk-insert into gainer_snapshots. Returns inserted count.""" + from app.services.screener_service import screener_service + from app.core.database import AsyncSessionLocal + from app.models.gainer_snapshot import GainerSnapshot + from sqlalchemy.dialects.postgresql import insert as pg_insert + + snapshot_at = _floor_to_5min(datetime.now(timezone.utc)) + + result = await screener_service.screen_preset("day_gainers", page=1, page_size=100) + stocks = result.get("stocks", []) + if not stocks: + logger.warning("[Gainers] No stocks returned from day_gainers preset") + return 0 + + rows = [ + { + "snapshot_at": snapshot_at, + "rank": rank, + "symbol": s["symbol"], + "name": s.get("name"), + "exchange": s.get("exchange"), + "price": s.get("price"), + "change_percent": s.get("change_percent"), + "volume": s.get("volume"), + "avg_volume_3m": s.get("avg_volume_3m"), + "market_cap": s.get("market_cap"), + "pe_ratio": s.get("pe_ratio"), + "forward_pe": s.get("forward_pe"), + "eps_ttm": s.get("eps_ttm"), + "dividend_yield": s.get("dividend_yield"), + "fifty_two_week_high": s.get("fifty_two_week_high"), + "fifty_two_week_low": s.get("fifty_two_week_low"), + } + for rank, s in enumerate(stocks, start=1) + ] + + async with AsyncSessionLocal() as db: + stmt = pg_insert(GainerSnapshot).values(rows) + stmt = stmt.on_conflict_do_nothing(constraint="uq_gainer_snapshot_symbol") + await db.execute(stmt) + await db.commit() + + logger.info("[Gainers] snapshot %s — %d rows", snapshot_at.isoformat(), len(rows)) + return len(rows) diff --git a/app/services/gainers/scheduler.py b/app/services/gainers/scheduler.py new file mode 100644 index 0000000..979a677 --- /dev/null +++ b/app/services/gainers/scheduler.py @@ -0,0 +1,81 @@ +""" +APScheduler entry point for gainer snapshot collection. + +Job: every 5 minutes on Mon-Fri ET, guarded to NYSE market hours (9:30–16:00 ET). +Note: US market holidays are treated as normal weekdays — Yahoo simply returns + fewer or no results on those days, which is harmless. +""" +import logging +from datetime import datetime, time + +logger = logging.getLogger(__name__) + +_scheduler = None + + +def _is_market_open() -> bool: + """Return True if current ET time is within NYSE regular session (9:30–16:00).""" + try: + from zoneinfo import ZoneInfo + now_et = datetime.now(ZoneInfo("America/New_York")) + except ImportError: + import pytz + now_et = datetime.now(pytz.timezone("America/New_York")) + return time(9, 30) <= now_et.time() <= time(16, 0) + + +def _get_scheduler(): + global _scheduler + if _scheduler is None: + try: + from apscheduler.schedulers.asyncio import AsyncIOScheduler + _scheduler = AsyncIOScheduler(timezone="UTC") + except ImportError: + logger.warning("apscheduler not installed; gainer collection disabled") + return None + return _scheduler + + +async def _run_collect_job() -> None: + if not _is_market_open(): + return + try: + from app.services.gainers.collector import collect_gainer_snapshot + await collect_gainer_snapshot() + except Exception as e: + logger.error("[Gainers] collection failed: %s", e) + + +def start_gainer_scheduler() -> None: + sched = _get_scheduler() + if sched is None: + return + + try: + from apscheduler.triggers.cron import CronTrigger + + sched.add_job( + _run_collect_job, + trigger=CronTrigger( + minute="*/5", + day_of_week="mon-fri", + timezone="America/New_York", + ), + id="gainer_snapshot_collect", + replace_existing=True, + max_instances=1, + misfire_grace_time=120, + coalesce=True, + ) + if not sched.running: + sched.start() + logger.info("[Gainers] scheduler started — every 5 min Mon-Fri ET (market hours guard)") + except Exception as e: + logger.error("[Gainers] scheduler start failed: %s", e) + + +def stop_gainer_scheduler() -> None: + sched = _get_scheduler() + if sched and sched.running: + sched.shutdown(wait=False) + logger.info("[Gainers] scheduler stopped") diff --git a/app/services/insider_transaction_service.py b/app/services/insider_transaction_service.py index 6084ecb..e2bb949 100644 --- a/app/services/insider_transaction_service.py +++ b/app/services/insider_transaction_service.py @@ -194,37 +194,39 @@ class InsiderTransactionService: # ------------------------------------------------------------------ async def _get_form4_xml_url(self, cik_int: int, acc: str, acc_clean: str) -> Optional[str]: - """Resolve the correct Form 4 XML URL by fetching the filing index JSON. + """Resolve the Form 4 XML URL via the filing's directory listing. SEC Form 4 XML files use filer-defined names (e.g., 'form4.xml', - 'wk-form4_*.xml', 'tm*_*.xml'). We fetch the filing index JSON to get - the primary document filename, then build the correct XML URL. + 'wk-form4_*.xml', 'tm*_*.xml'). The filing directory exposes + ``index.json`` with a ``directory.item[*].name`` listing — we pick + the first ``.xml`` entry that isn't a header/index sidecar. + + (The previous ``{acc}-index.json`` URL stopped serving in 2026 — every + nightly Form 4 ingest from 2026-04-24 onward fetched 404 here and + silently inserted zero rows.) """ idx_url = ( - f"https://www.sec.gov/Archives/edgar/data/{cik_int}/{acc_clean}/{acc}-index.json" + f"https://www.sec.gov/Archives/edgar/data/{cik_int}/{acc_clean}/index.json" ) try: idx_data = await self._http.fetch_json(idx_url) - primary = idx_data.get("primary_document", "") - if not primary: - docs = idx_data.get("documents", []) - for doc in docs: - url = doc.get("document_url", "") - if url.endswith(".xml") and "xsl" not in url: - primary = url.rsplit("/", 1)[-1] - break - if primary: - filename = primary.rsplit("/", 1)[-1] + items = (idx_data.get("directory") or {}).get("item") or [] + for it in items: + name = (it.get("name") or "").strip() + if not name.lower().endswith(".xml"): + continue + if "index" in name.lower(): # skip *-index.html etc. + continue return ( - f"https://www.sec.gov/Archives/edgar/data/{cik_int}/{acc_clean}/{filename}" + f"https://www.sec.gov/Archives/edgar/data/{cik_int}/{acc_clean}/{name}" ) - except Exception: - pass + except Exception as e: + logger.debug(f"index.json fetch failed for {acc}: {e}") return None async def index_form4_from_index_entries( self, - db: AsyncSession, + db: Optional[AsyncSession], entries: List[IndexEntry], commit_every: int = 3000, ) -> int: @@ -233,18 +235,26 @@ class InsiderTransactionService: For each new accession: fetch the filing index JSON to get the correct XML filename, then parse the XML (extracting ticker from issuerTradingSymbol). Skips already-indexed accession numbers. Returns new transaction count. - Commits to DB every commit_every accumulated rows to show progress and limit memory. + + DB sessions are short-lived: a fresh session is opened for the initial + dedup query, and another fresh session per flush. The legacy ``db`` + argument is accepted for back-compat but is not held across the long + HTTP-bound loop — that previously caused asyncpg to drop the + connection mid-job and kill every nightly run. """ if not entries: return 0 + from app.core.database import AsyncSessionLocal + acc_set = {e.accession_number for e in entries} - existing = await db.execute( - select(InsiderTransaction.accession_number).distinct().where( - InsiderTransaction.accession_number.in_(acc_set) + async with AsyncSessionLocal() as dedup_db: + existing = await dedup_db.execute( + select(InsiderTransaction.accession_number).distinct().where( + InsiderTransaction.accession_number.in_(acc_set) + ) ) - ) - existing_accs: Set[str] = {r[0] for r in existing.fetchall()} + existing_accs: Set[str] = {r[0] for r in existing.fetchall()} new_entries = [e for e in entries if e.accession_number not in existing_accs] if not new_entries: @@ -258,13 +268,14 @@ class InsiderTransactionService: async def _flush(rows: List[Dict]) -> int: n = 0 - for i in range(0, len(rows), _CHUNK): - chunk = rows[i:i + _CHUNK] - stmt = pg_insert(InsiderTransaction).values(chunk) - stmt = stmt.on_conflict_do_nothing(constraint="uq_insider_transaction") - result = await db.execute(stmt) - n += result.rowcount - await db.commit() + async with AsyncSessionLocal() as flush_db: + for i in range(0, len(rows), _CHUNK): + chunk = rows[i:i + _CHUNK] + stmt = pg_insert(InsiderTransaction).values(chunk) + stmt = stmt.on_conflict_do_nothing(constraint="uq_insider_transaction") + result = await flush_db.execute(stmt) + n += result.rowcount + await flush_db.commit() return n for idx, entry in enumerate(new_entries, 1): diff --git a/app/services/news/__init__.py b/app/services/news/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/news/alpaca_news_client.py b/app/services/news/alpaca_news_client.py new file mode 100644 index 0000000..df06cec --- /dev/null +++ b/app/services/news/alpaca_news_client.py @@ -0,0 +1,208 @@ +""" +Alpaca News (Benzinga backend) REST client. + +Endpoint: GET /v1beta1/news on data.alpaca.markets +Auth: same APCA-API-KEY-ID/APCA-API-SECRET-KEY as market data. +Free-tier history: ~30 days. Cumulative archive must be self-built via daily +ingest. + +Rate limit handled via existing per-process token-bucket pattern (200 req/min +shared with the market-data client where this matters; News calls are far +less frequent so a small private bucket is sufficient). +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, AsyncIterator + +import httpx + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class AlpacaNewsArticle: + id: int + headline: str + summary: str | None + url: str | None + author: str | None + created_at: datetime + updated_at: datetime + source: str | None # vendor source tag (e.g. "benzinga") + symbols: list[str] + images: list[dict[str, Any]] + content: str | None + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "AlpacaNewsArticle": + return cls( + id=int(payload["id"]), + headline=payload.get("headline") or "", + summary=payload.get("summary"), + url=payload.get("url"), + author=payload.get("author"), + created_at=_parse_iso(payload["created_at"]), + updated_at=_parse_iso(payload.get("updated_at") or payload["created_at"]), + source=payload.get("source"), + symbols=list(payload.get("symbols") or []), + images=list(payload.get("images") or []), + content=payload.get("content"), + ) + + +def _parse_iso(s: str) -> datetime: + """Parse Alpaca timestamps (RFC 3339, may end in 'Z').""" + if s.endswith("Z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +class AlpacaNewsClient: + """Alpaca News API client. Reuses ALPACA_API_KEY/SECRET.""" + + def __init__( + self, + api_key: str | None = None, + secret_key: str | None = None, + base_url: str | None = None, + max_requests_per_min: int = 60, + ): + self.api_key = api_key or settings.ALPACA_API_KEY + self.secret_key = secret_key or settings.ALPACA_SECRET_KEY + self.base_url = (base_url or settings.ALPACA_NEWS_BASE_URL).rstrip("/") + self._max_rpm = max_requests_per_min + self._request_times: list[float] = [] + self._client: httpx.AsyncClient | None = None + self._lock = asyncio.Lock() + + def is_configured(self) -> bool: + return bool(self.api_key) and bool(self.secret_key) + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + base_url=self.base_url, + headers={ + "APCA-API-KEY-ID": self.api_key, + "APCA-API-SECRET-KEY": self.secret_key, + }, + timeout=30.0, + ) + return self._client + + async def close(self) -> None: + if self._client and not self._client.is_closed: + await self._client.aclose() + + async def _wait_for_rate_limit(self) -> None: + async with self._lock: + now = time.monotonic() + self._request_times = [t for t in self._request_times if now - t < 60] + if len(self._request_times) >= self._max_rpm: + sleep_for = 60 - (now - self._request_times[0]) + 0.1 + if sleep_for > 0: + logger.debug(f"AlpacaNews rate limit reached, sleeping {sleep_for:.1f}s") + await asyncio.sleep(sleep_for) + self._request_times.append(time.monotonic()) + + async def _request( + self, + path: str, + params: dict[str, Any] | None = None, + retries: int = 3, + ) -> dict[str, Any]: + await self._wait_for_rate_limit() + client = await self._get_client() + + last_exc: Exception | None = None + for attempt in range(retries): + try: + resp = await client.get(path, params=params) + if resp.status_code == 429: + wait = 2 ** attempt + logger.warning(f"AlpacaNews 429 — retrying in {wait}s (attempt {attempt + 1})") + await asyncio.sleep(wait) + continue + if resp.status_code >= 500: + wait = 2 ** attempt + logger.warning(f"AlpacaNews {resp.status_code} — retrying in {wait}s (attempt {attempt + 1})") + await asyncio.sleep(wait) + continue + resp.raise_for_status() + return resp.json() + except httpx.HTTPStatusError as exc: + last_exc = exc + if attempt < retries - 1 and exc.response.status_code in (429, 500, 502, 503, 504): + await asyncio.sleep(2 ** attempt) + continue + raise + except (httpx.ConnectError, httpx.ReadTimeout) as exc: + last_exc = exc + if attempt < retries - 1: + await asyncio.sleep(2 ** attempt) + continue + raise + raise last_exc # type: ignore[misc] + + async def fetch_news( + self, + symbols: list[str] | None = None, + start: datetime | None = None, + end: datetime | None = None, + include_content: bool = True, + sort: str = "desc", + page_limit: int = 50, + ) -> AsyncIterator[AlpacaNewsArticle]: + """ + Yield articles for the given symbols (or the whole stream if None) over + [start, end]. Handles pagination via `page_token`. + + Alpaca page_size max = 50. + """ + params: dict[str, Any] = { + "limit": min(page_limit, 50), + "sort": sort, + "include_content": str(include_content).lower(), + } + if symbols: + params["symbols"] = ",".join(s.strip().upper() for s in symbols if s) + if start is not None: + params["start"] = _to_rfc3339(start) + if end is not None: + params["end"] = _to_rfc3339(end) + + page_token: str | None = None + while True: + if page_token: + params["page_token"] = page_token + else: + params.pop("page_token", None) + + payload = await self._request("/v1beta1/news", params=params) + for raw in payload.get("news", []) or []: + try: + yield AlpacaNewsArticle.from_payload(raw) + except Exception as e: + logger.warning(f"AlpacaNews payload parse failed: {e} — {raw.get('id')}") + + page_token = payload.get("next_page_token") + if not page_token: + return + + +def _to_rfc3339(dt: datetime) -> str: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + # Alpaca accepts RFC-3339 with "Z" or "+00:00" + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/app/services/news/category_normalizer.py b/app/services/news/category_normalizer.py new file mode 100644 index 0000000..6963655 --- /dev/null +++ b/app/services/news/category_normalizer.py @@ -0,0 +1,190 @@ +""" +Vendor news category → unified taxonomy normalizer. + +Each vendor uses its own categorization scheme (Alpaca/Benzinga channels, +Finnhub category codes, StockTwits sentiment-only). We map them all to a +single 22-term vocabulary so downstream consumers (fithia2 ORB scoring) can +treat sources interchangeably. Vendor-original categories are preserved on +NewsHeadline.vendor_categories for future re-classification. + +Headline-keyword regex passes provide override hints for category nuance the +vendor labels can't express (e.g. Alpaca tags FDA news as "FDA" but doesn't +distinguish approval vs. rejection). +""" + +from __future__ import annotations + +import re +from typing import Iterable + +UNIFIED_CATEGORIES: frozenset[str] = frozenset({ + "analyst_rating_upgrade", + "analyst_rating_downgrade", + "analyst_rating_initiate", + "earnings_release", + "earnings_preannouncement", + "guidance_update", + "m_and_a", + "partnership", + "contract_award", + "fda_approval", + "fda_rejection", + "clinical_trial", + "litigation", + "regulatory_action", + "sec_filing", + "insider_trading", + "secondary_offering", + "buyback", + "management_change", + "restructuring", + "general", +}) + + +# Alpaca News (Benzinga backend) channel taxonomy. Source: +# https://docs.alpaca.markets/reference/news-3 +ALPACA_BENZINGA_MAP: dict[str, str] = { + "Analyst Color": "general", + "Upgrades": "analyst_rating_upgrade", + "Downgrades": "analyst_rating_downgrade", + "Price Target": "general", + "Reiteration": "analyst_rating_initiate", + "Initiation": "analyst_rating_initiate", + "Earnings": "earnings_release", + "Earnings Beats": "earnings_release", + "Earnings Misses": "earnings_release", + "Guidance": "guidance_update", + "M&A": "m_and_a", + "Mergers": "m_and_a", + "Acquisitions": "m_and_a", + "Partnership": "partnership", + "Contract": "contract_award", + "FDA": "fda_approval", # rejection split via headline regex below + "Clinical Trials": "clinical_trial", + "Lawsuit": "litigation", + "Litigation": "litigation", + "SEC Filings": "sec_filing", + "Insider Trades": "insider_trading", + "Offerings": "secondary_offering", + "Buybacks": "buyback", + "Management": "management_change", + "Restructuring": "restructuring", + "Spinoff": "restructuring", + "Bankruptcy": "restructuring", + "Press Releases": "general", + "News": "general", +} + +# Finnhub /company-news category field. Source: +# https://finnhub.io/docs/api/company-news +FINNHUB_MAP: dict[str, str] = { + "company": "general", + "general": "general", + "earnings": "earnings_release", + "merger": "m_and_a", + "ipo": "secondary_offering", + "guidance": "guidance_update", + "rating": "general", # split direction via headline regex + "regulation": "regulatory_action", + "press release": "general", +} + +# StockTwits messages have no vendor category — only sentiment tags. +STOCKTWITS_DEFAULT: str = "general" + + +# Headline regex overrides — applied AFTER vendor map. Matches earlier in this +# list win when multiple match. Each tuple is (compiled regex, target category). +_HEADLINE_OVERRIDES: list[tuple[re.Pattern[str], str]] = [ + # Analyst rating direction (more specific than vendor map) + (re.compile(r"\b(downgrade[ds]?|cuts?\s+(price\s+target|rating)|lowers?\s+rating)\b", re.I), + "analyst_rating_downgrade"), + (re.compile(r"\b(upgrade[ds]?|raises?\s+(price\s+target|rating)|boosts?\s+rating)\b", re.I), + "analyst_rating_upgrade"), + (re.compile(r"\binitiates?\s+(coverage|with)\b", re.I), "analyst_rating_initiate"), + # FDA outcome direction + (re.compile(r"\bFDA\s+(approves?|approval|grants?\s+approval|clears?)\b", re.I), + "fda_approval"), + (re.compile(r"\bFDA\s+(rejects?|rejection|denies?|complete\s+response\s+letter|CRL)\b", re.I), + "fda_rejection"), + # Pre-announcements / guidance distinction + (re.compile(r"\b(pre[\-\s]?announce[sd]?|preliminary\s+results?)\b", re.I), + "earnings_preannouncement"), + (re.compile(r"\b(guidance|outlook|forecast)s?\b.*\b(raises?|lifts?|cuts?|lowers?|reaffirm[s]?)\b", re.I), + "guidance_update"), + # M&A specifics + (re.compile(r"\b(acquires?|to\s+acquire|merger\s+with|takeover)\b", re.I), "m_and_a"), + # Buybacks + (re.compile(r"\b(share\s+repurchase|buyback|stock\s+repurchase)\b", re.I), "buyback"), + # Offerings + (re.compile(r"\b(secondary\s+offering|public\s+offering|equity\s+offering|prices?\s+offering)\b", re.I), + "secondary_offering"), + # Mgmt change + (re.compile(r"\b(CEO|CFO|COO|CTO|chair(man)?|president)\b.*\b(steps?\s+down|resigns?|appointed?|named)\b", re.I), + "management_change"), + # Restructuring + (re.compile(r"\b(layoffs?|restructur(ing|e)|chapter\s+11|bankruptcy|spinoff)\b", re.I), "restructuring"), + # Litigation + (re.compile(r"\b(lawsuit|sued|class\s+action|settles?\s+(suit|claim))\b", re.I), "litigation"), + # Contract awards + (re.compile(r"\b(awarded\s+contract|wins?\s+contract|secures?\s+contract)\b", re.I), "contract_award"), + # Partnership + (re.compile(r"\b(partner(ship|s\s+with)|collaborates?\s+with|joint\s+venture)\b", re.I), "partnership"), +] + + +def normalize_alpaca(channels: Iterable[str] | None, headline: str) -> list[str]: + """Map Alpaca/Benzinga channels + headline keywords → unified categories.""" + cats = _from_vendor_map(channels, ALPACA_BENZINGA_MAP) + cats.update(_from_headline(headline)) + return _finalize(cats) + + +def normalize_finnhub(category: str | None, headline: str) -> list[str]: + """Map Finnhub category + headline keywords → unified categories.""" + cats: set[str] = set() + if category: + mapped = FINNHUB_MAP.get(category.strip().lower()) + if mapped: + cats.add(mapped) + cats.update(_from_headline(headline)) + return _finalize(cats) + + +def normalize_stocktwits(headline: str) -> list[str]: + """StockTwits has no category — only headline regex applies.""" + cats = _from_headline(headline) + return _finalize(cats) + + +def _from_vendor_map(channels: Iterable[str] | None, mapping: dict[str, str]) -> set[str]: + if not channels: + return set() + out: set[str] = set() + for ch in channels: + if not ch: + continue + mapped = mapping.get(ch.strip()) + if mapped: + out.add(mapped) + return out + + +def _from_headline(headline: str) -> set[str]: + if not headline: + return set() + out: set[str] = set() + for pattern, cat in _HEADLINE_OVERRIDES: + if pattern.search(headline): + out.add(cat) + return out + + +def _finalize(cats: set[str]) -> list[str]: + if not cats: + return ["general"] + # Drop "general" if a specific category was matched. + if len(cats) > 1: + cats.discard("general") + return sorted(cats) diff --git a/app/services/news/finnhub_client.py b/app/services/news/finnhub_client.py new file mode 100644 index 0000000..d1f7d3e --- /dev/null +++ b/app/services/news/finnhub_client.py @@ -0,0 +1,111 @@ +""" +Finnhub /company-news client (free tier). + +Rate limit: 60 calls/min on the free plan. We enforce 1 second between calls +via an asyncio.Semaphore + sleep, which is the simplest correct shape since +the entire ingest pipeline is single-process. + +Endpoint shape: /company-news?symbol=AAPL&from=2026-04-01&to=2026-04-25 +Free-tier history: ~12 months. Cumulative archive must be self-built by +running `scripts/news_backfill.py` once at install time, then daily. +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import date +from typing import Any + +import httpx + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class FinnhubClient: + def __init__( + self, + api_key: str | None = None, + base_url: str | None = None, + request_interval_sec: float = 1.05, + ): + self.api_key = api_key or settings.FINNHUB_API_KEY + self.base_url = (base_url or settings.FINNHUB_BASE_URL).rstrip("/") + self._client: httpx.AsyncClient | None = None + # Single-slot semaphore + sleep enforces 60-calls/min ceiling + self._gate = asyncio.Semaphore(1) + self._interval = request_interval_sec + + def is_configured(self) -> bool: + return bool(self.api_key) + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0) + return self._client + + async def close(self) -> None: + if self._client and not self._client.is_closed: + await self._client.aclose() + + async def _request(self, path: str, params: dict[str, Any], retries: int = 3) -> Any: + client = await self._get_client() + params = {**params, "token": self.api_key} + + async with self._gate: + last_exc: Exception | None = None + for attempt in range(retries): + try: + resp = await client.get(path, params=params) + if resp.status_code == 429: + wait = 2 ** attempt + logger.warning(f"Finnhub 429 — sleeping {wait}s (attempt {attempt + 1})") + await asyncio.sleep(wait) + continue + if resp.status_code >= 500: + wait = 2 ** attempt + logger.warning(f"Finnhub {resp.status_code} — retry in {wait}s") + await asyncio.sleep(wait) + continue + resp.raise_for_status() + await asyncio.sleep(self._interval) + return resp.json() + except httpx.HTTPStatusError as exc: + last_exc = exc + if attempt < retries - 1 and exc.response.status_code in (429, 500, 502, 503, 504): + await asyncio.sleep(2 ** attempt) + continue + raise + except (httpx.ConnectError, httpx.ReadTimeout) as exc: + last_exc = exc + if attempt < retries - 1: + await asyncio.sleep(2 ** attempt) + continue + raise + raise last_exc # type: ignore[misc] + + async def fetch_company_news( + self, + symbol: str, + from_date: date, + to_date: date, + ) -> list[dict[str, Any]]: + """Fetch company-news for [from_date, to_date] inclusive. + + One call covers the full range, so split by month at the caller side + for backfill granularity. + """ + payload = await self._request( + "/company-news", + params={ + "symbol": symbol.strip().upper(), + "from": from_date.isoformat(), + "to": to_date.isoformat(), + }, + ) + if not isinstance(payload, list): + logger.warning(f"Finnhub /company-news returned non-list for {symbol}") + return [] + return payload diff --git a/app/services/news/headline_ingest_service.py b/app/services/news/headline_ingest_service.py new file mode 100644 index 0000000..134b0f7 --- /dev/null +++ b/app/services/news/headline_ingest_service.py @@ -0,0 +1,222 @@ +""" +Multi-source headline ingest — vendor payload → NewsHeadline rows. + +For each article, one DB row per (source, source_id, ticker) is emitted so +ticker-indexed queries are O(log n). The full vendor symbol set is preserved +on `tickers_all`. + +Dedup is enforced by `uq_news_headline_source_ticker` + ON CONFLICT DO NOTHING, +making re-ingest idempotent (daily backfill rerunning over an overlapping +window is safe). +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import datetime, timezone +from typing import Iterable + +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.models.news_headline import NewsHeadline +from app.services.news.alpaca_news_client import AlpacaNewsArticle +from app.services.news.category_normalizer import ( + normalize_alpaca, + normalize_finnhub, + normalize_stocktwits, +) + +logger = logging.getLogger(__name__) + +# NewsHeadline has ~17 insertable columns. asyncpg param ceiling 32,767. +# 32767 // 17 ≈ 1927; round down for safety + future column adds. +_CHUNK = 1500 + + +async def insert_headline_rows(rows: list[dict], db: AsyncSession | None = None) -> int: + """Insert pre-built rows with chunked ON CONFLICT DO NOTHING. + + If `db` is provided, it's used directly and the caller commits. + Otherwise a short-lived session is opened and committed here. + """ + if not rows: + return 0 + + if db is not None: + return await _insert_chunked(db, rows) + + async with AsyncSessionLocal() as sess: + inserted = await _insert_chunked(sess, rows) + await sess.commit() + return inserted + + +async def _insert_chunked(sess: AsyncSession, rows: list[dict]) -> int: + inserted = 0 + for i in range(0, len(rows), _CHUNK): + stmt = pg_insert(NewsHeadline).values(rows[i : i + _CHUNK]) + stmt = stmt.on_conflict_do_nothing(constraint="uq_news_headline_source_ticker") + result = await sess.execute(stmt) + inserted += result.rowcount or 0 + await asyncio.sleep(0) + return inserted + + +# --------------------------------------------------------------------------- +# Vendor → row builders +# --------------------------------------------------------------------------- + +def alpaca_articles_to_rows( + articles: Iterable[AlpacaNewsArticle], + ingested_at: datetime | None = None, +) -> list[dict]: + """Expand each Alpaca article into one row per ticker.""" + now = ingested_at or datetime.now(timezone.utc) + out: list[dict] = [] + for art in articles: + if not art.symbols: + continue + vendor_cats = [art.source] if art.source else [] + unified = normalize_alpaca(vendor_cats, art.headline) + # Alpaca's first symbol is conventionally primary + primary = art.symbols[0].upper() + for sym in art.symbols: + sym_u = sym.strip().upper() + if not sym_u: + continue + out.append({ + "source": "alpaca_benzinga", + "source_id": str(art.id), + "ticker": sym_u, + "tickers_all": [s.upper() for s in art.symbols if s], + "published_at": art.created_at, + "headline": art.headline, + "summary": art.summary, + "url": art.url, + "language": "en", + "vendor_categories": vendor_cats, + "categories": unified, + "raw_sentiment": None, # Alpaca free tier does not include sentiment + "is_primary": sym_u == primary, + "ingested_at": now, + }) + return out + + +def finnhub_articles_to_rows( + ticker: str, + articles: Iterable[dict], + ingested_at: datetime | None = None, +) -> list[dict]: + """Convert Finnhub /company-news payloads. Single-ticker per article.""" + now = ingested_at or datetime.now(timezone.utc) + out: list[dict] = [] + sym = ticker.strip().upper() + for art in articles: + if not art.get("id"): + continue + ts = art.get("datetime") + if ts is None: + continue + try: + published_at = datetime.fromtimestamp(int(ts), tz=timezone.utc) + except (TypeError, ValueError): + continue + headline = art.get("headline") or "" + category = art.get("category") + unified = normalize_finnhub(category, headline) + out.append({ + "source": "finnhub", + "source_id": str(art["id"]), + "ticker": sym, + "tickers_all": [sym], + "published_at": published_at, + "headline": headline, + "summary": art.get("summary"), + "url": art.get("url"), + "language": "en", + "vendor_categories": [category] if category else [], + "categories": unified, + "raw_sentiment": None, + "is_primary": True, + "ingested_at": now, + }) + return out + + +def stocktwits_messages_to_rows( + ticker: str, + messages: Iterable[dict], + ingested_at: datetime | None = None, +) -> list[dict]: + """ + Convert StockTwits stream messages (streams/symbol/{ticker}.json). + + Sentiment tag is the message's entities.sentiment.basic in {"Bullish", "Bearish"}. + Mapped to raw_sentiment ∈ {+1, -1}; absent tag → NULL (neutral). + """ + now = ingested_at or datetime.now(timezone.utc) + out: list[dict] = [] + sym = ticker.strip().upper() + for msg in messages: + if not msg.get("id"): + continue + ts = msg.get("created_at") + if not ts: + continue + try: + published_at = _parse_st_iso(ts) + except Exception: + continue + body = (msg.get("body") or "").strip() + if not body: + continue + sentiment = _extract_st_sentiment(msg) + unified = normalize_stocktwits(body) + # Collect all symbol tags for tickers_all + all_syms = [s.get("symbol", "").upper() for s in (msg.get("symbols") or []) if s.get("symbol")] + if sym not in all_syms: + all_syms.insert(0, sym) + out.append({ + "source": "stocktwits", + "source_id": str(msg["id"]), + "ticker": sym, + "tickers_all": all_syms, + "published_at": published_at, + "headline": body[:300], # truncate for headline; full body in summary + "summary": body, + "url": f"https://stocktwits.com/message/{msg['id']}", + "language": "en", + "vendor_categories": [sentiment] if sentiment else [], + "categories": unified, + "raw_sentiment": _sentiment_to_score(sentiment), + "is_primary": all_syms[0] == sym if all_syms else True, + "ingested_at": now, + }) + return out + + +def _parse_st_iso(s: str) -> datetime: + if s.endswith("Z"): + s = s[:-1] + "+00:00" + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt + + +def _extract_st_sentiment(msg: dict) -> str | None: + entities = msg.get("entities") or {} + sentiment = entities.get("sentiment") or {} + return sentiment.get("basic") # "Bullish" | "Bearish" | None + + +def _sentiment_to_score(tag: str | None) -> float | None: + if tag == "Bullish": + return 1.0 + if tag == "Bearish": + return -1.0 + return None diff --git a/app/services/news/scheduler.py b/app/services/news/scheduler.py new file mode 100644 index 0000000..4ff5646 --- /dev/null +++ b/app/services/news/scheduler.py @@ -0,0 +1,310 @@ +""" +APScheduler entry point for News v2 ingest. + +Jobs: + alpaca_news_realtime_poll every 5 min: latest Alpaca News stream + alpaca_news_daily_backfill 04:30 ET: prior 26h Alpaca News refill + stocktwits_universe_refresh 09:00 ET: rebuild dynamic poll list + stocktwits_poll every 5 min: per-ticker stream pull + finnhub_daily_backfill 05:00 ET: prior day Finnhub /company-news + +Activation gate: + NEWS_INGEST_ENABLED must be true. Per-source key presence determines which + jobs are registered — Alpaca needs ALPACA_API_KEY/SECRET, Finnhub needs + FINNHUB_API_KEY, StockTwits needs nothing. If a source's key is missing, + its jobs are skipped with a WARNING. If NO sources are configured the + scheduler aborts (fail-fast) so the misconfiguration is loud. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +_scheduler = None + + +def _get_scheduler(): + global _scheduler + if _scheduler is None: + try: + from apscheduler.schedulers.asyncio import AsyncIOScheduler + _scheduler = AsyncIOScheduler(timezone=settings.NEWS_INGEST_TIMEZONE) + except ImportError: + logger.warning("apscheduler not installed; news ingest scheduling disabled") + return None + return _scheduler + + +# --------------------------------------------------------------------------- +# Job bodies +# --------------------------------------------------------------------------- + +async def _run_alpaca_realtime_poll() -> None: + """Poll the latest 5 minutes of Alpaca News (no symbol filter).""" + from app.services.news.alpaca_news_client import AlpacaNewsClient + from app.services.news.headline_ingest_service import ( + alpaca_articles_to_rows, + insert_headline_rows, + ) + + client = AlpacaNewsClient() + if not client.is_configured(): + return + + end = datetime.now(timezone.utc) + start = end - timedelta(minutes=10) # 5-min poll + 5-min overlap for safety + + try: + articles = [] + async for art in client.fetch_news(symbols=None, start=start, end=end, page_limit=50): + articles.append(art) + rows = alpaca_articles_to_rows(articles) + if rows: + inserted = await insert_headline_rows(rows) + if inserted: + logger.info(f"[News] Alpaca realtime: +{inserted} new ({len(articles)} articles)") + except Exception as e: + logger.error(f"[News] Alpaca realtime poll failed: {e}") + finally: + await client.close() + + +async def _run_alpaca_daily_backfill() -> None: + """Refill prior 26h Alpaca News stream to catch any gaps.""" + from app.services.news.alpaca_news_client import AlpacaNewsClient + from app.services.news.headline_ingest_service import ( + alpaca_articles_to_rows, + insert_headline_rows, + ) + + client = AlpacaNewsClient() + if not client.is_configured(): + return + + end = datetime.now(timezone.utc) + start = end - timedelta(hours=26) + + try: + articles = [] + async for art in client.fetch_news(symbols=None, start=start, end=end, page_limit=50): + articles.append(art) + rows = alpaca_articles_to_rows(articles) + inserted = await insert_headline_rows(rows) if rows else 0 + logger.info(f"[News] Alpaca daily backfill: +{inserted} new ({len(articles)} articles, 26h)") + except Exception as e: + logger.error(f"[News] Alpaca daily backfill failed: {e}") + finally: + await client.close() + + +async def _run_stocktwits_universe_refresh() -> None: + from app.services.news.stocktwits_universe import compute_stocktwits_universe + try: + await compute_stocktwits_universe() + except Exception as e: + logger.error(f"[News] StockTwits universe refresh failed: {e}") + + +async def _run_stocktwits_poll() -> None: + """Pull recent messages for every ticker in the cached universe.""" + from app.services.news.stocktwits_client import StocktwitsClient + from app.services.news.stocktwits_universe import get_cached_universe + from app.services.news.headline_ingest_service import ( + stocktwits_messages_to_rows, + insert_headline_rows, + ) + + universe = await get_cached_universe() + if not universe: + # On first start there's no cached universe — compute on the fly so + # the very first poll has something to do. + from app.services.news.stocktwits_universe import compute_stocktwits_universe + try: + universe = await compute_stocktwits_universe() + except Exception as e: + logger.warning(f"[News] StockTwits initial universe build failed: {e}") + return + if not universe: + return + + client = StocktwitsClient() + total_inserted = 0 + try: + for ticker in universe: + try: + payload = await client.fetch_symbol_stream(ticker, max_results=30) + except Exception as e: + logger.warning(f"[News] StockTwits {ticker} failed: {e}") + continue + messages = payload.get("messages") or [] + if not messages: + continue + rows = stocktwits_messages_to_rows(ticker, messages) + if rows: + total_inserted += await insert_headline_rows(rows) + finally: + await client.close() + + if total_inserted: + logger.info(f"[News] StockTwits poll: +{total_inserted} new across {len(universe)} tickers") + + +async def _run_finnhub_daily_backfill() -> None: + """Pull yesterday's Finnhub /company-news for the active V49 universe.""" + from app.services.news.finnhub_client import FinnhubClient + from app.services.news.headline_ingest_service import ( + finnhub_articles_to_rows, + insert_headline_rows, + ) + from sqlalchemy import select + from app.core.database import AsyncSessionLocal + from app.models.universe_snapshot import UniverseTickerRegistry + + client = FinnhubClient() + if not client.is_configured(): + return + + today = datetime.now(timezone.utc).date() + yesterday = today - timedelta(days=1) + + # Pull active universe tickers + async with AsyncSessionLocal() as db: + rows = (await db.execute( + select(UniverseTickerRegistry.ticker).where(UniverseTickerRegistry.is_active.is_(True)) + )).scalars().all() + tickers = [t.upper() for t in rows if t] + if not tickers: + logger.info("[News] Finnhub daily: no active universe tickers") + return + + total_inserted = 0 + try: + for ticker in tickers: + try: + payload = await client.fetch_company_news(ticker, yesterday, today) + except Exception as e: + logger.warning(f"[News] Finnhub {ticker} failed: {e}") + continue + if not payload: + continue + news_rows = finnhub_articles_to_rows(ticker, payload) + if news_rows: + total_inserted += await insert_headline_rows(news_rows) + finally: + await client.close() + + logger.info(f"[News] Finnhub daily: +{total_inserted} new across {len(tickers)} tickers") + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +def start_news_ingest_scheduler() -> None: + """Wire jobs into the AsyncIOScheduler. Idempotent.""" + if not settings.NEWS_INGEST_ENABLED: + logger.info("[News] NEWS_INGEST_ENABLED=false — scheduler not started") + return + + sched = _get_scheduler() + if sched is None: + return + + # Source key presence checks (fail-fast on no-keys-at-all) + alpaca_ok = bool(settings.ALPACA_API_KEY and settings.ALPACA_SECRET_KEY) + finnhub_ok = bool(settings.FINNHUB_API_KEY) + stocktwits_ok = True # public API + enabled_sources = [] + if alpaca_ok: + enabled_sources.append("alpaca_benzinga") + else: + logger.warning("[News] ALPACA_API_KEY/SECRET missing — Alpaca News jobs skipped") + if finnhub_ok: + enabled_sources.append("finnhub") + else: + logger.warning("[News] FINNHUB_API_KEY missing — Finnhub jobs skipped") + if stocktwits_ok: + enabled_sources.append("stocktwits") + + # Fail-fast: if neither Alpaca nor Finnhub is configured, the only source + # is StockTwits which is low-signal on its own. Abort instead of silently + # running a degenerate ingest. + if not (alpaca_ok or finnhub_ok): + logger.error( + "[News] NEWS_INGEST_ENABLED=true but no premium-source keys configured " + "(need at least ALPACA_API_KEY/SECRET or FINNHUB_API_KEY) — aborting scheduler" + ) + return + + try: + from apscheduler.triggers.cron import CronTrigger + from apscheduler.triggers.interval import IntervalTrigger + + if alpaca_ok: + sched.add_job( + _run_alpaca_realtime_poll, + trigger=IntervalTrigger(minutes=5), + id="alpaca_news_realtime_poll", + replace_existing=True, + max_instances=1, + misfire_grace_time=120, + coalesce=True, + ) + sched.add_job( + _run_alpaca_daily_backfill, + trigger=CronTrigger(hour=4, minute=30), + id="alpaca_news_daily_backfill", + replace_existing=True, + max_instances=1, + misfire_grace_time=600, + coalesce=True, + ) + + if stocktwits_ok: + sched.add_job( + _run_stocktwits_universe_refresh, + trigger=CronTrigger(day_of_week="mon-fri", hour=9, minute=0), + id="stocktwits_universe_refresh", + replace_existing=True, + max_instances=1, + misfire_grace_time=600, + coalesce=True, + ) + sched.add_job( + _run_stocktwits_poll, + trigger=IntervalTrigger(minutes=5), + id="stocktwits_poll", + replace_existing=True, + max_instances=1, + misfire_grace_time=120, + coalesce=True, + ) + + if finnhub_ok: + sched.add_job( + _run_finnhub_daily_backfill, + trigger=CronTrigger(hour=5, minute=0), + id="finnhub_daily_backfill", + replace_existing=True, + max_instances=1, + misfire_grace_time=600, + coalesce=True, + ) + + if not sched.running: + sched.start() + logger.info(f"[News] ingest scheduler started — sources: {enabled_sources}") + except Exception as e: + logger.error(f"[News] ingest scheduler start failed: {e}") + + +def stop_news_ingest_scheduler() -> None: + sched = _get_scheduler() + if sched and sched.running: + sched.shutdown(wait=False) + logger.info("[News] ingest scheduler stopped") diff --git a/app/services/news/session_aggregator.py b/app/services/news/session_aggregator.py new file mode 100644 index 0000000..2fdecfd --- /dev/null +++ b/app/services/news/session_aggregator.py @@ -0,0 +1,227 @@ +""" +Session-aggregated news/social signal. + +Computes per-(ticker, session_date, window) aggregates directly from raw +NewsHeadline rows via SQL — no materialized table. Redis caching at the +endpoint layer absorbs repeated reads. + +Aggregate fields: + headline_count, primary_count, first/last_headline_at, + category_counts (JSON), sentiment_mean, sentiment_recency_weighted, + social: {message_count, bull_count, bear_count, bull_bear_ratio}, + sources_present +""" + +from __future__ import annotations + +import logging +import math +from collections import Counter +from dataclasses import asdict, dataclass, field +from datetime import date, datetime, timezone +from typing import Iterable + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.news_headline import NewsHeadline +from app.services.news.session_window import WindowName, session_window + +logger = logging.getLogger(__name__) + +# Recency-weight half-life: 1 hour close to open contributes weight 1.0, +# 6 hours earlier ≈ 0.55. Matches plan (decay=0.1 per hour). +_RECENCY_DECAY_PER_MIN = 0.1 / 60.0 + + +@dataclass +class SocialStats: + message_count: int = 0 + bull_count: int = 0 + bear_count: int = 0 + bull_bear_ratio: float | None = None # bull/(bull+bear); None if no directional msgs + + +@dataclass +class SessionAggregate: + ticker: str + session_date: str # ISO date (ET) + window: str + headline_count: int = 0 + primary_count: int = 0 + first_headline_at: str | None = None + last_headline_at: str | None = None + category_counts: dict[str, int] = field(default_factory=dict) + sentiment_mean: float | None = None + sentiment_recency_weighted: float | None = None + social: SocialStats = field(default_factory=SocialStats) + sources_present: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return asdict(self) + + +async def aggregate_session( + db: AsyncSession, + ticker: str, + session_date: date, + window: WindowName, + sources: list[str] | None = None, + pit_cutoff: datetime | None = None, +) -> SessionAggregate: + """ + Aggregate news headlines for one (ticker, session_date, window). + + `pit_cutoff` (optional) excludes rows whose `ingested_at > cutoff` to + enable lookahead-safe backtest queries. If omitted, defaults to the + session window's end_utc (so we never see headlines that arrived after + the window closed in real time). + """ + ticker_u = ticker.strip().upper() + start_utc, end_utc = session_window(session_date, window) + cutoff = pit_cutoff or end_utc + + stmt = select(NewsHeadline).where( + NewsHeadline.ticker == ticker_u, + NewsHeadline.published_at >= start_utc, + NewsHeadline.published_at < end_utc, + NewsHeadline.ingested_at <= cutoff, + ) + if sources: + stmt = stmt.where(NewsHeadline.source.in_([s.strip() for s in sources if s])) + + result = await db.execute(stmt) + rows = result.scalars().all() + + return _build_aggregate( + rows=rows, + ticker=ticker_u, + session_date=session_date, + window=window, + window_end_utc=end_utc, + ) + + +async def aggregate_session_batch( + db: AsyncSession, + tickers: list[str], + session_date: date, + window: WindowName, + sources: list[str] | None = None, + pit_cutoff: datetime | None = None, +) -> dict[str, SessionAggregate]: + """Aggregate for many tickers in one query. Missing tickers → empty agg.""" + tickers_u = [t.strip().upper() for t in tickers if t and t.strip()] + if not tickers_u: + return {} + + start_utc, end_utc = session_window(session_date, window) + cutoff = pit_cutoff or end_utc + + stmt = select(NewsHeadline).where( + NewsHeadline.ticker.in_(tickers_u), + NewsHeadline.published_at >= start_utc, + NewsHeadline.published_at < end_utc, + NewsHeadline.ingested_at <= cutoff, + ) + if sources: + stmt = stmt.where(NewsHeadline.source.in_([s.strip() for s in sources if s])) + + result = await db.execute(stmt) + rows = result.scalars().all() + + by_ticker: dict[str, list[NewsHeadline]] = {t: [] for t in tickers_u} + for r in rows: + by_ticker.setdefault(r.ticker, []).append(r) + + out: dict[str, SessionAggregate] = {} + for t in tickers_u: + out[t] = _build_aggregate( + rows=by_ticker.get(t, []), + ticker=t, + session_date=session_date, + window=window, + window_end_utc=end_utc, + ) + return out + + +# --------------------------------------------------------------------------- +# Aggregate construction +# --------------------------------------------------------------------------- + +def _build_aggregate( + rows: Iterable[NewsHeadline], + ticker: str, + session_date: date, + window: str, + window_end_utc: datetime, +) -> SessionAggregate: + rows = list(rows) + agg = SessionAggregate( + ticker=ticker, + session_date=session_date.isoformat(), + window=window, + ) + if not rows: + return agg + + cat_counter: Counter[str] = Counter() + sentiments: list[float] = [] + weighted_num = 0.0 + weighted_den = 0.0 + sources: set[str] = set() + social_msgs = 0 + social_bull = 0 + social_bear = 0 + first_at: datetime | None = None + last_at: datetime | None = None + primary_count = 0 + + for r in rows: + sources.add(r.source) + if r.is_primary: + primary_count += 1 + + for c in (r.categories or []): + cat_counter[c] += 1 + + pub = r.published_at + if pub.tzinfo is None: + pub = pub.replace(tzinfo=timezone.utc) + if first_at is None or pub < first_at: + first_at = pub + if last_at is None or pub > last_at: + last_at = pub + + if r.raw_sentiment is not None: + sentiments.append(float(r.raw_sentiment)) + # Recency weight: closer to window end_utc → higher weight + mins_before_end = max(0.0, (window_end_utc - pub).total_seconds() / 60.0) + w = math.exp(-_RECENCY_DECAY_PER_MIN * mins_before_end) + weighted_num += w * float(r.raw_sentiment) + weighted_den += w + + if r.source == "stocktwits": + social_msgs += 1 + if r.raw_sentiment == 1.0: + social_bull += 1 + elif r.raw_sentiment == -1.0: + social_bear += 1 + + agg.headline_count = len(rows) + agg.primary_count = primary_count + agg.first_headline_at = first_at.isoformat() if first_at else None + agg.last_headline_at = last_at.isoformat() if last_at else None + agg.category_counts = dict(cat_counter) + agg.sentiment_mean = (sum(sentiments) / len(sentiments)) if sentiments else None + agg.sentiment_recency_weighted = (weighted_num / weighted_den) if weighted_den > 0 else None + directional = social_bull + social_bear + agg.social = SocialStats( + message_count=social_msgs, + bull_count=social_bull, + bear_count=social_bear, + bull_bear_ratio=(social_bull / directional) if directional > 0 else None, + ) + agg.sources_present = sorted(sources) + return agg diff --git a/app/services/news/session_window.py b/app/services/news/session_window.py new file mode 100644 index 0000000..660d71c --- /dev/null +++ b/app/services/news/session_window.py @@ -0,0 +1,150 @@ +""" +Trading-session window math (NYSE / XNYS). + +`session_window(date, "premarket"|"intraday"|"post"|"full_session")` returns +the (start_utc, end_utc) bounds the news aggregator filters on. NYSE holidays +and short-day closes (1pm ET on day-after-Thanksgiving etc.) are handled by +pandas_market_calendars when available; falls back to weekday-only logic if +the dependency is missing. +""" + +from __future__ import annotations + +import logging +from datetime import date, datetime, time, timedelta, timezone +from functools import lru_cache +from typing import Literal +from zoneinfo import ZoneInfo + +logger = logging.getLogger(__name__) + +ET = ZoneInfo("America/New_York") + +WindowName = Literal["premarket", "intraday", "post", "full_session"] + +# Default ET clock-time bounds. Short days (early close) override regular_close +# via the market-calendar lookup below. `post` window ends at the *next* +# trading day's premarket start (04:00 ET) to avoid overlap. +_DEFAULT_PREMARKET_START_T = time(4, 0) +_DEFAULT_REGULAR_OPEN_T = time(9, 30) +_DEFAULT_REGULAR_CLOSE_T = time(16, 0) + + +def session_window(session_date: date, window: WindowName) -> tuple[datetime, datetime]: + """Return UTC bounds for the given window on the given ET session date.""" + if not _is_trading_day(session_date): + raise ValueError( + f"{session_date} is not a NYSE trading day; choose the next/prev session" + ) + + open_dt_et, close_dt_et = _session_bounds_et(session_date) + prev_close_dt_et = _prev_session_close_et(session_date) + next_premarket_start_et = _next_session_premarket_start_et(session_date) + + if window == "premarket": + start_et = prev_close_dt_et + end_et = open_dt_et + elif window == "intraday": + start_et = open_dt_et + end_et = close_dt_et + elif window == "post": + # Post-market ends at next session's premarket start (04:00 ET) to avoid + # overlap with the next session's `premarket` window. + start_et = close_dt_et + end_et = next_premarket_start_et + elif window == "full_session": + start_et = prev_close_dt_et + end_et = next_premarket_start_et + else: + raise ValueError(f"Unknown window: {window}") + + return _to_utc(start_et), _to_utc(end_et) + + +def _to_utc(dt: datetime) -> datetime: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=ET) + return dt.astimezone(timezone.utc) + + +# --------------------------------------------------------------------------- +# Market-calendar lookups (cached) +# --------------------------------------------------------------------------- + +@lru_cache(maxsize=1) +def _calendar(): + try: + import pandas_market_calendars as mcal # type: ignore + return mcal.get_calendar("XNYS") + except Exception as e: + logger.warning( + "pandas_market_calendars not available (%s); falling back to weekday-only session logic", + e, + ) + return None + + +def _is_trading_day(d: date) -> bool: + cal = _calendar() + if cal is None: + return d.weekday() < 5 + schedule = cal.schedule(start_date=d, end_date=d) + return not schedule.empty + + +def _session_bounds_et(d: date) -> tuple[datetime, datetime]: + """Regular open/close in ET. Honors short days when calendar is available.""" + cal = _calendar() + if cal is not None: + schedule = cal.schedule(start_date=d, end_date=d) + if not schedule.empty: + row = schedule.iloc[0] + open_utc = row["market_open"].to_pydatetime() + close_utc = row["market_close"].to_pydatetime() + return open_utc.astimezone(ET), close_utc.astimezone(ET) + return ( + datetime.combine(d, _DEFAULT_REGULAR_OPEN_T, tzinfo=ET), + datetime.combine(d, _DEFAULT_REGULAR_CLOSE_T, tzinfo=ET), + ) + + +def _prev_session_close_et(d: date) -> datetime: + prev = _prev_trading_day(d) + _, close_dt_et = _session_bounds_et(prev) + return close_dt_et + + +def _next_session_open_et(d: date) -> datetime: + nxt = _next_trading_day(d) + open_dt_et, _ = _session_bounds_et(nxt) + return open_dt_et + + +def _next_session_premarket_start_et(d: date) -> datetime: + """Next trading day's premarket window start (04:00 ET).""" + nxt = _next_trading_day(d) + return datetime.combine(nxt, _DEFAULT_PREMARKET_START_T, tzinfo=ET) + + +def _prev_trading_day(d: date, max_lookback: int = 10) -> date: + cal = _calendar() + if cal is not None: + schedule = cal.schedule(start_date=d - timedelta(days=max_lookback), end_date=d - timedelta(days=1)) + if not schedule.empty: + return schedule.index[-1].date() + candidate = d - timedelta(days=1) + while candidate.weekday() >= 5: + candidate -= timedelta(days=1) + return candidate + + +def _next_trading_day(d: date, max_lookahead: int = 10) -> date: + cal = _calendar() + if cal is not None: + schedule = cal.schedule(start_date=d + timedelta(days=1), end_date=d + timedelta(days=max_lookahead)) + if not schedule.empty: + return schedule.index[0].date() + candidate = d + timedelta(days=1) + while candidate.weekday() >= 5: + candidate += timedelta(days=1) + return candidate diff --git a/app/services/news/stocktwits_client.py b/app/services/news/stocktwits_client.py new file mode 100644 index 0000000..3be3644 --- /dev/null +++ b/app/services/news/stocktwits_client.py @@ -0,0 +1,96 @@ +""" +StockTwits public API client. + +Endpoint: GET /streams/symbol/{ticker}.json on api.stocktwits.com/api/2 +Auth: none required for public streams. +Rate limit: ~200 req/hr per IP. We enforce a token bucket of one request per +3.6 seconds (60 / hour buffer below the 200/hr ceiling) — well under the +documented limit even with multiple ingestor processes. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Any + +import httpx + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class StocktwitsClient: + def __init__( + self, + base_url: str | None = None, + request_interval_sec: float = 3.6, + ): + self.base_url = (base_url or settings.STOCKTWITS_BASE_URL).rstrip("/") + self._client: httpx.AsyncClient | None = None + self._gate = asyncio.Semaphore(1) + self._interval = request_interval_sec + self._last_request_at: float = 0.0 + + def is_configured(self) -> bool: + return True # public API + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient(base_url=self.base_url, timeout=20.0) + return self._client + + async def close(self) -> None: + if self._client and not self._client.is_closed: + await self._client.aclose() + + async def fetch_symbol_stream( + self, + symbol: str, + since_id: int | None = None, + max_results: int = 30, + ) -> dict[str, Any]: + """ + Fetch the recent message stream for one symbol. + + `since_id` (StockTwits parameter `since`) yields only messages whose ID + is greater than the given value. Pass the highest message ID seen on + the previous poll to stay incremental and avoid re-ingesting duplicates + (dedup is also enforced at the DB layer via uq_news_headline_*). + """ + params: dict[str, Any] = {"limit": min(max_results, 30)} + if since_id is not None: + params["since"] = since_id + + sym = symbol.strip().upper() + path = f"/streams/symbol/{sym}.json" + + async with self._gate: + now = time.monotonic() + wait = self._interval - (now - self._last_request_at) + if wait > 0: + await asyncio.sleep(wait) + + client = await self._get_client() + try: + resp = await client.get(path, params=params) + self._last_request_at = time.monotonic() + except (httpx.ConnectError, httpx.ReadTimeout) as e: + logger.warning(f"StockTwits fetch failed {sym}: {e}") + return {"messages": []} + + if resp.status_code == 429: + logger.warning(f"StockTwits 429 on {sym} — caller should back off") + return {"messages": []} + if resp.status_code == 404: + # Symbol not found on StockTwits — treat as empty rather than error + return {"messages": []} + try: + resp.raise_for_status() + except httpx.HTTPStatusError as e: + logger.warning(f"StockTwits {resp.status_code} on {sym}: {e}") + return {"messages": []} + + return resp.json() diff --git a/app/services/news/stocktwits_universe.py b/app/services/news/stocktwits_universe.py new file mode 100644 index 0000000..2c8f627 --- /dev/null +++ b/app/services/news/stocktwits_universe.py @@ -0,0 +1,216 @@ +""" +Dynamic StockTwits subscription universe. + +Single V49 universe ("today only") would create a 16-hour data hole during +the premarket window (prev 16:00 ET → today 09:30 ET). Instead we union: + + (last N days of UniverseSnapshot active tickers) + ∪ + (today's premarket gap movers above threshold) + +Capped at STOCKTWITS_UNIVERSE_MAX_SIZE (default 300) to fit comfortably under +the 200-req/hr StockTwits ceiling at one poll per 5 minutes. + +Computed once per trading day at 09:00 ET and cached in Redis under +`news_v2:stocktwits:universe`. The poller reads that key each cycle. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +import orjson +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import AsyncSessionLocal +from app.models.universe_snapshot import UniverseSnapshot +from app.utils.cache import get_redis + +logger = logging.getLogger(__name__) + +REDIS_KEY = "news_v2:stocktwits:universe" +REDIS_TTL_SEC = 24 * 60 * 60 # 1 day + + +async def compute_stocktwits_universe( + db: AsyncSession | None = None, + lookback_days: int | None = None, + max_size: int | None = None, + gap_threshold: float | None = None, +) -> list[str]: + """Compute and persist the StockTwits subscription universe to Redis.""" + lookback_days = lookback_days or settings.STOCKTWITS_UNIVERSE_LOOKBACK_DAYS + max_size = max_size or settings.STOCKTWITS_UNIVERSE_MAX_SIZE + gap_threshold = gap_threshold if gap_threshold is not None else settings.STOCKTWITS_PREMARKET_GAP_THRESHOLD + + own_session = db is None + if own_session: + db = AsyncSessionLocal() + await db.__aenter__() # type: ignore[attr-defined] + try: + recent_universe = await _recent_universe_tickers(db, lookback_days) # type: ignore[arg-type] + movers = await _premarket_gap_movers(db, gap_threshold) # type: ignore[arg-type] + finally: + if own_session and db is not None: + await db.__aexit__(None, None, None) # type: ignore[attr-defined] + + union: list[str] = [] + seen: set[str] = set() + # Movers first so they survive if we hit max_size truncation + for t in movers + recent_universe: + if t and t not in seen: + seen.add(t) + union.append(t) + if len(union) >= max_size: + break + + await _persist_to_redis(union) + logger.info( + f"StockTwits universe: {len(union)} tickers " + f"({len(movers)} movers, {len(recent_universe)} recent universe, capped at {max_size})" + ) + return union + + +async def get_cached_universe() -> list[str]: + """Read the current Redis-cached universe; returns [] if absent.""" + redis = await get_redis() + if redis is None: + return [] + try: + raw = await redis.get(REDIS_KEY) + if raw is None: + return [] + data = orjson.loads(raw) + if isinstance(data, list): + return [str(t).upper() for t in data] + except Exception as e: + logger.warning(f"StockTwits universe read failed: {e}") + return [] + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + +async def _recent_universe_tickers(db: AsyncSession, lookback_days: int) -> list[str]: + """Tickers from UniverseSnapshot, ranked by market cap. + + Prefers the recent ``lookback_days`` window. If that's empty (the monthly + snapshot job hasn't refreshed in a while), falls back to the most recent + snapshot in the table — top market-cap tickers stay roughly stable across + months, so a stale snapshot is still useful for keeping the StockTwits + poll alive instead of returning []. + """ + from sqlalchemy import desc, func + + cutoff = datetime.now(timezone.utc) - timedelta(days=lookback_days) + # No DISTINCT here — caller dedupes via a set. Adding DISTINCT alongside + # ORDER BY market_cap fights asyncpg's "ORDER BY must appear in SELECT + # list" rule for SELECT DISTINCT. + stmt = ( + select(UniverseSnapshot.ticker) + .where(UniverseSnapshot.snapshot_date >= cutoff) + .order_by(desc(UniverseSnapshot.market_cap)) + ) + rows = (await db.execute(stmt)).scalars().all() + if rows: + return [t.upper() for t in rows if t] + + # Fallback — pick the latest snapshot date and take its top tickers. + latest_dt = ( + await db.execute(select(func.max(UniverseSnapshot.snapshot_date))) + ).scalar() + if not latest_dt: + return [] + fallback_stmt = ( + select(UniverseSnapshot.ticker) + .where(UniverseSnapshot.snapshot_date == latest_dt) + .order_by(desc(UniverseSnapshot.market_cap)) + ) + rows = (await db.execute(fallback_stmt)).scalars().all() + logger.warning( + f"StockTwits universe: no snapshots in last {lookback_days}d; " + f"falling back to {latest_dt.date()} snapshot ({len(rows)} tickers)" + ) + return [t.upper() for t in rows if t] + + +async def _premarket_gap_movers(db: AsyncSession, gap_threshold: float) -> list[str]: + """ + Today's premarket gap movers above |threshold|. + + Sources: + AlpacaPriceData: yesterday's daily close (1d) + today's intraday (1m/5m) + first available premarket bar. + + Returns at most a few hundred tickers; ordered by absolute gap descending + so movers survive max_size truncation. + """ + # Lazy import to keep startup fast & break import cycles + from app.models.alpaca_price import AlpacaPriceData + + today_utc = datetime.now(timezone.utc).date() + yday_utc = today_utc - timedelta(days=1) + + # Daily close for the prior trading day + daily_stmt = select( + AlpacaPriceData.ticker, AlpacaPriceData.close, AlpacaPriceData.date + ).where( + AlpacaPriceData.interval == "1d", + AlpacaPriceData.date >= datetime(yday_utc.year, yday_utc.month, yday_utc.day, tzinfo=timezone.utc) - timedelta(days=4), + AlpacaPriceData.date < datetime(today_utc.year, today_utc.month, today_utc.day, tzinfo=timezone.utc), + ) + daily_rows = (await db.execute(daily_stmt)).all() + + # Sort ascending by date so the loop's last write per ticker is the latest + last_close: dict[str, float] = {} + for tkr, close, dt in sorted(daily_rows, key=lambda r: r[2]): + last_close[tkr] = float(close) + + if not last_close: + return [] + + # Premarket bars: between today 04:00 ET (≈ 08:00 UTC EDT, 09:00 EST) and 09:30 ET + today_start = datetime(today_utc.year, today_utc.month, today_utc.day, 8, 0, tzinfo=timezone.utc) + today_open = datetime(today_utc.year, today_utc.month, today_utc.day, 13, 30, tzinfo=timezone.utc) + pm_stmt = select( + AlpacaPriceData.ticker, AlpacaPriceData.close, AlpacaPriceData.date + ).where( + AlpacaPriceData.interval.in_(("1m", "5m", "15m")), + AlpacaPriceData.date >= today_start, + AlpacaPriceData.date < today_open, + AlpacaPriceData.ticker.in_(list(last_close.keys())), + ) + pm_rows = (await db.execute(pm_stmt)).all() + + # Keep latest premarket close per ticker + pm_last: dict[str, float] = {} + for tkr, close, dt in sorted(pm_rows, key=lambda r: r[2]): + pm_last[tkr] = float(close) + + gaps: list[tuple[str, float]] = [] + for tkr, pm_close in pm_last.items(): + prev_close = last_close.get(tkr) + if not prev_close or prev_close == 0: + continue + gap = (pm_close - prev_close) / prev_close + if abs(gap) >= gap_threshold: + gaps.append((tkr, abs(gap))) + + gaps.sort(key=lambda x: x[1], reverse=True) + return [t for t, _ in gaps] + + +async def _persist_to_redis(tickers: list[str]) -> None: + redis = await get_redis() + if redis is None: + logger.warning("Redis unavailable — StockTwits universe not persisted") + return + try: + await redis.set(REDIS_KEY, orjson.dumps(tickers), ex=REDIS_TTL_SEC) + except Exception as e: + logger.warning(f"Redis set failed: {e}") diff --git a/app/services/overlay/entity_resolver.py b/app/services/overlay/entity_resolver.py index d64f8a8..f0844f8 100644 --- a/app/services/overlay/entity_resolver.py +++ b/app/services/overlay/entity_resolver.py @@ -64,11 +64,14 @@ class EntityResolver: if w in TOP_50_SYMBOLS and w not in _STOP_WORDS: symbols.add(w) - # Stage 3: Alias / company name match (case-insensitive) + # Stage 3: Alias / company name match (case-insensitive, word-boundary). + # Plain `substring in title` produces false positives like + # "Critical Metals" → META or "Ups Guidance" → UPS. if self._alias_cache: title_lower = title.lower() for alias_text, candidates in self._alias_cache.items(): - if alias_text in title_lower: + pattern = r'\b' + re.escape(alias_text) + r'\b' + if re.search(pattern, title_lower): for sym, conf in candidates: if conf >= 0.7: symbols.add(sym) diff --git a/app/services/overlay/feature_builder.py b/app/services/overlay/feature_builder.py deleted file mode 100644 index 87fd0e3..0000000 --- a/app/services/overlay/feature_builder.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -Feature builder - aggregate raw events into normalized (z-scored) feature dicts. -""" - -import logging -import statistics -from datetime import datetime, timedelta, timezone -from typing import Dict, List, Optional - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_, func, cast -from sqlalchemy.dialects.postgresql import JSONB - -from app.models.overlay_raw_event import ( - OverlayHeadlineEvent, - OverlayVideoEvent, - OverlayWikiPageview, - OverlayTrendObservation, -) -from app.models.overlay_registry import ThemeTopicMap -from app.services.overlay.finra_overlay_loader import FinraOverlayLoader -from app.core.overlay_config import ZSCORE_WINDOW_DAYS, WINSOR_LOWER, WINSOR_UPPER - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def winsorize(value: float, lower: float = WINSOR_LOWER, upper: float = WINSOR_UPPER) -> float: - return max(lower, min(upper, value)) - - - -def compute_zscore(value: float, values: List[float]) -> Optional[float]: - """Compute z-score of *value* within *values* (requires ≥2 data points).""" - if len(values) < 2: - return None - mean = statistics.mean(values) - stdev = statistics.pstdev(values) # population stdev for stability - if stdev == 0: - return 0.0 - z = (value - mean) / stdev - return winsorize(z) - - -def _day_key(ts) -> str: - """Return YYYY-MM-DD string from a datetime or date object.""" - if hasattr(ts, "date"): - return ts.date().isoformat() - return str(ts)[:10] - - -# --------------------------------------------------------------------------- -# Feature builder -# --------------------------------------------------------------------------- - -class FeatureBuilder: - """Build overlay feature records from raw event tables.""" - - def __init__(self): - self.finra_loader = FinraOverlayLoader() - - # ------------------------------------------------------------------ - # Headline features - # ------------------------------------------------------------------ - - async def build_headline_features( - self, db: AsyncSession, symbol: str, as_of: datetime - ) -> Dict: - cutoff_24h = as_of - timedelta(hours=24) - cutoff_6h = as_of - timedelta(hours=6) - window_cutoff = as_of - timedelta(days=ZSCORE_WINDOW_DAYS) - - # Fetch headlines matching symbol in z-score window (DB-level JSON filter) - symbol_json = cast([symbol], JSONB) - result = await db.execute( - select( - OverlayHeadlineEvent.publisher, - OverlayHeadlineEvent.published_at, - ).where( - and_( - OverlayHeadlineEvent.published_at >= window_cutoff, - OverlayHeadlineEvent.published_at <= as_of, - cast(OverlayHeadlineEvent.matched_symbols, JSONB).op('@>')(symbol_json), - ) - ) - ) - sym_rows_hist = result.fetchall() - - sym_rows_24h = [r for r in sym_rows_hist if r.published_at >= cutoff_24h] - sym_rows_6h = [r for r in sym_rows_24h if r.published_at >= cutoff_6h] - - headline_count_24h = len(sym_rows_24h) - headline_count_6h = len(sym_rows_6h) - publishers = {r.publisher for r in sym_rows_24h if r.publisher} - publisher_breadth_24h = len(publishers) - - # Build daily counts for z-score window - daily_counts: Dict[str, int] = {} - for row in sym_rows_hist: - key = _day_key(row.published_at) - daily_counts[key] = daily_counts.get(key, 0) + 1 - - hist_values = list(daily_counts.values()) - headline_burst_z = compute_zscore(float(headline_count_24h), hist_values) if hist_values else None - - return { - "headline_count_6h": headline_count_6h, - "headline_count_24h": headline_count_24h, - "publisher_breadth_24h": publisher_breadth_24h, - "headline_burst_z": headline_burst_z, - } - - # ------------------------------------------------------------------ - # YouTube features - # ------------------------------------------------------------------ - - async def build_youtube_features( - self, db: AsyncSession, symbol: str, as_of: datetime - ) -> Dict: - cutoff_24h = as_of - timedelta(hours=24) - window_cutoff = as_of - timedelta(days=ZSCORE_WINDOW_DAYS) - - symbol_json = cast([symbol], JSONB) - result = await db.execute( - select( - OverlayVideoEvent.view_count, - OverlayVideoEvent.channel_weight, - OverlayVideoEvent.published_at, - ).where( - and_( - OverlayVideoEvent.published_at >= window_cutoff, - OverlayVideoEvent.published_at <= as_of, - cast(OverlayVideoEvent.matched_symbols, JSONB).op('@>')(symbol_json), - ) - ) - ) - sym_rows = result.fetchall() - sym_rows_24h = [r for r in sym_rows if r.published_at >= cutoff_24h] - - mentions_24h = len(sym_rows_24h) - weighted_views_24h = sum((r.view_count or 0) * (r.channel_weight or 0.5) for r in sym_rows_24h) - - # Daily weighted views for z-score - daily_weighted: Dict[str, float] = {} - for row in sym_rows: - key = _day_key(row.published_at) - daily_weighted[key] = daily_weighted.get(key, 0.0) + (row.view_count or 0) * (row.channel_weight or 0.5) - - hist_values = list(daily_weighted.values()) - youtube_influence_z = compute_zscore(weighted_views_24h, hist_values) if hist_values else None - - return { - "youtube_mentions_24h": mentions_24h, - "youtube_weighted_views_24h": round(weighted_views_24h, 2), - "youtube_influence_z": youtube_influence_z, - } - - # ------------------------------------------------------------------ - # Wiki features - # ------------------------------------------------------------------ - - async def build_wiki_features( - self, db: AsyncSession, symbol: str, as_of: datetime - ) -> Dict: - cutoff_7d = as_of - timedelta(days=7) - cutoff_1d = as_of - timedelta(days=1) - window_cutoff = as_of - timedelta(days=ZSCORE_WINDOW_DAYS) - - result = await db.execute( - select( - OverlayWikiPageview.views, - OverlayWikiPageview.date, - ).where( - and_( - OverlayWikiPageview.mapped_symbol == symbol, - OverlayWikiPageview.date >= window_cutoff, - ) - ).order_by(OverlayWikiPageview.date.desc()) - ) - rows = result.fetchall() - - if not rows: - return {"wiki_views_1d": None, "wiki_views_7d_avg": None, "wiki_attention_z": None} - - # Latest day's views (most recent row, regardless of exact time) - views_1d = rows[0].views if rows else None - - # 7-day average - rows_7d = [r for r in rows if r.date >= cutoff_7d] - views_7d_avg = sum(r.views for r in rows_7d) / len(rows_7d) if rows_7d else None - - # Historical z-score - hist_views = [r.views for r in rows] - wiki_attention_z = None - if views_1d is not None and hist_views: - wiki_attention_z = compute_zscore(float(views_1d), hist_views) - - return { - "wiki_views_1d": views_1d, - "wiki_views_7d_avg": round(views_7d_avg, 2) if views_7d_avg else None, - "wiki_attention_z": wiki_attention_z, - } - - # ------------------------------------------------------------------ - # Google Trends features - # ------------------------------------------------------------------ - - async def build_trends_features( - self, db: AsyncSession, symbol: str, as_of: datetime - ) -> Dict: - window_cutoff = as_of - timedelta(days=ZSCORE_WINDOW_DAYS) - cutoff_1d = as_of - timedelta(days=1) - - # Find topic IDs mapped to this symbol - topics_result = await db.execute( - select(ThemeTopicMap).where(ThemeTopicMap.active == True) - ) - topics = topics_result.scalars().all() - topic_ids = [t.topic_id for t in topics if symbol in (t.mapped_symbols or [])] - - if not topic_ids: - return {"theme_heat_z": None} - - result = await db.execute( - select( - OverlayTrendObservation.interest_value, - OverlayTrendObservation.observed_at, - ).where( - and_( - OverlayTrendObservation.topic_id.in_(topic_ids), - OverlayTrendObservation.observed_at >= window_cutoff, - ) - ).order_by(OverlayTrendObservation.observed_at) - ) - rows = result.fetchall() - - if not rows: - return {"theme_heat_z": None} - - recent = [r for r in rows if r.observed_at >= cutoff_1d] - current_value = float(sum(r.interest_value for r in recent) / len(recent)) if recent else None - - if current_value is None: - return {"theme_heat_z": None} - - hist_values = [float(r.interest_value) for r in rows] - theme_heat_z = compute_zscore(current_value, hist_values) if len(hist_values) >= 2 else None - - return {"theme_heat_z": theme_heat_z} - - # ------------------------------------------------------------------ - # FINRA crowding features - # ------------------------------------------------------------------ - - async def build_crowding_features(self, db: AsyncSession, symbol: str) -> Dict: - return await self.finra_loader.get_crowding_metrics(db, symbol) - - # ------------------------------------------------------------------ - # Build all features - # ------------------------------------------------------------------ - - async def build_all_features( - self, db: AsyncSession, symbol: str, as_of: Optional[datetime] = None - ) -> Dict: - """Build all features for a symbol and return a combined feature dict.""" - if as_of is None: - as_of = datetime.now(timezone.utc) - - headline = await self.build_headline_features(db, symbol, as_of) - youtube = await self.build_youtube_features(db, symbol, as_of) - wiki = await self.build_wiki_features(db, symbol, as_of) - trends = await self.build_trends_features(db, symbol, as_of) - crowding = await self.build_crowding_features(db, symbol) - - return { - **headline, - **youtube, - **wiki, - **trends, - **crowding, - "as_of_ts": as_of, - } diff --git a/app/services/overlay/finra_overlay_loader.py b/app/services/overlay/finra_overlay_loader.py deleted file mode 100644 index beec3ab..0000000 --- a/app/services/overlay/finra_overlay_loader.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -FINRA overlay loader - derive crowding/stress features from the existing -finra_short_volume table without duplicating data. -""" - -import logging -import statistics -from datetime import datetime, timedelta, timezone -from typing import Dict - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_, func - -from app.models.finra_short_volume import FinraShortVolume - -logger = logging.getLogger(__name__) - - -class FinraOverlayLoader: - """Calculate crowding stress metrics from FINRA short volume data.""" - - async def get_crowding_metrics( - self, db: AsyncSession, symbol: str, days: int = 30 - ) -> Dict: - """ - Derive crowding stress metrics for a symbol over the last *days* days. - - Returns a dict with: - short_volume_ratio - latest daily short/total ratio - short_volume_spike_zscore - how far above the rolling mean - crowding_stress_z - negative spike z-score (high → more stress) - - Returns {} if no FINRA data is available. - """ - symbol = symbol.upper() - cutoff = datetime.now(timezone.utc) - timedelta(days=days) - - result = await db.execute( - select( - FinraShortVolume.date, - func.sum(FinraShortVolume.short_volume).label("short_volume"), - func.sum(FinraShortVolume.total_volume).label("total_volume"), - ) - .where( - and_( - FinraShortVolume.symbol == symbol, - FinraShortVolume.date >= cutoff, - ) - ) - .group_by(FinraShortVolume.date) - .order_by(FinraShortVolume.date) - ) - - rows = result.fetchall() - if not rows: - return {} - - # Build daily short ratios - ratios = [] - for row in rows: - _, sv, tv = row - if tv and tv > 0: - ratios.append(sv / tv) - - if not ratios: - return {} - - latest_ratio = ratios[-1] - - # z-score of latest vs rolling window - if len(ratios) >= 2: - mean_r = statistics.mean(ratios) - stdev_r = statistics.stdev(ratios) - spike_z = (latest_ratio - mean_r) / stdev_r if stdev_r > 0 else 0.0 - else: - spike_z = 0.0 - - # crowding_stress_z: higher short-volume spike → negative stress on price - crowding_stress_z = round(-spike_z, 4) - - return { - "short_volume_ratio": round(latest_ratio, 6), - "short_volume_spike_zscore": round(spike_z, 4), - "crowding_stress_z": crowding_stress_z, - } diff --git a/app/services/overlay/google_trends_adapter.py b/app/services/overlay/google_trends_adapter.py deleted file mode 100644 index bdc2bae..0000000 --- a/app/services/overlay/google_trends_adapter.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Google Trends adapter (experimental, feature-flagged). -Disabled by default; enable via GOOGLE_TRENDS_ENABLED=true env var. -""" - -import logging -from datetime import datetime, timezone -from typing import List - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ - -from app.core.config import settings -from app.models.overlay_registry import ThemeTopicMap -from app.models.overlay_raw_event import OverlayTrendObservation - -logger = logging.getLogger(__name__) - - -class GoogleTrendsAdapter: - """Collect Google Trends data (experimental, disabled by default).""" - - @property - def enabled(self) -> bool: - return getattr(settings, "GOOGLE_TRENDS_ENABLED", False) - - async def collect(self, db: AsyncSession) -> int: - """ - Collect Google Trends interest data for active topics. - - Returns 0 if feature flag is disabled or pytrends is not installed. - Returns number of new records inserted. - """ - if not self.enabled: - logger.debug("Google Trends adapter: feature flag disabled, skipping") - return 0 - - try: - from pytrends.request import TrendReq - except ImportError: - logger.warning("pytrends not installed; Google Trends adapter inactive") - return 0 - - result = await db.execute( - select(ThemeTopicMap).where(ThemeTopicMap.active == True) - ) - topics = result.scalars().all() - if not topics: - return 0 - - inserted = 0 - - try: - pytrends = TrendReq(hl="en-US", tz=360) - # pytrends limits to 5 keywords at a time - kw_list = [t.topic_label for t in topics[:5]] - pytrends.build_payload(kw_list, timeframe="now 7-d", geo="US") - interest_df = pytrends.interest_over_time() - except Exception as e: - logger.error(f"Google Trends API error: {e}") - return 0 - - for topic in topics: - if topic.topic_label not in interest_df.columns: - continue - series = interest_df[topic.topic_label] - for ts, val in series.items(): - observed_at = ts.to_pydatetime().replace(tzinfo=timezone.utc) - - # Check duplicate - existing = await db.execute( - select(OverlayTrendObservation.id).where( - and_( - OverlayTrendObservation.topic_id == topic.topic_id, - OverlayTrendObservation.observed_at == observed_at, - OverlayTrendObservation.geography == "US", - ) - ) - ) - if existing.first(): - continue - - record = OverlayTrendObservation( - topic_id=topic.topic_id, - observed_at=observed_at, - geography="US", - interest_value=int(val), - ) - db.add(record) - inserted += 1 - - if inserted: - await db.commit() - logger.info(f"Google Trends: inserted {inserted} observations") - - return inserted diff --git a/app/services/overlay/overlay_pipeline.py b/app/services/overlay/overlay_pipeline.py index e6b78cc..1d14db1 100644 --- a/app/services/overlay/overlay_pipeline.py +++ b/app/services/overlay/overlay_pipeline.py @@ -1,43 +1,25 @@ """ -Overlay pipeline orchestrator - coordinates data collection, feature building, -and scoring for the full pipeline (batch) and on-demand (single symbol). +Overlay pipeline orchestrator — runs the Yahoo RSS headline collector. """ -import asyncio import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from typing import Dict, List, Optional from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, desc +from sqlalchemy import select -from app.core.overlay_config import ONDEMAND_TIMEOUT_SECONDS, FEATURE_STALE_HOURS, TOP_50_SYMBOLS -from app.models.overlay_feature import OverlayFeatureRecord, OverlayJobLog -from app.models.overlay_registry import ThemeTopicMap +from app.models.overlay_feature import OverlayJobLog from app.services.overlay.yahoo_rss_adapter import YahooRSSAdapter -from app.services.overlay.wikimedia_adapter import WikimediaAdapter -from app.services.overlay.youtube_adapter import YouTubeAdapter -from app.services.overlay.google_trends_adapter import GoogleTrendsAdapter -from app.services.overlay.feature_builder import FeatureBuilder -from app.services.overlay.overlay_scorer import OverlayScorer logger = logging.getLogger(__name__) class OverlayPipeline: - """Orchestrate overlay data collection, feature building, and scoring.""" + """Orchestrate Yahoo RSS headline collection.""" def __init__(self): self.rss = YahooRSSAdapter() - self.wiki = WikimediaAdapter() - self.youtube = YouTubeAdapter() - self.trends = GoogleTrendsAdapter() - self.builder = FeatureBuilder() - self.scorer = OverlayScorer() - - # ------------------------------------------------------------------ - # Job logging helpers - # ------------------------------------------------------------------ async def _log_job( self, @@ -49,7 +31,6 @@ class OverlayPipeline: error: Optional[str] = None, ) -> None: if status != "running": - # Try to update existing "running" entry rather than creating a duplicate existing_result = await db.execute( select(OverlayJobLog) .where( @@ -87,208 +68,20 @@ class OverlayPipeline: logger.warning(f"Failed to persist job log: {e}") await db.rollback() - # ------------------------------------------------------------------ - # Data collection - # ------------------------------------------------------------------ - async def collect_all(self, db: AsyncSession) -> Dict: - """Run all data collectors sequentially (source failures are isolated). - - NOTE: Sequential (not concurrent) because SQLAlchemy async sessions do not - allow concurrent operations on the same connection. - """ + """Run the Yahoo RSS headline collector.""" started = datetime.now(timezone.utc) await self._log_job(db, "collect_all", "running", started) - counts: Dict[str, int] = { - "yahoo_rss": 0, - "wikimedia": 0, - "youtube": 0, - "google_trends": 0, - } - errors: List[str] = [] - - adapters = [ - ("yahoo_rss", self.rss.collect(db, symbols=TOP_50_SYMBOLS)), - ("wikimedia", self.wiki.collect(db)), - ("youtube", self.youtube.collect(db)), - ("google_trends", self.trends.collect(db)), - ] - for name, coro in adapters: - try: - result = await coro - counts[name] = result if isinstance(result, int) else 0 - except Exception as e: - logger.error(f"Collect error for {name}: {e}") - errors.append(f"{name}: {e}") - - error_str = "; ".join(errors) if errors else None - final_status = "partial" if error_str else "completed" - - await self._log_job(db, "collect_all", final_status, started, sum(counts.values()), error_str) - return counts - - # ------------------------------------------------------------------ - # Feature building - # ------------------------------------------------------------------ - - async def build_features_for_symbol( - self, db: AsyncSession, symbol: str - ) -> Optional[OverlayFeatureRecord]: - """Build and persist a feature record for a single symbol.""" - as_of = datetime.now(timezone.utc) - features = await self.builder.build_all_features(db, symbol, as_of) - scores = self.scorer.score(features) - - record = OverlayFeatureRecord( - symbol=symbol.upper(), - as_of_ts=as_of, - feature_version="v1", - # z-scores - headline_burst_z=features.get("headline_burst_z"), - youtube_influence_z=features.get("youtube_influence_z"), - wiki_attention_z=features.get("wiki_attention_z"), - theme_heat_z=features.get("theme_heat_z"), - crowding_stress_z=features.get("crowding_stress_z"), - # headline detail - headline_count_6h=features.get("headline_count_6h", 0), - headline_count_24h=features.get("headline_count_24h", 0), - publisher_breadth_24h=features.get("publisher_breadth_24h", 0), - # youtube detail - youtube_mentions_24h=features.get("youtube_mentions_24h", 0), - youtube_weighted_views_24h=features.get("youtube_weighted_views_24h", 0.0), - # wiki detail - wiki_views_1d=features.get("wiki_views_1d"), - wiki_views_7d_avg=features.get("wiki_views_7d_avg"), - # FINRA detail - short_volume_ratio=features.get("short_volume_ratio"), - short_volume_spike_zscore=features.get("short_volume_spike_zscore"), - # scores - **scores, - ) - db.add(record) - await db.commit() - await db.refresh(record) - return record - - async def build_features_batch( - self, db: AsyncSession, symbols: Optional[List[str]] = None - ) -> int: - """Build features for a batch of symbols (default: TOP_50_SYMBOLS).""" - if symbols is None: - symbols = TOP_50_SYMBOLS - - started = datetime.now(timezone.utc) - await self._log_job(db, "feature_build", "running", started) - - built = 0 - errors = [] - for sym in symbols: - try: - await self.build_features_for_symbol(db, sym) - built += 1 - except Exception as e: - logger.error(f"Feature build error for {sym}: {e}") - errors.append(str(e)) - - error_str = "; ".join(errors[:3]) if errors else None - final_status = "partial" if error_str else "completed" - await self._log_job(db, "feature_build", final_status, started, built, error_str) - return built - - # ------------------------------------------------------------------ - # Query helpers - # ------------------------------------------------------------------ + counts: Dict[str, int] = {"yahoo_rss": 0} + error: Optional[str] = None - async def get_latest_feature( - self, db: AsyncSession, symbol: str - ) -> Optional[OverlayFeatureRecord]: - """Return the most recent feature record for a symbol.""" - result = await db.execute( - select(OverlayFeatureRecord) - .where(OverlayFeatureRecord.symbol == symbol.upper()) - .order_by(desc(OverlayFeatureRecord.as_of_ts)) - .limit(1) - ) - return result.scalars().first() - - def is_stale(self, record: Optional[OverlayFeatureRecord]) -> bool: - """Return True if the record is missing or older than FEATURE_STALE_HOURS.""" - if record is None: - return True - age = datetime.now(timezone.utc) - record.as_of_ts - return age.total_seconds() > FEATURE_STALE_HOURS * 3600 - - # ------------------------------------------------------------------ - # On-demand build - # ------------------------------------------------------------------ - - async def get_or_build( - self, db: AsyncSession, symbol: str - ) -> Optional[OverlayFeatureRecord]: - """ - Return the latest feature record for *symbol*. - - If the record is stale/missing, trigger an on-demand pipeline run - with a timeout guard. Returns the (possibly stale) record on timeout. - """ - record = await self.get_latest_feature(db, symbol) - if record and not self.is_stale(record): - return record - - logger.info(f"Overlay on-demand build triggered for {symbol}") try: - record = await asyncio.wait_for( - self.build_features_for_symbol(db, symbol), - timeout=ONDEMAND_TIMEOUT_SECONDS, - ) - except asyncio.TimeoutError: - logger.warning(f"Overlay on-demand build timed out for {symbol}") - # Return potentially-stale record rather than None - return record - - # ------------------------------------------------------------------ - # Topic map seeding - # ------------------------------------------------------------------ - - async def seed_topic_maps(self, db: AsyncSession) -> int: - """Seed ThemeTopicMap with default entries for TOP_50_SYMBOLS if empty. - - Each symbol gets a topic with label "{TICKER} stock" used as Google Trends keyword. - Safe to call repeatedly — skips symbols that already have a mapping. - Returns number of new entries created. - """ - inserted = 0 - for symbol in TOP_50_SYMBOLS: - existing = await db.execute( - select(ThemeTopicMap).where(ThemeTopicMap.topic_id == symbol) - ) - if existing.scalars().first(): - continue - entry = ThemeTopicMap( - topic_id=symbol, - topic_label=f"{symbol} stock", - mapped_symbols=[symbol], - active=True, - ) - db.add(entry) - inserted += 1 - - if inserted: - await db.commit() - logger.info(f"Seeded {inserted} ThemeTopicMap entries") - return inserted - - # ------------------------------------------------------------------ - # Full pipeline - # ------------------------------------------------------------------ + counts["yahoo_rss"] = await self.rss.collect(db) + except Exception as e: + logger.error(f"yahoo_rss collect error: {e}") + error = str(e) - async def run_full_pipeline(self, db: AsyncSession) -> Dict: - """Run seed → collect → build feature for all TOP_50 symbols.""" - await self.seed_topic_maps(db) - collect_counts = await self.collect_all(db) - built = await self.build_features_batch(db) - return { - "collected": collect_counts, - "features_built": built, - } + final_status = "partial" if error else "completed" + await self._log_job(db, "collect_all", final_status, started, sum(counts.values()), error) + return counts diff --git a/app/services/overlay/overlay_scorer.py b/app/services/overlay/overlay_scorer.py deleted file mode 100644 index dd45b5d..0000000 --- a/app/services/overlay/overlay_scorer.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Overlay scorer - compute final overlay_score, band, confidence, and decision hints -from a feature dict produced by FeatureBuilder. -""" - -import math -import logging -from typing import Dict, Optional - -from app.core.overlay_config import ( - SOURCE_WEIGHTS, - BAND_THRESHOLDS, - CONFIDENCE_PER_SOURCE, - HOLD_EXTENSION_EXTEND_THRESHOLD, - HOLD_EXTENSION_TRIM_THRESHOLD, - ADD_ON_ELIGIBILITY_THRESHOLD, -) - -logger = logging.getLogger(__name__) - - -def _sigmoid(x: float) -> float: - """Sigmoid function mapping any real to (0, 1).""" - return 1.0 / (1.0 + math.exp(-x)) - - -def _zscore_to_01(z: Optional[float]) -> Optional[float]: - """Convert z-score to 0~1 via sigmoid (z=0 → 0.5).""" - if z is None: - return None - return _sigmoid(z) - - -class OverlayScorer: - """Compute final overlay score and derived metrics from a feature dict.""" - - def score(self, features: Dict) -> Dict: - """ - Given a features dict (output of FeatureBuilder.build_all_features), - compute overlay_score, overlay_confidence, overlay_band, and decision hints. - - Returns a dict suitable for storing in OverlayFeatureRecord. - """ - # Map each z-score to 0~1 - normalized = { - "yahoo": _zscore_to_01(features.get("headline_burst_z")), - "youtube": _zscore_to_01(features.get("youtube_influence_z")), - "wikimedia": _zscore_to_01(features.get("wiki_attention_z")), - "google_trends": _zscore_to_01(features.get("theme_heat_z")), - "finra": _zscore_to_01(features.get("crowding_stress_z")), - } - - # Source presence: based on actual raw data, not z-score availability. - # Z-scores require 2+ days of history; a source is "present" if it has any data at all. - source_presence_mask = { - "yahoo": (features.get("headline_count_24h") or 0) > 0, - "youtube": (features.get("youtube_mentions_24h") or 0) > 0, - "wikimedia": features.get("wiki_views_1d") is not None, - "google_trends": normalized.get("google_trends") is not None, - "finra": features.get("short_volume_ratio") is not None, - } - - # Weighted average across present sources - total_weight = 0.0 - weighted_sum = 0.0 - present_count = 0 - for src, val in normalized.items(): - if val is not None: - w = SOURCE_WEIGHTS.get(src, 0.0) - weighted_sum += val * w - total_weight += w - present_count += 1 - - overlay_score = weighted_sum / total_weight if total_weight > 0 else 0.0 - - # Confidence based on number of active sources - overlay_confidence = min(1.0, present_count * CONFIDENCE_PER_SOURCE) - - # Band assignment (evaluate thresholds from high to low) - overlay_band = "silent" - for band_name, threshold in sorted(BAND_THRESHOLDS.items(), key=lambda kv: -kv[1]): - if overlay_score >= threshold: - overlay_band = band_name - break - - # Hold-extension hint - if overlay_score >= HOLD_EXTENSION_EXTEND_THRESHOLD: - hold_extension_hint = "extend" - elif overlay_score <= HOLD_EXTENSION_TRIM_THRESHOLD: - hold_extension_hint = "trim" - else: - hold_extension_hint = "neutral" - - # Add-on eligibility - add_on_eligibility = overlay_score >= ADD_ON_ELIGIBILITY_THRESHOLD - - return { - "overlay_score": round(overlay_score, 4), - "overlay_confidence": round(overlay_confidence, 4), - "overlay_band": overlay_band, - "source_presence_mask": source_presence_mask, - "hold_extension_hint": hold_extension_hint, - "add_on_eligibility": add_on_eligibility, - } diff --git a/app/services/overlay/scheduler.py b/app/services/overlay/scheduler.py index 74c4bcf..47c32cd 100644 --- a/app/services/overlay/scheduler.py +++ b/app/services/overlay/scheduler.py @@ -1,11 +1,10 @@ """ -APScheduler-based batch scheduler for the Overlay pipeline. +APScheduler-based batch scheduler for the Overlay headline collector. Integrated into FastAPI's lifespan via start_scheduler() / stop_scheduler(). """ import logging -from datetime import datetime, timezone logger = logging.getLogger(__name__) @@ -13,21 +12,19 @@ _scheduler = None def _get_scheduler(): - """Lazily create the APScheduler instance.""" global _scheduler if _scheduler is None: try: from apscheduler.schedulers.asyncio import AsyncIOScheduler - from apscheduler.triggers.cron import CronTrigger _scheduler = AsyncIOScheduler(timezone="UTC") except ImportError: - logger.warning("apscheduler not installed; overlay batch scheduling disabled") + logger.warning("apscheduler not installed; overlay scheduling disabled") return None return _scheduler async def _run_collect_job() -> None: - """Scheduled job: collect RSS + wiki + youtube + trends.""" + """Scheduled job: collect Yahoo RSS headlines.""" from app.core.database import AsyncSessionLocal from app.services.overlay.overlay_pipeline import OverlayPipeline @@ -40,22 +37,8 @@ async def _run_collect_job() -> None: logger.error(f"[Scheduler] collect_all failed: {e}") -async def _run_build_job() -> None: - """Scheduled job: build features for all TOP_50 symbols.""" - from app.core.database import AsyncSessionLocal - from app.services.overlay.overlay_pipeline import OverlayPipeline - - pipeline = OverlayPipeline() - async with AsyncSessionLocal() as db: - try: - built = await pipeline.build_features_batch(db) - logger.info(f"[Scheduler] feature_build completed: {built} symbols") - except Exception as e: - logger.error(f"[Scheduler] feature_build failed: {e}") - - def start_scheduler() -> None: - """Start the APScheduler with overlay jobs. Call from FastAPI lifespan startup.""" + """Start the APScheduler. Call from FastAPI lifespan startup.""" from app.core.config import settings if not getattr(settings, "OVERLAY_ENABLED", True): @@ -69,7 +52,7 @@ def start_scheduler() -> None: try: from apscheduler.triggers.cron import CronTrigger - # Collect: 23:30 UTC daily on weekdays (≈ 18:30 ET) + # Yahoo RSS collect: 23:30 UTC daily on weekdays (≈ 18:30 ET) sched.add_job( _run_collect_job, trigger=CronTrigger(day_of_week="mon-fri", hour=23, minute=30, timezone="UTC"), @@ -77,23 +60,13 @@ def start_scheduler() -> None: replace_existing=True, misfire_grace_time=600, ) - # Feature build: 01:30 UTC (≈ 20:30 ET) - sched.add_job( - _run_build_job, - trigger=CronTrigger(day_of_week="mon-fri", hour=1, minute=30, timezone="UTC"), - id="overlay_feature_build", - replace_existing=True, - misfire_grace_time=600, - ) - sched.start() - logger.info("Overlay scheduler started (collect @ 23:30 UTC, build @ 01:30 UTC, weekdays)") + logger.info("Overlay scheduler started (collect @ 23:30 UTC, weekdays)") except Exception as e: logger.error(f"Overlay scheduler start failed: {e}") def stop_scheduler() -> None: - """Stop the scheduler. Call from FastAPI lifespan shutdown.""" sched = _get_scheduler() if sched and sched.running: sched.shutdown(wait=False) diff --git a/app/services/overlay/wikimedia_adapter.py b/app/services/overlay/wikimedia_adapter.py deleted file mode 100644 index 8259267..0000000 --- a/app/services/overlay/wikimedia_adapter.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -Wikimedia REST API adapter - collect daily page view counts for watched pages. -""" - -import logging -from datetime import datetime, timedelta, timezone -from typing import List, Optional - -import aiohttp -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, and_ - -from app.models.overlay_raw_event import OverlayWikiPageview -from app.models.overlay_registry import WikiPageMap - -logger = logging.getLogger(__name__) - -WIKIMEDIA_BASE = "https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article" -USER_AGENT = "StockOracle/1.0 (github.com/stockoracle; contact@stockoracle.com)" - - -class WikimediaAdapter: - """Collect Wikimedia page view data for pages mapped to tickers.""" - - async def _get_watched_pages(self, db: AsyncSession) -> List[WikiPageMap]: - result = await db.execute( - select(WikiPageMap).where(WikiPageMap.active == True) - ) - return result.scalars().all() - - async def fetch_pageviews( - self, page_title: str, date: datetime, project: str = "en.wikipedia" - ) -> Optional[int]: - """Fetch daily pageviews for a specific page and date.""" - date_str = date.strftime("%Y%m%d") - encoded_title = page_title.replace(" ", "_") - url = ( - f"{WIKIMEDIA_BASE}/{project}/all-access/all-agents" - f"/{encoded_title}/daily/{date_str}/{date_str}" - ) - try: - headers = {"User-Agent": USER_AGENT} - async with aiohttp.ClientSession(headers=headers) as session: - async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp: - if resp.status == 404: - return None - resp.raise_for_status() - data = await resp.json() - items = data.get("items", []) - if items: - return items[0].get("views", 0) - return 0 - except Exception as e: - logger.debug(f"Wikimedia fetch error for '{page_title}' on {date_str}: {e}") - return None - - async def collect(self, db: AsyncSession, days_back: int = 3) -> int: - """ - Collect pageviews for all active wiki page mappings. - - Returns number of new records inserted. - """ - pages = await self._get_watched_pages(db) - if not pages: - logger.info("Wikimedia: no active wiki page mappings found") - return 0 - - inserted = 0 - now = datetime.now(timezone.utc) - - for page in pages: - for days_ago in range(1, days_back + 1): - target_date = now - timedelta(days=days_ago) - target_date = target_date.replace(hour=0, minute=0, second=0, microsecond=0) - - # Check duplicate - existing = await db.execute( - select(OverlayWikiPageview.id).where( - and_( - OverlayWikiPageview.page_title == page.wiki_page_title, - OverlayWikiPageview.date == target_date, - OverlayWikiPageview.project == "en.wikipedia", - ) - ) - ) - if existing.first(): - continue - - views = await self.fetch_pageviews(page.wiki_page_title, target_date) - if views is None: - continue - - record = OverlayWikiPageview( - page_title=page.wiki_page_title, - date=target_date, - project="en.wikipedia", - views=views, - mapped_symbol=page.symbol, - ) - db.add(record) - inserted += 1 - - if inserted: - await db.commit() - logger.info(f"Wikimedia: inserted {inserted} pageview records") - - return inserted diff --git a/app/services/overlay/yahoo_rss_adapter.py b/app/services/overlay/yahoo_rss_adapter.py index 215da30..db94f3d 100644 --- a/app/services/overlay/yahoo_rss_adapter.py +++ b/app/services/overlay/yahoo_rss_adapter.py @@ -1,37 +1,118 @@ """ -Yahoo Finance RSS feed adapter - collect headline events and match to tickers. +General-purpose financial news RSS adapter. + +Pulls a small set of live general feeds (Yahoo Finance, CNBC, MarketWatch, +Seeking Alpha) and runs every headline through the EntityResolver to attach +matched ticker symbols. Replaces the previous per-symbol pull strategy. """ import asyncio import logging from datetime import datetime, timezone from typing import Dict, List, Optional +from urllib.parse import urlparse import aiohttp from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.http_client import get_http_session - from app.models.overlay_raw_event import OverlayHeadlineEvent from app.services.overlay.entity_resolver import EntityResolver -from app.core.overlay_config import YAHOO_RSS_FEEDS, TOP_50_SYMBOLS logger = logging.getLogger(__name__) +# General financial-news feeds. Each one returns a stream of latest headlines; +# we let EntityResolver decide which tickers each headline mentions. +_GENERAL_FEEDS: List[str] = [ + "https://finance.yahoo.com/news/rssindex", + "https://www.cnbc.com/id/100003114/device/rss/rss.html", # CNBC Top News + "https://www.cnbc.com/id/15839135/device/rss/rss.html", # CNBC Markets + "https://feeds.content.dowjones.io/public/rss/mw_topstories", + "https://seekingalpha.com/market_currents.xml", +] + + +_PUBLISHER_DOMAINS: Dict[str, str] = { + "fool.com": "Motley Fool", + "finance.yahoo.com": "Yahoo Finance", + "news.yahoo.com": "Yahoo News", + "yahoo.com": "Yahoo Finance", + "bloomberg.com": "Bloomberg", + "reuters.com": "Reuters", + "wsj.com": "Wall Street Journal", + "cnbc.com": "CNBC", + "marketwatch.com": "MarketWatch", + "barrons.com": "Barron's", + "ft.com": "Financial Times", + "investors.com": "Investor's Business Daily", + "forbes.com": "Forbes", + "businessinsider.com": "Business Insider", + "seekingalpha.com": "Seeking Alpha", + "zacks.com": "Zacks", + "thestreet.com": "TheStreet", + "benzinga.com": "Benzinga", + "investorplace.com": "InvestorPlace", + "morningstar.com": "Morningstar", + "investopedia.com": "Investopedia", + "businesswire.com": "Business Wire", + "prnewswire.com": "PR Newswire", + "globenewswire.com": "GlobeNewswire", + "apnews.com": "Associated Press", + "gurufocus.com": "GuruFocus", + "247wallst.com": "24/7 Wall St.", + "simplywall.st": "Simply Wall St", + "kiplinger.com": "Kiplinger", + "tipranks.com": "TipRanks", + "fortune.com": "Fortune", + "marketbeat.com": "MarketBeat", + "barchart.com": "Barchart", +} + + +def _publisher_from_link(link: Optional[str]) -> Optional[str]: + if not link: + return None + try: + host = (urlparse(link).hostname or "").lower().lstrip(".") + except Exception: + return None + if host.startswith("www."): + host = host[4:] + if host in _PUBLISHER_DOMAINS: + return _PUBLISHER_DOMAINS[host] + parts = host.split(".") + if len(parts) >= 2: + apex = ".".join(parts[-2:]) + return _PUBLISHER_DOMAINS.get(apex, apex) + return host or None + + +def _entry_publisher(entry) -> Optional[str]: + """Yahoo rssindex carries publisher in a child; everything else + we infer from the article URL.""" + src = getattr(entry, "source", None) + if isinstance(src, dict): + title = src.get("title") + if title: + return title + elif isinstance(src, str) and src.strip(): + return src.strip() + return _publisher_from_link(getattr(entry, "link", None)) + + class YahooRSSAdapter: - """Collect Yahoo Finance RSS headlines and match to tickers.""" + """Pull general financial news feeds and resolve tickers from titles.""" def __init__(self): self.resolver = EntityResolver() async def fetch_feed(self, url: str) -> List[Dict]: - """Fetch and parse a single RSS feed URL.""" try: import feedparser except ImportError: - logger.error("feedparser not installed; Yahoo RSS adapter inactive") + logger.error("feedparser not installed; RSS adapter inactive") return [] try: @@ -44,88 +125,67 @@ class YahooRSSAdapter: url, headers=headers, timeout=aiohttp.ClientTimeout(total=30) ) as resp: if resp.status != 200: - logger.warning(f"Yahoo RSS: non-200 from {url}: {resp.status}") + logger.warning(f"RSS non-200 from {url}: {resp.status}") return [] text = await resp.text() - - feed = feedparser.parse(text) - entries = [] - for entry in feed.entries: - guid = getattr(entry, "id", None) or getattr(entry, "link", None) - title = getattr(entry, "title", "") - publisher = getattr(entry, "publisher", None) - if not publisher: - src = getattr(entry, "source", {}) - publisher = src.get("title") if isinstance(src, dict) else None - - published = getattr(entry, "published_parsed", None) - if published: - try: - pub_dt = datetime(*published[:6], tzinfo=timezone.utc) - except Exception: - pub_dt = datetime.now(timezone.utc) - else: - pub_dt = datetime.now(timezone.utc) - - entries.append({ - "guid": guid or title, - "title": title, - "publisher": publisher, - "published_at": pub_dt, - }) - return entries - except Exception as e: - logger.error(f"Yahoo RSS fetch error for {url}: {e}") + logger.error(f"RSS fetch error for {url}: {e}") return [] - async def collect(self, db: AsyncSession, symbols: Optional[List[str]] = None) -> int: - """ - Collect RSS headlines from general feeds and per-symbol feeds. + feed = feedparser.parse(text) + out: List[Dict] = [] + for entry in feed.entries: + link = getattr(entry, "link", None) + guid = getattr(entry, "id", None) or link + title = getattr(entry, "title", "") + if not guid or not title: + continue - Persists new events (deduped by article_guid), skips duplicates. - Returns number of new records inserted. - """ + published = getattr(entry, "published_parsed", None) + if published: + try: + pub_dt = datetime(*published[:6], tzinfo=timezone.utc) + except Exception: + pub_dt = datetime.now(timezone.utc) + else: + pub_dt = datetime.now(timezone.utc) + + out.append({ + "guid": guid, + "title": title, + "publisher": _entry_publisher(entry), + "published_at": pub_dt, + }) + return out + + async def collect(self, db: AsyncSession, **_ignored) -> int: + """Pull all general feeds, dedupe, resolve, persist.""" await self.resolver.load_aliases(db) - all_entries: List[Dict] = [] + sem = asyncio.Semaphore(8) - # General feeds - tasks = [self.fetch_feed(url) for url in YAHOO_RSS_FEEDS] - results = await asyncio.gather(*tasks, return_exceptions=True) - for r in results: - if isinstance(r, list): - all_entries.extend(r) - - # Per-symbol feeds for watch-list - watch_symbols = symbols or TOP_50_SYMBOLS - sym_tasks = [ - self.fetch_feed(f"https://finance.yahoo.com/rss/headline?s={sym}") - for sym in watch_symbols - ] - sym_results = await asyncio.gather(*sym_tasks, return_exceptions=True) - for r in sym_results: - if isinstance(r, list): - all_entries.extend(r) - - # Deduplicate by guid within this batch - seen_guids: set = set() - unique_entries = [] - for entry in all_entries: - g = entry.get("guid") - if g and g not in seen_guids: - seen_guids.add(g) - unique_entries.append(entry) + async def _fetch_one(url: str) -> List[Dict]: + async with sem: + return await self.fetch_feed(url) - inserted = 0 - for entry in unique_entries: - if not entry.get("guid") or not entry.get("title"): + results = await asyncio.gather( + *[_fetch_one(u) for u in _GENERAL_FEEDS], return_exceptions=True + ) + + # Cross-feed deduplication by guid (some headlines syndicate across + # multiple aggregators). + seen: Dict[str, Dict] = {} + for r in results: + if isinstance(r, Exception) or not isinstance(r, list): continue + for entry in r: + seen.setdefault(entry["guid"], entry) - # Check DB duplicate + inserted = 0 + for guid, entry in seen.items(): existing = await db.execute( select(OverlayHeadlineEvent.id).where( - OverlayHeadlineEvent.article_guid == entry["guid"] + OverlayHeadlineEvent.article_guid == guid ) ) if existing.first(): @@ -133,7 +193,7 @@ class YahooRSSAdapter: matched = self.resolver.resolve_from_title(entry["title"]) event = OverlayHeadlineEvent( - article_guid=entry["guid"], + article_guid=guid, title=entry["title"], publisher=entry.get("publisher"), published_at=entry["published_at"], @@ -144,6 +204,6 @@ class YahooRSSAdapter: if inserted: await db.commit() - logger.info(f"Yahoo RSS: inserted {inserted} new headline events") + logger.info(f"RSS: inserted {inserted} new headline events") return inserted diff --git a/app/services/overlay/youtube_adapter.py b/app/services/overlay/youtube_adapter.py deleted file mode 100644 index 46ef14d..0000000 --- a/app/services/overlay/youtube_adapter.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -YouTube Data API v3 adapter - collect video mentions from whitelisted channels. -Gracefully skips if YOUTUBE_API_KEY is not configured. -""" - -import logging -from datetime import datetime, timedelta, timezone -from typing import Dict, List, Optional - -import aiohttp -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.http_client import get_http_session -from sqlalchemy import select - -from app.core.config import settings -from app.models.overlay_raw_event import OverlayVideoEvent -from app.models.overlay_registry import YouTubeChannelRegistry -from app.services.overlay.entity_resolver import EntityResolver - -logger = logging.getLogger(__name__) - -YOUTUBE_SEARCH_URL = "https://www.googleapis.com/youtube/v3/search" -YOUTUBE_VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos" - - -class YouTubeAdapter: - """Collect YouTube video data from whitelisted channels.""" - - def __init__(self): - self.api_key: str = getattr(settings, "YOUTUBE_API_KEY", "") - self.resolver = EntityResolver() - - @property - def enabled(self) -> bool: - return bool(self.api_key) - - async def _get_channels(self, db: AsyncSession) -> List[YouTubeChannelRegistry]: - result = await db.execute( - select(YouTubeChannelRegistry).where(YouTubeChannelRegistry.active == True) - ) - return result.scalars().all() - - async def _search_channel_videos( - self, channel_id: str, published_after: datetime - ) -> List[dict]: - """Search for recent videos from a channel via YouTube Data API.""" - params = { - "part": "snippet", - "channelId": channel_id, - "type": "video", - "order": "date", - "publishedAfter": published_after.strftime("%Y-%m-%dT%H:%M:%SZ"), - "maxResults": 20, - "key": self.api_key, - } - try: - session = await get_http_session() - async with session.get( - YOUTUBE_SEARCH_URL, params=params, timeout=aiohttp.ClientTimeout(total=30) - ) as resp: - if resp.status == 403: - logger.warning("YouTube API: quota exceeded or invalid key") - return [] - resp.raise_for_status() - data = await resp.json() - return data.get("items", []) - except Exception as e: - logger.error(f"YouTube search error for channel {channel_id}: {e}") - return [] - - async def _get_video_stats(self, video_ids: List[str]) -> Dict[str, Dict]: - """Fetch view/comment counts for a list of video IDs.""" - if not video_ids: - return {} - params = { - "part": "statistics", - "id": ",".join(video_ids), - "key": self.api_key, - } - try: - session = await get_http_session() - async with session.get( - YOUTUBE_VIDEOS_URL, params=params, timeout=aiohttp.ClientTimeout(total=30) - ) as resp: - resp.raise_for_status() - data = await resp.json() - stats = {} - for item in data.get("items", []): - vid_id = item["id"] - s = item.get("statistics", {}) - stats[vid_id] = { - "view_count": int(s.get("viewCount", 0)), - "comment_count": int(s.get("commentCount", 0)), - } - return stats - except Exception as e: - logger.error(f"YouTube stats fetch error: {e}") - return {} - - async def collect(self, db: AsyncSession, days_back: int = 2) -> int: - """ - Collect YouTube videos from whitelisted channels. - - Gracefully returns 0 if API key is not configured. - Returns number of new records inserted. - """ - if not self.enabled: - logger.info("YouTube adapter: API key not configured, skipping") - return 0 - - await self.resolver.load_aliases(db) - channels = await self._get_channels(db) - if not channels: - logger.info("YouTube: no active channels in registry") - return 0 - - published_after = datetime.now(timezone.utc) - timedelta(days=days_back) - inserted = 0 - - for channel in channels: - items = await self._search_channel_videos(channel.channel_id, published_after) - video_ids = [ - item["id"]["videoId"] - for item in items - if isinstance(item.get("id"), dict) and "videoId" in item["id"] - ] - stats = await self._get_video_stats(video_ids) - - for item in items: - vid_id = item.get("id", {}).get("videoId") if isinstance(item.get("id"), dict) else None - if not vid_id: - continue - - # Check duplicate - existing = await db.execute( - select(OverlayVideoEvent.id).where(OverlayVideoEvent.video_id == vid_id) - ) - if existing.first(): - continue - - snippet = item.get("snippet", {}) - title = snippet.get("title", "") - pub_at_str = snippet.get("publishedAt", "") - try: - pub_at = datetime.fromisoformat(pub_at_str.replace("Z", "+00:00")) - except Exception: - pub_at = datetime.now(timezone.utc) - - stat = stats.get(vid_id, {}) - matched = self.resolver.resolve_from_title(title) - - event = OverlayVideoEvent( - video_id=vid_id, - channel_id=channel.channel_id, - title=title, - view_count=stat.get("view_count", 0), - comment_count=stat.get("comment_count", 0), - published_at=pub_at, - matched_symbols=matched, - channel_weight=channel.channel_weight, - ) - db.add(event) - inserted += 1 - - if inserted: - await db.commit() - logger.info(f"YouTube: inserted {inserted} video events") - - return inserted diff --git a/app/services/screener_service.py b/app/services/screener_service.py index 6b0c2d1..b4d53e0 100644 --- a/app/services/screener_service.py +++ b/app/services/screener_service.py @@ -152,35 +152,43 @@ class ScreenerService: def _screen_sync(self, query, offset: int, size: int, sort_field: str, sort_asc: bool) -> dict: """Synchronous yfinance screen() call — must run in executor.""" - import yfinance as yf - - max_retries = 3 - for attempt in range(max_retries): - try: - return yf.screen(query, offset=offset, size=size, sortField=sort_field, sortAsc=sort_asc) - except Exception as e: - err = str(e).lower() - is_retriable = ( - "too many requests" in err - or "rate limit" in err - or "429" in err - or "401" in err - or "unauthorized" in err - ) - if is_retriable and attempt < max_retries - 1: - delay = (attempt + 1) * 3 # 3s, 6s - logger.warning( - "yfinance screen() error '%s' (attempt %d/%d), retrying in %.1fs", - str(e)[:80], attempt + 1, max_retries, delay, - ) - time.sleep(delay) - continue - if is_retriable: - raise RuntimeError( - "Yahoo Finance is rate limiting this server. " - "Please try again in 30–60 seconds." - ) - raise + import yfinance_plus as yf + return yf.screen(query, offset=offset, size=size, sortField=sort_field, sortAsc=sort_asc) + + def _screen_preset_sync(self, preset: str, offset: int, count: int) -> dict: + """Synchronous preset screen() call — must run in executor.""" + import yfinance_plus as yf + return yf.screen(preset, offset=offset, count=count) + + async def screen_preset(self, preset: str, page: int = 1, page_size: int = 25) -> dict: + """Fetch a Yahoo Finance predefined screener (e.g. day_gainers).""" + start_time = time.time() + page_size = max(1, min(page_size, 250)) + page = max(1, page) + offset = (page - 1) * page_size + + loop = asyncio.get_event_loop() + raw = await loop.run_in_executor( + None, self._screen_preset_sync, preset, offset, page_size + ) + + quotes = raw.get('quotes', []) + total_available = raw.get('count') or raw.get('total') or len(quotes) + total_pages = max(1, (total_available + page_size - 1) // page_size) + + return { + 'stocks': [self._parse_quote(q) for q in quotes], + 'total_available': total_available, + 'returned_count': len(quotes), + 'page': page, + 'page_size': page_size, + 'total_pages': total_pages, + 'query_time_seconds': round(time.time() - start_time, 3), + 'metadata': { + 'preset': preset, + 'source': 'yfinance_screen_preset', + }, + } async def screen_stocks( self, diff --git a/app/services/sec_http_client.py b/app/services/sec_http_client.py index 8613a03..157749d 100644 --- a/app/services/sec_http_client.py +++ b/app/services/sec_http_client.py @@ -278,7 +278,10 @@ class SECHttpClient: backoff *= 1.8 continue resp.raise_for_status() - data = await resp.json() + # SEC's filing index.json (used by Form 4 ingest) is + # served with text/html content-type, so disable the + # mimetype check. + data = await resp.json(content_type=None) # Cache successful response try: with open(self._cache_path(url) + ".json", "w", encoding="utf-8") as f: diff --git a/app/services/sec_ingest/scheduler.py b/app/services/sec_ingest/scheduler.py index 2717044..ebd33d7 100644 --- a/app/services/sec_ingest/scheduler.py +++ b/app/services/sec_ingest/scheduler.py @@ -44,7 +44,6 @@ def _prev_business_day() -> date: async def _run_form4_daily_ingest() -> None: """Ingest prior business day Form 4 entries from SEC daily full-index.""" - from app.core.database import AsyncSessionLocal from app.services.sec_full_index_service import SECFullIndexService from app.services.insider_transaction_service import InsiderTransactionService @@ -60,12 +59,14 @@ async def _run_form4_daily_ingest() -> None: logger.info(f"[SEC Ingest] No Form 4 entries in daily index for {date_str}") return - async with AsyncSessionLocal() as db: - try: - inserted = await txn_svc.index_form4_from_index_entries(db, entries) - logger.info(f"[SEC Ingest] Form 4 daily: {inserted} new transactions for {date_str}") - except Exception as e: - logger.error(f"[SEC Ingest] Form 4 daily ingest failed ({date_str}): {e}") + # index_form4_from_index_entries opens its own short-lived sessions for + # dedup and each flush, so we don't hold a connection across the long + # HTTP loop (which previously dropped asyncpg mid-job). + try: + inserted = await txn_svc.index_form4_from_index_entries(None, entries) + logger.info(f"[SEC Ingest] Form 4 daily: {inserted} new transactions for {date_str}") + except Exception as e: + logger.error(f"[SEC Ingest] Form 4 daily ingest failed ({date_str}): {e}") async def _run_activist_daily_ingest() -> None: @@ -96,7 +97,6 @@ async def _run_activist_daily_ingest() -> None: async def _run_form4_weekly_reindex() -> None: """Re-scan current quarter's company.idx to catch corrections and amendments.""" - from app.core.database import AsyncSessionLocal from app.services.sec_full_index_service import SECFullIndexService from app.services.insider_transaction_service import InsiderTransactionService @@ -111,12 +111,11 @@ async def _run_form4_weekly_reindex() -> None: if not entries: return - async with AsyncSessionLocal() as db: - try: - inserted = await txn_svc.index_form4_from_index_entries(db, entries) - logger.info(f"[SEC Ingest] Form 4 weekly reindex: {inserted} new transactions") - except Exception as e: - logger.error(f"[SEC Ingest] Form 4 weekly reindex failed: {e}") + try: + inserted = await txn_svc.index_form4_from_index_entries(None, entries) + logger.info(f"[SEC Ingest] Form 4 weekly reindex: {inserted} new transactions") + except Exception as e: + logger.error(f"[SEC Ingest] Form 4 weekly reindex failed: {e}") async def _run_activist_enrich() -> None: diff --git a/app/services/universe_service.py b/app/services/universe_service.py index 881f8e4..7803c2b 100644 --- a/app/services/universe_service.py +++ b/app/services/universe_service.py @@ -46,7 +46,7 @@ class UniverseService: def _screen_page_sync(self, query, offset: int) -> dict: """Synchronous yfinance screen() — runs in executor.""" - import yfinance as yf + import yfinance_plus as yf return yf.screen( query, offset=offset, size=_SCREEN_PAGE, sortField="intradaymarketcap", sortAsc=False, @@ -492,7 +492,7 @@ class UniverseService: """ try: import math as _math - import yfinance as yf + import yfinance_plus as yf except ImportError: logger.error("yfinance not available for price download") return {} diff --git a/docker-compose.yml b/docker-compose.yml index 42be550..c2a34e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,8 @@ services: - ALPACA_SECRET_KEY=${ALPACA_SECRET_KEY:-} - ALPHA_VANTAGE_API_KEY=${ALPHA_VANTAGE_API_KEY:-} - GOOGLE_TRENDS_ENABLED=true + - NEWS_INGEST_ENABLED=${NEWS_INGEST_ENABLED:-false} + - FINNHUB_API_KEY=${FINNHUB_API_KEY:-} ports: - "18001:18000" # External:Internal port mapping depends_on: diff --git a/docs/DATA_COVERAGE.md b/docs/DATA_COVERAGE.md index fc33c68..09b0dad 100644 --- a/docs/DATA_COVERAGE.md +++ b/docs/DATA_COVERAGE.md @@ -2,7 +2,7 @@ 각 API 엔드포인트의 **실제 DB 보유 데이터 범위**와 **과거 데이터 백필 방법**을 정리한 문서입니다. -> 마지막 업데이트: 2026-04-23 +> 마지막 업데이트: 2026-04-26 > DB 실측 기준 --- @@ -34,6 +34,9 @@ | `/universe/screen` | SEC EDGAR + yfinance 월별 스냅샷 | ✅ (사전 빌드 필요) | admin 빌드 후 사용 가능 | 2010년~ | **⚠️ 사전 빌드 필요** | | `/company/{ticker}` | yfinance-plus + universe_ticker_registry | ✅ (Redis 24h + DB 영구) | 모든 yfinance 지원 티커 | 즉시 | 요청 기반 자동 누적 | | `/company/bulk` | yfinance-plus + universe_ticker_registry | ✅ (Redis 24h + DB 영구) | 최대 100 티커/요청 | 즉시 | 요청 기반 자동 누적 | +| `/news/v2/headlines` | Alpaca News (Benzinga) + StockTwits + Finnhub | ✅ (`news_headline`) | **ingest 시작 시점 이후만** (vendor 한계) | Alpaca: ingest 시작 누적 / Finnhub: 12개월 / StockTwits: 적재 시작 이후 | **⚠️ `NEWS_INGEST_ENABLED=true` opt-in 필요** | +| `/news/v2/session_aggregate*` | `news_headline` 즉시 SQL 집계 | ✅ (raw 기반) | headlines와 동일 | 동상 | 동상 | +| `/news/v2/coverage` | `news_headline` MIN/MAX/COUNT | — | — | — | 운영 도구 | --- @@ -570,6 +573,80 @@ python scripts/backfill_registry_sector.py --dry-run --- +### `/api/v1/news/v2` — 멀티소스 헤드라인 + 세션 집계 (신규, 2026-04-26) + +**현재 DB 보유**: `news_headline` 테이블. ingest 시작 후 누적. 빈 DB 상태에서도 엔드포인트는 200 + 빈 결과 반환. + +**데이터 소스** (모두 `NEWS_INGEST_ENABLED=true` 시 활성화): + +| Source | History | Rate limit | Sentiment | +|---|---|---|---| +| `alpaca_benzinga` | ~30일 vendor cap → ingest 시작 후 누적 | 200 req/min | 없음 (free tier) | +| `stocktwits` | rolling | 200 req/hr/IP | Bullish/Bearish → ±1 | +| `finnhub` | ~12개월 vendor cap | 60 calls/min free | 없음 | + +**카테고리 정규화**: vendor 라벨 → 22-term 통합 vocab (`analyst_rating_*`, `earnings_release`, `m_and_a`, `fda_approval/rejection`, `buyback`, `litigation`, ... 등). 원본은 `vendor_categories`에 보존. + +**세션 윈도우** (NYSE / `pandas_market_calendars` XNYS 휴장일·short day 처리): +- `premarket` = 전일 close → 당일 09:30 ET +- `intraday` = 09:30 → 16:00 ET +- `post` = 16:00 → 다음 거래일 04:00 ET (다음 premarket과 중복 없음) +- `full_session` = 전일 close → 다음 거래일 04:00 ET + +**PIT 안전성**: 집계 시 `ingested_at <= window_end_utc` 필터 적용 → backtest가 lookahead 데이터를 못 봄. + +| 엔드포인트 | 메서드 | 설명 | +|---|---|---| +| `/news/v2/headlines` | `GET` | raw 헤드라인 (symbols, start, end, sources, limit, cursor 필터) | +| `/news/v2/session_aggregate` | `GET` | 단일 ticker × session × window 집계 (Redis 캐시) | +| `/news/v2/session_aggregate/batch` | `POST` | 다수 ticker 일괄 (V49 핫패스, 캐싱 없음 — 클라이언트 disk-cache 가정) | +| `/news/v2/coverage` | `GET` | source × symbol 적재 깊이 probe | + +**Ingest 활성화 절차**: + +```bash +# 1. .env에 키 설정 (Alpaca는 기존 키 재사용, Finnhub은 신규) +echo "FINNHUB_API_KEY=" >> .env +echo "NEWS_INGEST_ENABLED=true" >> .env + +# 2. 컨테이너 재시작 +docker restart stock_oracle_api + +# 3. 로그에서 시작 메시지 확인 +docker logs stock_oracle_api 2>&1 | grep "\[News\]" +# → "[News] ingest scheduler started — sources: ['alpaca_benzinga', 'finnhub', 'stocktwits']" + +# 4. 5분 후 첫 Alpaca News poll 결과 확인 +curl "http://localhost:18001/api/v1/news/v2/coverage?source=alpaca_benzinga" +``` + +**Fail-fast**: `NEWS_INGEST_ENABLED=true`인데 `ALPACA_API_KEY/SECRET` & `FINNHUB_API_KEY` 모두 없으면 scheduler 시작 거부 (StockTwits만으로는 신호 부족). + +**Finnhub 12개월 백필** (수동 1회): + +```bash +docker exec stock_oracle_api python scripts/news_backfill.py \ + --source finnhub \ + --tickers AAPL,MSFT,NVDA,TSLA,GOOGL \ + --start 2025-04-26 --end 2026-04-26 \ + --chunk monthly +# → 100 ticker × 12 month ≈ 1,200 calls @ 60 cpm ≈ 20분 +``` + +**StockTwits 동적 universe** (자동, 매 평일 09:00 ET): +- `(최근 14일 V49 universe 합집합) ∪ (당일 09:00 ET premarket gap > threshold movers)` +- 기본 cap 300 ticker, 5분 polling, 200 req/hr 안정 +- Config: `STOCKTWITS_UNIVERSE_LOOKBACK_DAYS` (14), `STOCKTWITS_PREMARKET_GAP_THRESHOLD` (0.02), `STOCKTWITS_UNIVERSE_MAX_SIZE` (300) +- Redis key: `news_v2:stocktwits:universe` + +**제외**: +- WebSocket 실시간 push (P2) +- vendor 통합 sentiment NLP (현재는 vendor passthrough) +- GDELT raw feed 노출 (기존 attention 서브시스템 활용) +- Reddit/Twitter + +--- + ## 백필 우선순위 권장 사항 | 우선순위 | 대상 | 이유 | 예상 소요 시간 | diff --git a/docs/PYTHON_CLIENT.md b/docs/PYTHON_CLIENT.md index 33f98b5..d6f0b3b 100644 --- a/docs/PYTHON_CLIENT.md +++ b/docs/PYTHON_CLIENT.md @@ -224,6 +224,71 @@ ingest = requests.post(f"{BASE}/finra/admin/ingest", params={"date": "2025-03-10 print(f"Ingested: {ingest['records_ingested']} records") ``` +### 8. News v2 — Multi-source Headlines & Session Aggregates + +Multi-source structured news ingest (Alpaca News, StockTwits, Finnhub) with +unified categorization and PIT-safe session aggregates. Distinct from the +on-demand `get_news_social_data()` aggregator above. + +```python +from stock_oracle_client import StockOracleClient + +client = StockOracleClient(base_url="http://localhost:18001") + +# 1. Coverage probe — confirm archive depth before backtest window selection +coverage = client.get_news_coverage(source="alpaca_benzinga", symbol="AAPL") +print(f"AAPL Alpaca News: {coverage['ingested_count']} headlines, " + f"{coverage['earliest']} → {coverage['latest']}") + +# 2. Raw headlines — last 24 hours for two tickers +headlines = client.get_news_headlines( + symbols=["AAPL", "MSFT"], + start="2026-04-24T00:00:00Z", + end="2026-04-25T00:00:00Z", + sources=["alpaca_benzinga", "finnhub"], + limit=100, +) +for h in headlines["items"][:3]: + print(f"[{h['source']}] {h['published_at']} {h['ticker']}: {h['headline']}") + print(f" categories: {h['categories']}") + +# 3. Single-ticker session aggregate (V49 use case) +agg = client.get_news_session_aggregate( + symbol="AAPL", + session_date="2026-04-25", + window="premarket", # premarket | intraday | post | full_session +) +print(f"AAPL premarket: {agg['headline_count']} headlines, " + f"sentiment={agg['sentiment_recency_weighted']}, " + f"categories={agg['category_counts']}") + +# 4. Batch — fithia2's hot path (one call per session, then disk-cache) +batch = client.get_news_session_aggregate_batch( + session_date="2026-04-25", + window="premarket", + symbols=["AAPL", "MSFT", "NVDA", "TSLA", "GOOGL"], + sources=["alpaca_benzinga", "stocktwits"], +) +for ticker, item in batch["items"].items(): + soc = item["social"] + print(f"{ticker}: count={item['headline_count']}, " + f"social_msgs={soc['message_count']}, " + f"bull_bear_ratio={soc['bull_bear_ratio']}") +``` + +**Method reference:** + +| Method | Returns | +|---|---| +| `get_news_headlines(symbols=None, start=None, end=None, sources=None, limit=100, cursor=None)` | `{items: [...], next_cursor: str\|None}` | +| `get_news_session_aggregate(symbol, session_date, window="premarket", sources=None, force_refresh=False)` | session aggregate dict | +| `get_news_session_aggregate_batch(session_date, window, symbols, sources=None)` | `{items: {ticker: aggregate}}` | +| `get_news_coverage(source, symbol=None)` | `{source, symbol, earliest, latest, ingested_count}` | + +`session_date` strings are ET dates (YYYY-MM-DD). Aggregates filter +`ingested_at <= window_end_utc` so backtests don't see lookahead headlines. +Tickers with no matching headlines are returned as zero-count rows (not omitted). + ## Advanced Usage ### Error Handling diff --git a/docs/openapi.json b/docs/openapi.json index 129b51f..2e6c93b 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Stock Oracle","version":"1.0.0"},"paths":{"/api/v1/health":{"get":{"tags":["health"],"summary":"Health check","description":"Check the health status of the API and its dependencies","operationId":"health_check_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthCheckResponse"}}}}}}},"/api/v1/financial/data":{"post":{"tags":["financial"],"summary":"Get SEC EDGAR financial data for a ticker","description":"Retrieve comprehensive financial data directly from SEC EDGAR filings for a specific ticker and time period.\n \n **🔥 Three Ways to Specify Time Period (choose one):**\n \n 1. **Period String** (NEW! Most convenient):\n - `period`: \"1d\", \"7d\", \"30d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - Examples: `{\"ticker\": \"AAPL\", \"period\": \"1y\"}` - Last 1 year of data\n - Example: `{\"ticker\": \"TSLA\", \"period\": \"max\"}` - All available data from listing date to SEC limits\n \n 2. **Date Range** (Traditional):\n - `start_date` + `end_date`: Specific date range\n - Example: `{\"ticker\": \"AAPL\", \"start_date\": \"2024-01-01\", \"end_date\": \"2024-12-31\"}`\n \n 3. **Quarters** (Quarter-based):\n - `quarters`: List of quarters like [\"2024Q1\", \"2024Q2\"]\n - Example: `{\"ticker\": \"AAPL\", \"quarters\": [\"2024Q1\", \"2024Q2\", \"2024Q3\"]}`\n \n **Data Sources:**\n - **Financial Data**: Direct SEC EDGAR API calls (revenue, income, assets, cash flow)\n - **Price Data**: Available via separate price data endpoints using yfinance-plus\n \n **This endpoint returns:**\n - Company information (name, CIK, sector, industry)\n - Financial statements data from SEC filings (income statement, balance sheet, cash flow)\n - Calculated financial metrics (ratios, margins, growth rates)\n - Period types: quarterly (10-Q) and annual (10-K) filings\n \n **Performance Features:**\n - Database caching to avoid repeated SEC API calls\n - Historical data available from 1994-present\n - 15+ years of data typically available for most companies\n - Use `force_refresh=true` to fetch fresh data from SEC EDGAR\n \n **Data Quality:**\n - All financial data sourced directly from official SEC filings\n - No estimated or synthetic data - only actual reported figures\n - Automatic validation and error handling for missing periods\n \n **Example Requests:**\n ```json\n // Using period (simplest)\n {\n \"ticker\": \"AAPL\",\n \"period\": \"1y\",\n \"include_metrics\": true\n }\n \n // Using date range\n {\n \"ticker\": \"MSFT\",\n \"start_date\": \"2024-01-01\",\n \"end_date\": \"2024-12-31\",\n \"period_type\": \"quarterly\"\n }\n \n // Using quarters\n {\n \"ticker\": \"GOOGL\",\n \"quarters\": [\"2024Q1\", \"2024Q2\"],\n \"include_metrics\": true\n }\n ```","operationId":"get_financial_data_api_v1_financial_data_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FinancialDataRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FinancialDataResponse"}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Data not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/financial/data/{ticker}":{"get":{"tags":["financial"],"summary":"Get financial data by ticker (simplified)","description":"Simplified GET endpoint to retrieve financial data with query parameters.\n \n **Time Period Options:**\n - Use `period` for convenience: \"1d\", \"7d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - OR use `start_date` and `end_date` for specific date range\n - Cannot use both approaches simultaneously\n \n **Examples:**\n - `/api/v1/financial/data/AAPL?period=1y&include_metrics=true` - Last year of financial data\n - `/api/v1/financial/data/AAPL?start_date=2024-01-01&end_date=2024-12-31&period_type=quarterly` - Specific date range","operationId":"get_financial_data_simple_api_v1_financial_data__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"period","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'","title":"Period"},"description":"Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Start date for data retrieval (use with end_date, not with period)","title":"Start Date"},"description":"Start date for data retrieval (use with end_date, not with period)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"End date for data retrieval (use with start_date, not with period)","title":"End Date"},"description":"End date for data retrieval (use with start_date, not with period)"},{"name":"period_type","in":"query","required":false,"schema":{"type":"string","description":"Period type: quarterly, annual, or all","default":"all","title":"Period Type"},"description":"Period type: quarterly, annual, or all"},{"name":"include_metrics","in":"query","required":false,"schema":{"type":"boolean","description":"Include calculated metrics","default":true,"title":"Include Metrics"},"description":"Include calculated metrics"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Force refresh from SEC","default":false,"title":"Force Refresh"},"description":"Force refresh from SEC"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FinancialDataResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/financial/data/bulk":{"post":{"tags":["financial"],"summary":"Get SEC EDGAR financial data for multiple tickers","description":"Retrieve comprehensive financial data for multiple tickers in a single request directly from SEC EDGAR filings.\n \n **🔥 Three Ways to Specify Time Period (choose one):**\n \n 1. **Period String** (NEW! Most convenient):\n - `period`: \"1d\", \"7d\", \"30d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - Example: Last 1 year for multiple tickers, or \"max\" for all available data\n \n 2. **Date Range** (Traditional):\n - `start_date` + `end_date`: Specific date range\n - Example: Specific date range for all tickers\n \n 3. **Quarters** (Quarter-based):\n - `quarters`: List of quarters like [\"2024Q1\", \"2024Q2\"]\n - Example: Specific quarters for all tickers\n \n **Data Sources:**\n - **Financial Data**: Direct SEC EDGAR API calls (revenue, income, assets, cash flow)\n - **Price Data**: Available via separate price data endpoints using yfinance-plus\n \n **Bulk Processing Features:**\n - Processes up to 100 tickers in parallel for maximum efficiency\n - Returns individual success/failure results for each ticker\n - Handles partial failures gracefully (some tickers can fail while others succeed)\n - Uses the same robust SEC data retrieval logic as single ticker endpoint\n \n **SEC EDGAR Integration:**\n - Direct API calls to official SEC EDGAR database\n - All financial data sourced from actual SEC filings (10-K, 10-Q)\n - No estimated or synthetic data - only actual reported figures\n - Historical data available from 1994-present (15+ years for most companies)\n - Automatic validation and error handling for missing periods\n \n **Data Quality & Features:**\n - Company information (name, CIK, sector, industry, business description)\n - Comprehensive financial statements (income statement, balance sheet, cash flow)\n - Calculated financial metrics (ratios, margins, growth rates)\n - Period types: quarterly (10-Q) and annual (10-K) filings\n - Database caching to avoid repeated SEC API calls\n \n **Performance:**\n - Parallel processing for bulk requests\n - Intelligent caching and rate limiting\n - Use `force_refresh=true` to fetch fresh data from SEC EDGAR\n \n **Example Requests:**\n ```json\n // Using period (simplest)\n {\n \"tickers\": [\"AAPL\", \"MSFT\", \"GOOGL\"],\n \"period\": \"1y\",\n \"include_metrics\": true\n }\n \n // Using date range\n {\n \"tickers\": [\"NVDA\", \"AMD\", \"INTC\"],\n \"start_date\": \"2024-01-01\",\n \"end_date\": \"2024-12-31\",\n \"period_type\": \"quarterly\"\n }\n \n // Using quarters\n {\n \"tickers\": [\"TSLA\", \"F\", \"GM\"],\n \"quarters\": [\"2024Q1\", \"2024Q2\"],\n \"include_metrics\": true\n }\n ```\n \n Each ticker result includes the same comprehensive financial data structure as the single ticker endpoint.\n Failed tickers will have detailed error messages while successful ones will have complete SEC filing data.","operationId":"get_bulk_financial_data_api_v1_financial_data_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkFinancialDataRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkFinancialDataResponse"}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/data":{"post":{"tags":["price"],"summary":"Get enhanced price data via yfinance-plus","description":"Retrieve historical price data for a specific ticker using enhanced yfinance-plus integration.\n \n **🔥 Three Ways to Specify Time Period (choose one):**\n \n 1. **Period String** (NEW! Most convenient):\n - `period`: \"1d\", \"7d\", \"30d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - Example: `{\"ticker\": \"AAPL\", \"period\": \"3m\", \"interval\": \"1d\"}` - Last 3 months, daily prices\n - Example: `{\"ticker\": \"TSLA\", \"period\": \"max\", \"interval\": \"1d\"}` - Maximum 20 years of data\n \n 2. **Date Range** (Traditional):\n - `start_date` + `end_date`: Specific date range\n - Example: `{\"ticker\": \"AAPL\", \"start_date\": \"2024-01-01\", \"end_date\": \"2024-12-31\", \"interval\": \"1d\"}`\n \n 3. **Quarters** (Quarter-based):\n - `quarters`: List of quarters like [\"2024Q1\", \"2024Q2\"]\n - Example: `{\"ticker\": \"AAPL\", \"quarters\": [\"2024Q1\", \"2024Q2\"], \"interval\": \"1d\"}`\n \n **Data Source:**\n - **Price Data**: Yahoo Finance via yfinance-plus with enhanced rate limiting and caching\n - **Financial Data**: Available via separate financial endpoints using SEC EDGAR\n \n **This endpoint returns:**\n - OHLCV data (Open, High, Low, Close, Volume)\n - Adjusted close prices with dividend/split adjustments\n - Multiple intervals: 1d, 1w, 1m, 1h (where available)\n - Extensive historical data (decades for most symbols)\n \n **Enhanced Features (yfinance-plus):**\n - Intelligent rate limiting to prevent API throttling\n - Multi-threaded bulk downloads for better performance\n - Advanced caching with cache management\n - Automatic retry with exponential backoff\n - Multiple user agents for improved reliability\n - Enhanced error handling and recovery\n \n **Performance:**\n - Database caching to minimize external API calls\n - Bulk mode capable of 59+ tickers/second throughput\n - 4.3x faster than individual ticker requests\n - Use `force_refresh=true` to fetch fresh data from Yahoo Finance\n \n **Example Requests:**\n ```json\n // Using period (simplest)\n {\n \"ticker\": \"AAPL\",\n \"period\": \"6m\",\n \"interval\": \"1d\"\n }\n \n // Using date range\n {\n \"ticker\": \"TSLA\",\n \"start_date\": \"2024-01-01\",\n \"end_date\": \"2024-12-31\",\n \"interval\": \"1w\"\n }\n \n // Using quarters\n {\n \"ticker\": \"NVDA\",\n \"quarters\": [\"2024Q1\", \"2024Q2\"],\n \"interval\": \"1d\",\n \"force_refresh\": true\n }\n ```","operationId":"get_price_data_api_v1_price_data_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceDataRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceDataResponse"}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Data not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["price","price","alpaca"],"summary":"Get daily bars for multiple tickers via Alpaca (DB-backed)","description":"Fetch OHLCV daily bars for up to ~500 tickers. Results are stored in DB so subsequent calls only fetch new/missing dates from Alpaca.\n\n- `tickers`: comma-separated list, e.g. `AAPL,MSFT,BF-B`\n- Ticker normalization: `BF-B` → `BF.B` handled automatically; response keys use the original symbol names.\n- `force_refresh=true`: re-fetch all from Alpaca regardless of DB state.\n- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`.\n\n**⚠️ Alpaca 배치 제한**\n\nAlpaca multi-bar 엔드포인트는 요청당 **~100개 심볼**이 실질적 상한입니다 (공식 문서 미명시, 커뮤니티 보고 및 실제 운용 기준). 내부적으로 **100개 단위로 자동 분할**하여 요청하므로 클라이언트는 신경 쓸 필요 없음. 단, 배치 수가 늘어날수록 응답 시간이 선형적으로 증가함 (500종목 → Alpaca 5회 호출).","operationId":"get_multi_ticker_daily_bars_api_v1_price_data_get","parameters":[{"name":"tickers","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B","title":"Tickers"},"description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B"},{"name":"start_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Start date (YYYY-MM-DD)","title":"Start Date"},"description":"Start date (YYYY-MM-DD)"},{"name":"end_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"End date (YYYY-MM-DD)","title":"End Date"},"description":"End date (YYYY-MM-DD)"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Bar interval: 1d, 1w, 1mo","default":"1d","title":"Interval"},"description":"Bar interval: 1d, 1w, 1mo"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Re-fetch from Alpaca even if DB has data","default":false,"title":"Force Refresh"},"description":"Re-fetch from Alpaca even if DB has data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiBarsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/data/{ticker}":{"get":{"tags":["price"],"summary":"Get price data by ticker (simplified)","description":"Simplified GET endpoint to retrieve price data with query parameters.\n \n **Time Period Options:**\n - Use `period` for convenience: \"1d\", \"7d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - OR use `start_date` and `end_date` for specific date range\n - Cannot use both approaches simultaneously\n \n **Examples:**\n - `/api/v1/price/data/AAPL?period=1y&interval=1d` - Last year of daily prices\n - `/api/v1/price/data/TSLA?period=max&interval=1d` - Maximum 20 years of data for Tesla\n - `/api/v1/price/data/AAPL?start_date=2024-01-01&end_date=2024-12-31&interval=1d` - Specific date range","operationId":"get_price_data_simple_api_v1_price_data__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"period","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'","title":"Period"},"description":"Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Start date for data retrieval (use with end_date, not with period)","title":"Start Date"},"description":"Start date for data retrieval (use with end_date, not with period)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"End date for data retrieval (use with start_date, not with period)","title":"End Date"},"description":"End date for data retrieval (use with start_date, not with period)"},{"name":"start","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Alias for start_date","title":"Start"},"description":"Alias for start_date"},{"name":"end","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Alias for end_date","title":"End"},"description":"Alias for end_date"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Data interval: 1d, 1w, 1m, 5d, 1h, etc.","default":"1d","title":"Interval"},"description":"Data interval: 1d, 1w, 1m, 5d, 1h, etc."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Force refresh from Yahoo Finance","default":false,"title":"Force Refresh"},"description":"Force refresh from Yahoo Finance"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceDataResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/data/bulk":{"post":{"tags":["price"],"summary":"Get enhanced price data for multiple tickers via yfinance-plus","description":"Retrieve historical price data for multiple tickers in a single request using enhanced yfinance-plus integration.\n \n **🔥 Three Ways to Specify Time Period (choose one):**\n \n 1. **Period String** (NEW! Most convenient):\n - `period`: \"1d\", \"7d\", \"30d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - Example: Last 3 months for multiple tickers, or \"max\" for maximum 20 years of data\n \n 2. **Date Range** (Traditional):\n - `start_date` + `end_date`: Specific date range\n - Example: Specific date range for all tickers\n \n 3. **Quarters** (Quarter-based):\n - `quarters`: List of quarters like [\"2024Q1\", \"2024Q2\"]\n - Example: Specific quarters for all tickers\n \n **Data Source:**\n - **Price Data**: Yahoo Finance via yfinance-plus with enhanced rate limiting and caching\n - **Financial Data**: Available via separate financial endpoints using SEC EDGAR\n \n **Bulk Processing Features:**\n - Processes up to 100 tickers in parallel for maximum throughput\n - Returns individual success/failure results for each ticker\n - Handles partial failures gracefully (some tickers can fail while others succeed)\n - Uses the same enhanced data retrieval logic as single ticker endpoint\n \n **Enhanced Performance (yfinance-plus):**\n - Multi-threaded bulk downloads with intelligent rate limiting\n - 4.3x faster than individual ticker requests\n - Bulk mode capable of 59+ tickers/second throughput\n - Advanced caching and automatic retry with exponential backoff\n - Enhanced error handling and recovery mechanisms\n \n **Data Quality:**\n - OHLCV data with dividend/split adjustments\n - Multiple intervals: 1d, 1w, 1m, 1h (where available)\n - Extensive historical data (decades for most symbols)\n - Database caching to minimize external API calls\n \n **Example Requests:**\n ```json\n // Using period (simplest)\n {\n \"tickers\": [\"AAPL\", \"MSFT\", \"GOOGL\"],\n \"period\": \"3m\",\n \"interval\": \"1d\"\n }\n \n // Using date range\n {\n \"tickers\": [\"NVDA\", \"AMD\", \"INTC\"],\n \"start_date\": \"2024-01-01\",\n \"end_date\": \"2024-12-31\",\n \"interval\": \"1w\"\n }\n \n // Using quarters\n {\n \"tickers\": [\"TSLA\", \"F\", \"GM\"],\n \"quarters\": [\"2024Q1\", \"2024Q2\"],\n \"interval\": \"1d\",\n \"force_refresh\": true\n }\n ```\n \n Each ticker result includes the same comprehensive price data structure as the single ticker endpoint.\n Failed tickers will have detailed error messages while successful ones will have complete OHLCV data.","operationId":"get_bulk_price_data_api_v1_price_data_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkPriceDataRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkPriceDataResponse"}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/latest/{ticker}":{"get":{"tags":["price"],"summary":"Get latest price for a ticker","description":"Get the most recent price data point for a ticker","operationId":"get_latest_price_api_v1_price_latest__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceDataPoint"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/quote/{ticker}":{"get":{"tags":["price"],"summary":"Get latest quote (regular/pre/post)","description":"Return latest price with regular/pre/post market fields from yfinance-plus","operationId":"get_quote_api_v1_price_quote__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"use_prepost","in":"query","required":false,"schema":{"type":"boolean","description":"Include pre/post market prices if available","default":true,"title":"Use Prepost"},"description":"Include pre/post market prices if available"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/intraday":{"get":{"tags":["price"],"summary":"Get intraday bars for multiple tickers via Yahoo Finance","description":"Fetch intraday OHLCV bars for up to ~500 tickers using Yahoo Finance.\n\n**⚠️ Yahoo Finance 분봉 데이터 한계**\n\n| 항목 | 내용 |\n|------|------|\n| 지연 | **15분 지연** (실시간 아님) |\n| `1m` 최대 조회 기간 | 최근 **7일** 이내 |\n| `2m`/`5m`/`15m`/`30m`/`90m` | 최근 **60일** 이내 |\n| `1h` | 최근 **730일** 이내 |\n| 실시간 거래 전략 | **부적합** — 15분 지연으로 ORB 등 당일 전략에 사용 불가 |\n| 데이터 품질 | Yahoo Finance 자체 집계, 간헐적 누락/오류 가능 |\n\n**권장 용도**: 백테스트, 과거 분봉 분석 (60일 이내)\n\n**실시간 당일 분봉이 필요하면** → `GET /api/v1/alpaca/intraday` 사용 (Alpaca IEX 피드, 실시간)\n\n- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n- Redis 5분 TTL 캐시 적용","operationId":"get_multi_ticker_intraday_api_v1_price_intraday_get","parameters":[{"name":"tickers","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated tickers","title":"Tickers"},"description":"Comma-separated tickers"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Interval: 1m, 5m, 15m, 30m, 1h","default":"5m","title":"Interval"},"description":"Interval: 1m, 5m, 15m, 30m, 1h"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Start date (YYYY-MM-DD)","title":"Start Date"},"description":"Start date (YYYY-MM-DD)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"End date (YYYY-MM-DD)","title":"End Date"},"description":"End date (YYYY-MM-DD)"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiBarsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/intraday/{ticker}":{"get":{"tags":["price"],"summary":"Get intraday candles","description":"Return intraday candles using yfinance-plus history(period,interval)","operationId":"get_intraday_api_v1_price_intraday__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"interval","in":"query","required":false,"schema":{"type":"string","default":"1m","title":"Interval"}},{"name":"period","in":"query","required":false,"schema":{"type":"string","default":"1d","title":"Period"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntradayResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/today/{ticker}":{"get":{"tags":["price"],"summary":"Get today's OHLC","description":"Return today's OHLC. If daily not finalized yet, aggregate from 1m intraday.","operationId":"get_today_ohlc_api_v1_price_today__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TodayOHLCResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/stocks/index/{index_name}":{"get":{"tags":["stocks"],"summary":"Get index constituents (S&P 500 / Nasdaq 100)","description":"Get current constituents of a major stock index from Wikipedia.\n\nReturns each stock's symbol, company name, GICS Sector, and GICS Sub-Industry.\n\n**Supported values for `index_name`**:\n- `sp500` — S&P 500 (~503 stocks)\n- `nasdaq100` — Nasdaq 100 (~101 stocks)\n\n**Data Source**: Wikipedia\n**Cache TTL**: 24 hours (`X-Cache: HIT/MISS`, `ETag` headers included)\n**Timeout**: 30 seconds (Wikipedia fetch)\n\n**Error codes**:\n- `400` — unsupported `index_name`\n- `504` — Wikipedia response timed out","operationId":"get_index_constituents_api_v1_stocks_index__index_name__get","parameters":[{"name":"index_name","in":"path","required":true,"schema":{"type":"string","title":"Index Name"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"If true, bypasses cache and fetches fresh data","default":false,"title":"Force Refresh"},"description":"If true, bypasses cache and fetches fresh data"}],"responses":{"200":{"description":"List of constituent stocks with symbol, name, sector, and industry","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/stocks/most-active":{"get":{"tags":["stocks"],"summary":"Most actively traded stocks by volume","description":"Get most actively traded stocks from Yahoo Finance.\n\n**⚠️ 실시간 전용**: DB에 저장되지 않음. 과거 데이터 조회 불가.\n캐시 TTL: 1시간 (`X-Cache: HIT/MISS` 헤더 포함).","operationId":"get_most_active_stocks_api_v1_stocks_most_active_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":500,"minimum":1},{"type":"null"}],"description":"Maximum number of stocks to return (1-500). If not specified, returns all available stocks.","title":"Limit"},"description":"Maximum number of stocks to return (1-500). If not specified, returns all available stocks."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"If true, bypasses cache and fetches fresh data","default":false,"title":"Force Refresh"},"description":"If true, bypasses cache and fetches fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/stocks/52-week-gainers":{"get":{"tags":["stocks"],"summary":"Top 52-week gaining stocks","description":"Get 52-week top gaining stocks from Yahoo Finance.\n\n**⚠️ 실시간 전용**: DB에 저장되지 않음. 과거 데이터 조회 불가.\n캐시 TTL: 1시간. 첫 호출 시 15-30초 소요 (웹 스크래핑).","operationId":"get_52week_gainers_api_v1_stocks_52_week_gainers_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":1000,"minimum":1},{"type":"null"}],"description":"Maximum number of stocks to return (1-1000). If not specified, returns first 600 stocks (3 pages) for performance.","title":"Limit"},"description":"Maximum number of stocks to return (1-1000). If not specified, returns first 600 stocks (3 pages) for performance."},{"name":"max_pages","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":10,"minimum":1},{"type":"null"}],"description":"Maximum pages to fetch (1-10). Each page has ~200 stocks. Higher values may cause rate limiting.","default":3,"title":"Max Pages"},"description":"Maximum pages to fetch (1-10). Each page has ~200 stocks. Higher values may cause rate limiting."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/stocks/trending":{"get":{"tags":["stocks"],"summary":"Trending stocks combining most active and 52-week gainers","description":"Get trending stocks by combining most-active + 52-week gainers.\n\n**⚠️ 실시간 전용**: DB에 저장되지 않음. 과거 데이터 조회 불가.\n캐시 TTL: 30분. 병렬 스크래핑으로 최적화.","operationId":"get_trending_stocks_api_v1_stocks_trending_get","parameters":[{"name":"n","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Total number of trending stocks to return after combining most active + gainers (default: 500)","default":500,"title":"N"},"description":"Total number of trending stocks to return after combining most active + gainers (default: 500)"},{"name":"most_active_limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Number of most active stocks to include. If not specified, returns all available stocks (~170).","title":"Most Active Limit"},"description":"Number of most active stocks to include. If not specified, returns all available stocks (~170)."},{"name":"gainers_limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Number of 52-week gainers to fetch. If not specified, fetches enough to reach target 'n' after combining with most active.","title":"Gainers Limit"},"description":"Number of 52-week gainers to fetch. If not specified, fetches enough to reach target 'n' after combining with most active."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/fred/stats/usage":{"get":{"tags":["fred"],"summary":"FRED API usage statistics and cache performance","description":"Get FRED API usage statistics and cache performance\n\nReturns detailed statistics about API usage, cache performance, and daily limits.\nNow includes enhanced proxy service statistics.\n\n**Example Response**:\n```json\n{\n \"success\": true,\n \"data\": {\n \"daily_limit\": 1000,\n \"used_today\": 45,\n \"remaining_today\": 955,\n \"usage_percentage\": 4.5,\n \"can_make_requests\": true,\n \"daily_stats\": [\n {\n \"date\": \"2025-01-14\",\n \"total_calls\": 45,\n \"successful_calls\": 44,\n \"total_records\": 1250,\n \"success_rate\": 97.8\n }\n ],\n \"endpoint_stats\": [\n {\n \"endpoint\": \"series\",\n \"call_count\": 25\n }\n ],\n \"proxy_info\": {\n \"mode\": \"pass_through_proxy\",\n \"supported_endpoints\": \"all_fred_endpoints\"\n }\n }\n}\n```\n\n**Parameters**:\n- `days`: Number of days to include in historical statistics (1-30)\n- `use_proxy_stats`: Use enhanced proxy service statistics (recommended)\n\n**Metrics Included**:\n- Daily API usage and remaining quota\n- Historical usage patterns \n- Endpoint-specific usage statistics (NEW!)\n- Success rates and error tracking\n- Proxy service information (NEW!)","operationId":"get_fred_usage_stats_api_v1_fred_stats_usage_get","parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to include in stats","default":7,"title":"Days"},"description":"Number of days to include in stats"},{"name":"use_proxy_stats","in":"query","required":false,"schema":{"type":"boolean","description":"Use enhanced proxy service statistics","default":true,"title":"Use Proxy Stats"},"description":"Use enhanced proxy service statistics"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/fred/proxy/{endpoint}":{"get":{"tags":["fred"],"summary":"Universal FRED API proxy","description":"FRED API Pass-through Proxy\n\nUniversal proxy endpoint that forwards requests to any FRED API endpoint while maintaining\nour caching and rate limiting logic.\n\n**Supported Endpoints**: All FRED API endpoints are supported\n\n**Examples**:\n```bash\n# Series information\nGET /api/v1/fred/proxy/series?series_id=GDP\n\n# Series observations \nGET /api/v1/fred/proxy/series/observations?series_id=UNRATE&limit=12\n\n# Category information\nGET /api/v1/fred/proxy/category?category_id=125\n\n# Category children\nGET /api/v1/fred/proxy/category/children?category_id=13\n\n# Release information\nGET /api/v1/fred/proxy/release?release_id=53\n\n# Search series\nGET /api/v1/fred/proxy/series/search?search_text=unemployment&limit=25\n\n# Sources\nGET /api/v1/fred/proxy/sources\n\n# Tags\nGET /api/v1/fred/proxy/tags?limit=100\n```\n\n**Key Features**:\n- **Universal Access**: Support for all FRED API endpoints\n- **Smart Caching**: 24-hour DB caching for series and observations (NEW!)\n- **Permanent Storage**: Historical data permanently stored in database (NEW!)\n- **Rate Limiting**: Respects 1,000/day limit with usage tracking \n- **Parameter Forwarding**: Automatically forwards all supported parameters\n- **Error Handling**: Comprehensive error handling and logging\n- **Usage Statistics**: Tracks endpoint usage and performance\n\n**Parameters**:\nAll standard FRED API parameters are supported including:\n- `series_id`, `category_id`, `release_id`, `source_id`\n- `realtime_start`, `realtime_end`, `observation_start`, `observation_end` \n- `limit`, `offset`, `order_by`, `sort_order`\n- `search_text`, `search_type`, `frequency`, `aggregation_method`\n- `force_refresh`: Bypass cache and fetch fresh data from FRED API\n- `bypass_limit_check`: Skip daily limit validation (admin only)\n- And many more...\n\n**Caching Strategy**:\n- **Cache Hit**: Returns instantly from database (no API call)\n- **Cache Miss**: Fetches from FRED API and stores for 24 hours \n- **Permanent Storage**: Historical observations stored permanently\n- **API Limit Reached**: Returns cached data even if expired\n\n**Response Format**: Returns original FRED API response with additional metadata","operationId":"fred_proxy_endpoint_api_v1_fred_proxy__endpoint__get","parameters":[{"name":"endpoint","in":"path","required":true,"schema":{"type":"string","title":"Endpoint"}},{"name":"series_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Series ID parameter","title":"Series Id"},"description":"Series ID parameter"},{"name":"category_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Category ID parameter","title":"Category Id"},"description":"Category ID parameter"},{"name":"release_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Release ID parameter","title":"Release Id"},"description":"Release ID parameter"},{"name":"source_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Source ID parameter","title":"Source Id"},"description":"Source ID parameter"},{"name":"tag_names","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Tag names parameter","title":"Tag Names"},"description":"Tag names parameter"},{"name":"realtime_start","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Realtime start date (YYYY-MM-DD)","title":"Realtime Start"},"description":"Realtime start date (YYYY-MM-DD)"},{"name":"realtime_end","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Realtime end date (YYYY-MM-DD)","title":"Realtime End"},"description":"Realtime end date (YYYY-MM-DD)"},{"name":"observation_start","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Observation start date (YYYY-MM-DD)","title":"Observation Start"},"description":"Observation start date (YYYY-MM-DD)"},{"name":"observation_end","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Observation end date (YYYY-MM-DD)","title":"Observation End"},"description":"Observation end date (YYYY-MM-DD)"},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":100000,"minimum":1},{"type":"null"}],"description":"Limit number of results","title":"Limit"},"description":"Limit number of results"},{"name":"offset","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":0},{"type":"null"}],"description":"Offset for pagination","title":"Offset"},"description":"Offset for pagination"},{"name":"order_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Order by parameter","title":"Order By"},"description":"Order by parameter"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order (asc/desc)","title":"Sort Order"},"description":"Sort order (asc/desc)"},{"name":"search_text","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Search text","title":"Search Text"},"description":"Search text"},{"name":"search_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Search type","title":"Search Type"},"description":"Search type"},{"name":"frequency","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Data frequency","title":"Frequency"},"description":"Data frequency"},{"name":"aggregation_method","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Aggregation method","title":"Aggregation Method"},"description":"Aggregation method"},{"name":"output_type","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Output type","title":"Output Type"},"description":"Output type"},{"name":"vintage_dates","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Vintage dates","title":"Vintage Dates"},"description":"Vintage dates"},{"name":"exclude_tag_names","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Exclude tag names","title":"Exclude Tag Names"},"description":"Exclude tag names"},{"name":"tag_group_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Tag group ID","title":"Tag Group Id"},"description":"Tag group ID"},{"name":"bypass_limit_check","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass daily limit check (admin only)","default":false,"title":"Bypass Limit Check"},"description":"Bypass daily limit check (admin only)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Force refresh from API, bypass cache","default":false,"title":"Force Refresh"},"description":"Force refresh from API, bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/fred/endpoints":{"get":{"tags":["fred"],"summary":"List supported FRED API endpoints","description":"Get list of supported FRED API endpoints\n\nReturns comprehensive list of all FRED API endpoints that can be accessed\nthrough the proxy service.\n\n**Usage**: Use this to discover available endpoints and their categories.\n\n**Example Response**:\n```json\n{\n \"series_endpoints\": [\n \"series\",\n \"series/observations\", \n \"series/search\",\n \"...\"\n ],\n \"category_endpoints\": [\"...\"],\n \"release_endpoints\": [\"...\"]\n}\n```","operationId":"get_supported_fred_endpoints_api_v1_fred_endpoints_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/news/{ticker}":{"get":{"tags":["news"],"summary":"Get news and social media for a ticker","description":"Fetch recent news articles and social media posts for a ticker from multiple sources.\n\n **News sources**: Yahoo Finance, NewsAPI\n **Social sources**: Reddit (r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis, r/ValueInvesting)\n\n Both sources are fetched in parallel. Results are deduplicated and ranked by relevance.\n Cached for **10 minutes**.\n\n **Examples**:\n - `GET /news/AAPL` — last 7 days, up to 20 articles + 15 posts\n - `GET /news/TSLA?days_back=14&max_articles=50&include_social=false` — news-only, 2 weeks","operationId":"get_ticker_news_and_social_api_v1_news__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"days_back","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to look back for articles (1-30)","default":7,"title":"Days Back"},"description":"Number of days to look back for articles (1-30)"},{"name":"max_articles","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Maximum number of news articles to return (1-100)","default":20,"title":"Max Articles"},"description":"Maximum number of news articles to return (1-100)"},{"name":"max_social_posts","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":0,"description":"Maximum number of social media posts to return (0-50)","default":15,"title":"Max Social Posts"},"description":"Maximum number of social media posts to return (0-50)"},{"name":"include_social","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to include social media data","default":true,"title":"Include Social"},"description":"Whether to include social media data"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and fetch fresh data","default":false,"title":"Force Refresh"},"description":"Bypass cache and fetch fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsSocialResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/news/{ticker}/news-only":{"get":{"tags":["news"],"summary":"Get news articles for a ticker (no social media)","description":"Faster endpoint that returns only news articles, skipping social media API calls.\n\n **Sources**: Yahoo Finance, NewsAPI\n Cached for **10 minutes**.\n\n **Example**: `GET /news/NVDA/news-only?days_back=3&max_articles=30`","operationId":"get_ticker_news_only_api_v1_news__ticker__news_only_get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"days_back","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to look back for articles (1-30)","default":7,"title":"Days Back"},"description":"Number of days to look back for articles (1-30)"},{"name":"max_articles","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Maximum number of news articles to return (1-100)","default":30,"title":"Max Articles"},"description":"Maximum number of news articles to return (1-100)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and fetch fresh data","default":false,"title":"Force Refresh"},"description":"Bypass cache and fetch fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsOnlyResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/news/{ticker}/social-only":{"get":{"tags":["news"],"summary":"Get social media posts for a ticker","description":"Returns only Reddit posts for a ticker, skipping news API calls.\n\n **Subreddits**: r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis,\n r/StockMarket, r/ValueInvesting, r/financialindependence\n Cached for **10 minutes**.\n\n **Example**: `GET /news/GME/social-only?days_back=3&max_social_posts=30`","operationId":"get_ticker_social_only_api_v1_news__ticker__social_only_get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"days_back","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to look back for posts (1-30)","default":7,"title":"Days Back"},"description":"Number of days to look back for posts (1-30)"},{"name":"max_social_posts","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":1,"description":"Maximum number of social media posts to return (1-50)","default":20,"title":"Max Social Posts"},"description":"Maximum number of social media posts to return (1-50)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and fetch fresh data","default":false,"title":"Force Refresh"},"description":"Bypass cache and fetch fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialOnlyResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/etf/holdings/{ticker}":{"get":{"tags":["etf"],"summary":"Get ETF portfolio holdings","description":"Fetch the constituent holdings of an ETF (e.g., SPY, QQQ, IWM). Data is sourced from SEC 13-F filings and cached for 1 hour.\n\nUse `top_n` to limit to the N largest positions, or `top_percentage` to return the minimal set of holdings that covers X% of the portfolio (e.g., `top_percentage=0.8` for the holdings making up 80% of the ETF).\n\n**Examples**:\n- `GET /etf/holdings/SPY` — all holdings\n- `GET /etf/holdings/QQQ?top_n=10` — top 10 positions\n- `GET /etf/holdings/IWM?top_percentage=0.5` — holdings covering 50% of portfolio","operationId":"get_etf_holdings_api_v1_etf_holdings__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"as_of_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"YYYY-MM-DD","title":"As Of Date"},"description":"YYYY-MM-DD"},{"name":"top_n","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Return top N holdings by weight/value (mutually exclusive with top_percentage)","title":"Top N"},"description":"Return top N holdings by weight/value (mutually exclusive with top_percentage)"},{"name":"top_percentage","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Return minimal set covering X percent (e.g., 0.5 or 50 for 50%). Mutually exclusive with top_n","title":"Top Percentage"},"description":"Return minimal set covering X percent (e.g., 0.5 or 50 for 50%). Mutually exclusive with top_n"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ETFHoldingsOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/etf/admin/refresh-maps":{"post":{"tags":["etf","etf"],"summary":"Refresh ETF CIK and CUSIP mapping tables","description":"Re-fetches and upserts the ETF→CIK and CUSIP→ticker mapping tables from SEC data. Run this when new ETFs need to be supported. Returns the number of rows updated.","operationId":"refresh_etf_maps_api_v1_etf_admin_refresh_maps_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshMapsOut"}}}}}}},"/api/v1/filings/search/{ticker}":{"get":{"tags":["filings"],"summary":"Search SEC filings for a ticker","description":"Search SEC filings for the given ticker. Supported form types: **8-K, 6-K, 20-F, 40-F**.\n\nAuto-indexes filings from EDGAR on first request (or when `force_refresh=true`). Results are cached for 1 hour.\n\n**현재 DB 보유**: 1994-01-05 ~ 현재, 1598 티커. 처음 조회하는 티커는 SEC EDGAR에서 자동 인덱싱 (수 초 소요).\n\n**Example**: `GET /filings/search/AAPL?form_type=8-K&limit=10`","operationId":"search_filings_api_v1_filings_search__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"form_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated form types (e.g. '8-K,6-K'). Default: all supported.","title":"Form Type"},"description":"Comma-separated form types (e.g. '8-K,6-K'). Default: all supported."},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Start date YYYY-MM-DD","title":"Start Date"},"description":"Start date YYYY-MM-DD"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"End date YYYY-MM-DD","title":"End Date"},"description":"End date YYYY-MM-DD"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Force re-indexing from SEC","default":false,"title":"Force Refresh"},"description":"Force re-indexing from SEC"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilingSearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/documents/{accession_number}":{"get":{"tags":["filings"],"summary":"List documents in a SEC filing","description":"List all documents attached to a SEC filing by accession number.\n\nReturns filename, document type, size, and SEC URL for each document. Cached for 24 hours.\n\n**Example**: `GET /filings/documents/0001193125-24-123456`","operationId":"get_filing_documents_api_v1_filings_documents__accession_number__get","parameters":[{"name":"accession_number","in":"path","required":true,"schema":{"type":"string","title":"Accession Number"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilingDocumentListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/exhibit/{accession_number}":{"get":{"tags":["filings"],"summary":"Extract exhibit content from a filing","description":"Extract the text content of a specific exhibit (e.g., press release **EX-99.1**) from a SEC filing.\n\nReturns the full text content along with content type, filename, and SEC URL. 404 responses are negative-cached for 1 hour. Cached for 24 hours.\n\n**Example**: `GET /filings/exhibit/0001193125-24-123456?exhibit_type=EX-99.1`","operationId":"get_exhibit_content_api_v1_filings_exhibit__accession_number__get","parameters":[{"name":"accession_number","in":"path","required":true,"schema":{"type":"string","title":"Accession Number"}},{"name":"exhibit_type","in":"query","required":false,"schema":{"type":"string","description":"Exhibit type (e.g. EX-99.1)","default":"EX-99.1","title":"Exhibit Type"},"description":"Exhibit type (e.g. EX-99.1)"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExhibitContentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/search/bulk":{"post":{"tags":["filings"],"summary":"Bulk search SEC filings for multiple tickers","description":"Search SEC filings for up to many tickers in a single request. Auto-indexes from EDGAR for any ticker not yet in the database.\n\n**Timeout**: 600 seconds. Each ticker is processed concurrently.\n\n**Example body**:\n```json\n{\"tickers\": [\"AAPL\", \"MSFT\", \"NVDA\"], \"form_type\": \"8-K\", \"start_date\": \"2024-01-01\", \"limit_per_ticker\": 5}\n```","operationId":"search_filings_bulk_api_v1_filings_search_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkFilingSearchRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkFilingSearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/exhibit/bulk":{"post":{"tags":["filings"],"summary":"Bulk fetch exhibit content","description":"Fetch exhibit content for multiple accession numbers in one request. Up to 4 concurrent fetches; max 300 second timeout.\n\n**Example body**:\n```json\n{\"items\": [{\"accession_number\": \"0001193125-24-123456\", \"exhibit_type\": \"EX-99.1\"}]}\n```","operationId":"get_exhibit_bulk_api_v1_filings_exhibit_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkExhibitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkExhibitResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/events/{ticker}":{"get":{"tags":["filings"],"summary":"Get parsed 8-K events for a ticker","description":"Returns structured events parsed from 8-K filings. Each event corresponds to one 8-K Item (e.g., Item 8.01 → other_material_event, Item 2.02 → earnings_result).\\n\\nIf there are unprocessed (pending) filings, they are lazily parsed on first request.\\n\\n**Example**: `GET /filings/events/AVGO?start_date=2026-04-01&event_type=other_material_event`","operationId":"get_filing_events_api_v1_filings_events__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Start date YYYY-MM-DD","title":"Start Date"},"description":"Start date YYYY-MM-DD"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"End date YYYY-MM-DD","title":"End Date"},"description":"End date YYYY-MM-DD"},{"name":"event_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by event type (e.g. other_material_event)","title":"Event Type"},"description":"Filter by event type (e.g. other_material_event)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":20,"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":{"$ref":"#/components/schemas/FilingEventsSearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/events/parse/bulk":{"post":{"tags":["filings"],"summary":"Bulk parse pending 8-K filings","description":"Parse 8-K filings and create structured events.\\n\\n- **Default**: processes only `pending` filings.\\n- **`force_reparse=true`**: resets `succeeded`/`failed` filings to `pending` and re-parses them.\\n\\n**Example — reparse specific ticker**: `{\"tickers\": [\"AVGO\"], \"limit\": 50, \"force_reparse\": true}`\\n**Example — backfill all pending**: `{\"limit\": 200}`","operationId":"parse_8k_bulk_api_v1_filings_events_parse_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkParseRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkParseResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/events/parse/{accession_number}":{"post":{"tags":["filings"],"summary":"Force-reparse a single 8-K filing","description":"Reparse a specific filing by accession number, regardless of current `parsed_status`.\\n\\n**Example**: `POST /filings/events/parse/0001193125-26-144028`","operationId":"parse_8k_single_api_v1_filings_events_parse__accession_number__post","parameters":[{"name":"accession_number","in":"path","required":true,"schema":{"type":"string","title":"Accession Number"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkParseResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/metadata/catalog":{"get":{"tags":["metadata"],"summary":"Get data catalog","description":"Get a comprehensive catalog of all available data fields.\n \n This endpoint returns:\n - All available financial metrics and their descriptions\n - Data types and units for each field\n - Calculation methods where applicable\n - Data sources for each field\n \n The catalog is organized by categories:\n - Company Information\n - Income Statement\n - Balance Sheet\n - Cash Flow Statement\n - Valuation Ratios\n - Profitability Metrics\n - Growth Metrics\n - Liquidity & Solvency\n - Efficiency Metrics\n - Market Data (Future)","operationId":"get_catalog_api_v1_metadata_catalog_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DataCatalogResponse"}}}}}}},"/api/v1/admin/migrate":{"post":{"tags":["admin"],"summary":"Migrate data from another instance","description":"Migrate financial data from another SEC Investment API instance.\n \n This endpoint allows you to:\n - Transfer all data from one instance to another\n - Migrate specific tickers only\n - Migrate data within specific date ranges\n \n Requires valid migration API key in X-API-Key header.","operationId":"migrate_data_api_v1_admin_migrate_post","parameters":[{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrationRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/migration/export/{ticker}":{"get":{"tags":["admin"],"summary":"Export data for migration","description":"Export financial data for a specific ticker (used by migration process)","operationId":"export_data_api_v1_admin_migration_export__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Date"}},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Date"}},{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/database/stats":{"get":{"tags":["database"],"summary":"Database record counts and date ranges","description":"Returns aggregate statistics across all core tables:\n\n - `companies` — total companies, how many have financial/price data\n - `financial_data` — total records, real vs estimated, date range, breakdown by source\n - `price_data` — total records, date range, list of tickers\n - `calculated_metrics` — total records and date range","operationId":"get_database_stats_api_v1_database_stats_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Database Stats Api V1 Database Stats Get"}}}}}}},"/api/v1/database/health":{"get":{"tags":["database"],"summary":"Database connection health check","description":"데이터베이스 연결 상태를 확인합니다.","operationId":"get_database_health_api_v1_database_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Database Health Api V1 Database Health Get"}}}}}}},"/api/v1/database/tables":{"get":{"tags":["database"],"summary":"Table row counts for all core tables","description":"데이터베이스 테이블 정보를 반환합니다.","operationId":"get_table_info_api_v1_database_tables_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Table Info Api V1 Database Tables Get"}}}}}}},"/api/v1/database/cleanup/duplicates":{"post":{"tags":["database"],"summary":"Remove duplicate financial and metrics records","description":"Remove duplicate financial and metrics records, keeping the most recent real data.","operationId":"cleanup_duplicate_records_api_v1_database_cleanup_duplicates_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Cleanup Duplicate Records Api V1 Database Cleanup Duplicates Post"}}}}}}},"/api/v1/database/tickers":{"get":{"tags":["database"],"summary":"List tickers available in the database","description":"사용 가능한 종목 목록을 반환합니다.","operationId":"get_available_tickers_api_v1_database_tickers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object","title":"Response Get Available Tickers Api V1 Database Tickers Get"}}}}}}},"/api/v1/database/etf/snapshots":{"get":{"tags":["database"],"summary":"List persisted ETF holdings snapshots","description":"Browse ETF holdings snapshots stored in the database. Each snapshot represents\n the portfolio as reported in a SEC 13-F filing.\n\n Filter by `ticker`, `start_date`, `end_date`. Results are ordered by snapshot date (newest first).\n\n **Example**: `GET /database/etf/snapshots?ticker=SPY&limit=10`","operationId":"list_etf_snapshots_api_v1_database_etf_snapshots_get","parameters":[{"name":"ticker","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ticker"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Start Date"}},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"End Date"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","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/v1/database/etf/snapshot/{snapshot_id}":{"get":{"tags":["database"],"summary":"Get ETF snapshot with full holdings list","operationId":"get_etf_snapshot_api_v1_database_etf_snapshot__snapshot_id__get","parameters":[{"name":"snapshot_id","in":"path","required":true,"schema":{"type":"string","title":"Snapshot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/database/financial/records":{"get":{"tags":["database"],"summary":"Browse raw financial data records","description":"List raw financial data rows from the `financial_data` table.\n\n Supports filtering by `ticker`, `period_type` (`quarterly`/`annual`),\n `start_date`, and `end_date`. Results ordered by `period_date` descending.\n\n **Example**: `GET /database/financial/records?ticker=AAPL&period_type=quarterly&limit=8`","operationId":"list_financial_records_api_v1_database_financial_records_get","parameters":[{"name":"ticker","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ticker"}},{"name":"period_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period Type"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Start Date"}},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"End Date"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","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/v1/admin/errors/logs":{"get":{"tags":["error-logs"],"summary":"Get error logs","description":"Retrieve error logs with filtering and pagination options.\n \n **Filters:**\n - Date range (start_date, end_date)\n - Error type\n - Status code range\n - Endpoint pattern\n - Resolution status\n \n **Sorting:**\n - By date (newest first by default)\n - By status code\n - By response time\n \n **Pagination:**\n - Configurable page size (default: 50, max: 200)\n - Page-based navigation","operationId":"get_error_logs_api_v1_admin_errors_logs_get","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number","default":1,"title":"Page"},"description":"Page number"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"description":"Items per page","default":50,"title":"Page Size"},"description":"Items per page"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter by start date","title":"Start Date"},"description":"Filter by start date"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter by end date","title":"End Date"},"description":"Filter by end date"},{"name":"error_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by error type","title":"Error Type"},"description":"Filter by error type"},{"name":"status_code","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by status code","title":"Status Code"},"description":"Filter by status code"},{"name":"endpoint","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by endpoint (supports wildcards)","title":"Endpoint"},"description":"Filter by endpoint (supports wildcards)"},{"name":"is_resolved","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Filter by resolution status","title":"Is Resolved"},"description":"Filter by resolution status"},{"name":"sort_by","in":"query","required":false,"schema":{"type":"string","description":"Sort field: created_at, status_code, response_time_ms","default":"created_at","title":"Sort By"},"description":"Sort field: created_at, status_code, response_time_ms"},{"name":"sort_order","in":"query","required":false,"schema":{"type":"string","description":"Sort order: asc or desc","default":"desc","title":"Sort Order"},"description":"Sort order: asc or desc"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["error-logs"],"summary":"Delete all error logs","description":"Delete all error logs (use with caution)","operationId":"delete_all_error_logs_api_v1_admin_errors_logs_delete","parameters":[{"name":"confirm","in":"query","required":false,"schema":{"type":"boolean","description":"Must be true to confirm deletion","default":false,"title":"Confirm"},"description":"Must be true to confirm deletion"},{"name":"only_resolved","in":"query","required":false,"schema":{"type":"boolean","description":"Only delete resolved errors","default":false,"title":"Only Resolved"},"description":"Only delete resolved errors"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/errors/logs/{log_id}":{"get":{"tags":["error-logs"],"summary":"Get error log by ID","description":"Retrieve detailed information about a specific error log","operationId":"get_error_log_api_v1_admin_errors_logs__log_id__get","parameters":[{"name":"log_id","in":"path","required":true,"schema":{"type":"integer","title":"Log Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["error-logs"],"summary":"Update error log","description":"Update error log resolution status and notes","operationId":"update_error_log_api_v1_admin_errors_logs__log_id__patch","parameters":[{"name":"log_id","in":"path","required":true,"schema":{"type":"integer","title":"Log Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/errors/by-request/{request_id}":{"get":{"tags":["error-logs"],"summary":"Get error log by request ID","description":"Retrieve error log information for a specific request ID","operationId":"get_error_by_request_id_api_v1_admin_errors_by_request__request_id__get","parameters":[{"name":"request_id","in":"path","required":true,"schema":{"type":"string","title":"Request Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/errors/stats":{"get":{"tags":["error-logs"],"summary":"Get error statistics","description":"Get aggregated statistics about errors.\n \n **Statistics include:**\n - Total error count\n - Errors by type\n - Errors by status code\n - Errors by endpoint\n - Time-based trends\n - Resolution rate","operationId":"get_error_stats_api_v1_admin_errors_stats_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Start date for statistics","title":"Start Date"},"description":"Start date for statistics"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"End date for statistics","title":"End Date"},"description":"End date for statistics"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogStats"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/errors/logs/old":{"delete":{"tags":["error-logs"],"summary":"Delete old error logs","description":"Delete error logs older than specified days","operationId":"delete_old_logs_api_v1_admin_errors_logs_old_delete","parameters":[{"name":"days_old","in":"query","required":false,"schema":{"type":"integer","maximum":365,"minimum":1,"description":"Delete logs older than this many days","default":30,"title":"Days Old"},"description":"Delete logs older than this many days"},{"name":"only_resolved","in":"query","required":false,"schema":{"type":"boolean","description":"Only delete resolved errors","default":true,"title":"Only Resolved"},"description":"Only delete resolved errors"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/requests/logs":{"get":{"tags":["request-logs"],"summary":"Get request logs","description":"Retrieve request logs with filtering and pagination options.\n \n **Filters:**\n - Date range (start_date, end_date)\n - HTTP method\n - Status code range\n - Endpoint pattern\n - Response time range\n \n **Sorting:**\n - By date (newest first by default)\n - By status code\n - By response time\n \n **Pagination:**\n - Configurable page size (default: 50, max: 200)\n - Page-based navigation","operationId":"get_request_logs_api_v1_admin_requests_logs_get","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number","default":1,"title":"Page"},"description":"Page number"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"description":"Items per page","default":50,"title":"Page Size"},"description":"Items per page"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter by start date","title":"Start Date"},"description":"Filter by start date"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter by end date","title":"End Date"},"description":"Filter by end date"},{"name":"method","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by HTTP method","title":"Method"},"description":"Filter by HTTP method"},{"name":"status_code","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by status code","title":"Status Code"},"description":"Filter by status code"},{"name":"endpoint","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by endpoint (supports wildcards)","title":"Endpoint"},"description":"Filter by endpoint (supports wildcards)"},{"name":"min_response_time","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Minimum response time in ms","title":"Min Response Time"},"description":"Minimum response time in ms"},{"name":"max_response_time","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Maximum response time in ms","title":"Max Response Time"},"description":"Maximum response time in ms"},{"name":"sort_by","in":"query","required":false,"schema":{"type":"string","description":"Sort field: created_at, status_code, response_time_ms","default":"created_at","title":"Sort By"},"description":"Sort field: created_at, status_code, response_time_ms"},{"name":"sort_order","in":"query","required":false,"schema":{"type":"string","description":"Sort order: asc or desc","default":"desc","title":"Sort Order"},"description":"Sort order: asc or desc"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestLogListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["request-logs"],"summary":"Delete all request logs","description":"Delete all request logs (use with caution)","operationId":"delete_all_request_logs_api_v1_admin_requests_logs_delete","parameters":[{"name":"confirm","in":"query","required":false,"schema":{"type":"boolean","description":"Must be true to confirm deletion","default":false,"title":"Confirm"},"description":"Must be true to confirm deletion"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/requests/logs/{log_id}":{"get":{"tags":["request-logs"],"summary":"Get request log by ID","description":"Retrieve detailed information about a specific request log","operationId":"get_request_log_api_v1_admin_requests_logs__log_id__get","parameters":[{"name":"log_id","in":"path","required":true,"schema":{"type":"integer","title":"Log Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/requests/stats":{"get":{"tags":["request-logs"],"summary":"Get request statistics","description":"Get aggregated statistics about API requests.\n \n **Statistics include:**\n - Total request count\n - Success/error rates\n - Requests by method\n - Requests by status code\n - Requests by endpoint\n - Time-based trends\n - Average response time","operationId":"get_request_stats_api_v1_admin_requests_stats_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Start date for statistics","title":"Start Date"},"description":"Start date for statistics"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"End date for statistics","title":"End Date"},"description":"End date for statistics"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestLogStats"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/requests/logs/old":{"delete":{"tags":["request-logs"],"summary":"Delete old request logs","description":"Delete request logs older than specified days","operationId":"delete_old_request_logs_api_v1_admin_requests_logs_old_delete","parameters":[{"name":"days_old","in":"query","required":false,"schema":{"type":"integer","maximum":365,"minimum":1,"description":"Delete logs older than this many days","default":30,"title":"Days Old"},"description":"Delete logs older than this many days"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/alpaca/status":{"get":{"tags":["alpaca"],"summary":"Alpaca connection status","description":"Check Alpaca API key validity and connection health.","operationId":"alpaca_status_api_v1_alpaca_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/alpaca/intraday":{"get":{"tags":["alpaca"],"summary":"Get historical intraday bars for multiple tickers (SIP feed, DB-backed)","description":"멀티 종목 과거 분봉 데이터를 Alpaca **SIP 피드**로 가져옵니다. DB에 저장되며 재요청 시 Alpaca 미호출.\n\n**⚠️ 어제(yesterday)까지만 조회 가능** — 당일 데이터는 `/intraday/today` 사용\n\n| 항목 | 내용 |\n|------|------|\n| 피드 | **SIP** (전체 미국 거래소 통합) |\n| 거래량 | **100%** 정확 |\n| 조회 범위 | **2016년~어제** |\n| DB 저장 | 있음 (재요청 시 Alpaca 미사용) |\n\n**권장 용도**: 백테스트, 과거 분봉 분석\n\n- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n- 내부 100개 단위 자동 배치 분할 (500종목 → Alpaca 5회 호출)\n- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`","operationId":"get_alpaca_intraday_multi_api_v1_alpaca_intraday_get","parameters":[{"name":"tickers","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B","title":"Tickers"},"description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Interval: 1m, 5m, 15m, 30m, 1h","default":"5m","title":"Interval"},"description":"Interval: 1m, 5m, 15m, 30m, 1h"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Start date (YYYY-MM-DD). Default: yesterday","title":"Start Date"},"description":"Start date (YYYY-MM-DD). Default: yesterday"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"End date (YYYY-MM-DD). Must be before today. Default: yesterday","title":"End Date"},"description":"End date (YYYY-MM-DD). Must be before today. Default: yesterday"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Re-fetch from Alpaca even if DB has data","default":false,"title":"Force Refresh"},"description":"Re-fetch from Alpaca even if DB has data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiBarsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/alpaca/intraday/today":{"get":{"tags":["alpaca"],"summary":"Get today's real-time intraday bars for multiple tickers (IEX feed, DB-backed)","description":"당일(오늘) 실시간 분봉 데이터를 Alpaca **IEX 피드**로 가져옵니다. 장 중 재요청 시 항상 Alpaca에서 최신 데이터를 가져옵니다.\n\n**⚠️ 오늘 데이터만 조회 가능** — 과거 데이터는 `/intraday` 사용\n\n| 항목 | 내용 |\n|------|------|\n| 피드 | **IEX** (IEX 거래소 단일) |\n| 지연 | **실시간** (지연 없음) |\n| 거래량 | 실제의 약 **2~5%** (IEX 거래소 거래만 집계) |\n| High/Low range | SIP 대비 좁게 표시될 수 있음 |\n| DB 저장 | 있음 (장 중 항상 재조회) |\n\n**권장 용도**: 당일 ORB 전략, 실시간 장 중 모니터링\n\n- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n- 내부 100개 단위 자동 배치 분할\n- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`","operationId":"get_alpaca_intraday_today_api_v1_alpaca_intraday_today_get","parameters":[{"name":"tickers","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B","title":"Tickers"},"description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Interval: 1m, 5m, 15m, 30m, 1h","default":"5m","title":"Interval"},"description":"Interval: 1m, 5m, 15m, 30m, 1h"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiBarsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/alpaca/snapshot":{"get":{"tags":["alpaca"],"summary":"Real-time snapshots for multiple tickers (IEX feed)","description":"멀티 종목 실시간 스냅샷. 최신 체결가, bid/ask, 당일 OHLCV, 전일 대비 변동률 포함.\n\n단일 종목도 `?tickers=AAPL`로 조회 가능.\n\n| 항목 | 내용 |\n|------|------|\n| 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 |\n| 지연 | **실시간** (지연 없음) |\n| 거래량 | IEX 기준 (실제의 2~5%) |\n| 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 |\n\n- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`","operationId":"get_snapshots_api_v1_alpaca_snapshot_get","parameters":[{"name":"tickers","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated ticker symbols, e.g. AAPL,MSFT,NVDA","title":"Tickers"},"description":"Comma-separated ticker symbols, e.g. AAPL,MSFT,NVDA"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiSnapshotResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/finra/short-volume/{symbol}":{"get":{"tags":["finra"],"summary":"Get short volume data for a symbol","description":"Query FINRA RegSHO short sale volume. Auto-ingests if data is missing.\n\n**DB 보유**: 2021년 ~ 현재 (5년치 백필 완료). 추가 백필: `POST /finra/admin/ingest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD`\n\n**데이터 소스**: FINRA RegSHO CDN (공개, API 키 불필요). 주말/공휴일 데이터 없음.","operationId":"get_short_volume_api_v1_finra_short_volume__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":3650,"minimum":1,"description":"Number of days to look back (max ~10 years)","default":30,"title":"Days"},"description":"Number of days to look back (max ~10 years)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":10000,"minimum":1,"description":"Max entries to return","default":100,"title":"Limit"},"description":"Max entries to return"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShortVolumeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/finra/short-ratio/{symbol}":{"get":{"tags":["finra"],"summary":"Get short ratio history for a symbol","description":"Return daily short_ratio (aggregated across markets) for the last N days.\n\n**DB 보유**: 2021년 ~ 현재 (5년치). days 최대 3650 (10년).\n\n추가 백필: `POST /finra/admin/ingest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD`","operationId":"get_short_ratio_api_v1_finra_short_ratio__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":3650,"minimum":1,"description":"Number of days (max ~10 years)","default":60,"title":"Days"},"description":"Number of days (max ~10 years)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShortRatioHistoryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/finra/admin/ingest":{"post":{"tags":["finra"],"summary":"Manually ingest FINRA short volume data","description":"Download and ingest FINRA short volume file(s) for a specific date or date range.\n\n**백필 예시**:\n- 단일 날짜: `?date=2025-01-15`\n- 날짜 범위: `?start_date=2025-01-01&end_date=2025-12-31`\n- 이미 있는 데이터 재인제스트: `?start_date=...&end_date=...&force=true`\n\n주말/공휴일은 자동으로 건너뜀. 1년치 기준 약 20-40분 소요.","operationId":"ingest_short_volume_api_v1_finra_admin_ingest_post","parameters":[{"name":"date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Single date (YYYY-MM-DD)","title":"Date"},"description":"Single date (YYYY-MM-DD)"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Range start (YYYY-MM-DD)","title":"Start Date"},"description":"Range start (YYYY-MM-DD)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Range end (YYYY-MM-DD)","title":"End Date"},"description":"Range end (YYYY-MM-DD)"},{"name":"force","in":"query","required":false,"schema":{"type":"boolean","description":"Re-ingest even if data exists","default":false,"title":"Force"},"description":"Re-ingest even if data exists"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/bulk":{"get":{"tags":["overlay"],"summary":"Bulk overlay scores","description":"Comma-separated symbols (max 50). Returns overlay scores for each.","operationId":"get_bulk_overlay_api_v1_overlay_bulk_get","parameters":[{"name":"symbols","in":"query","required":true,"schema":{"type":"string","title":"Symbols"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkOverlayResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/top-movers":{"get":{"tags":["overlay"],"summary":"Top overlay movers","description":"Symbols with highest overlay scores in the last 24 hours.","operationId":"get_top_movers_api_v1_overlay_top_movers_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopMoversResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/admin/health":{"get":{"tags":["overlay","overlay-admin"],"summary":"Overlay system health","operationId":"admin_health_api_v1_overlay_admin_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdminHealthResponse"}}}}}}},"/api/v1/overlay/admin/trigger-pipeline":{"post":{"tags":["overlay","overlay-admin"],"summary":"Trigger overlay pipeline manually","operationId":"trigger_pipeline_api_v1_overlay_admin_trigger_pipeline_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerPipelineResponse"}}}}}}},"/api/v1/overlay/admin/seed-topics":{"post":{"tags":["overlay","overlay-admin"],"summary":"Seed ThemeTopicMap with default topic mappings","description":"Create default ThemeTopicMap entries for all TOP_50_SYMBOLS (safe to re-run; skips existing).","operationId":"seed_topics_api_v1_overlay_admin_seed_topics_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/overlay/admin/job-log":{"get":{"tags":["overlay","overlay-admin"],"summary":"Overlay job log","operationId":"get_job_log_api_v1_overlay_admin_job_log_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/{symbol}":{"get":{"tags":["overlay"],"summary":"Overlay score for a symbol","description":"Returns attention overlay score, z-scored features, and source details.","operationId":"get_overlay_score_api_v1_overlay__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and trigger on-demand rebuild","default":false,"title":"Force Refresh"},"description":"Bypass cache and trigger on-demand rebuild"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OverlayScoreResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/{symbol}/headlines":{"get":{"tags":["overlay"],"summary":"Recent headlines for a symbol","operationId":"get_headlines_api_v1_overlay__symbol__headlines_get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"hours","in":"query","required":false,"schema":{"type":"integer","maximum":168,"minimum":1,"default":24,"title":"Hours"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HeadlinesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/{symbol}/youtube":{"get":{"tags":["overlay"],"summary":"YouTube mentions for a symbol","operationId":"get_youtube_api_v1_overlay__symbol__youtube_get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/YouTubeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/{symbol}/wiki":{"get":{"tags":["overlay"],"summary":"Wikipedia pageview time series for a symbol","operationId":"get_wiki_api_v1_overlay__symbol__wiki_get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":90,"minimum":1,"default":30,"title":"Days"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WikiResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/{symbol}/crowding":{"get":{"tags":["overlay"],"summary":"FINRA crowding metrics for a symbol","operationId":"get_crowding_api_v1_overlay__symbol__crowding_get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CrowdingResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/{symbol}/trends":{"get":{"tags":["overlay"],"summary":"Google Trends data for a symbol","operationId":"get_trends_api_v1_overlay__symbol__trends_get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TrendsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/{symbol}/history":{"get":{"tags":["overlay"],"summary":"Overlay score history for a symbol","description":"Time-series of overlay scores (useful for backtesting).","operationId":"get_history_api_v1_overlay__symbol__history_get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":365,"minimum":1,"default":30,"title":"Days"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OverlayHistoryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/screener/stocks":{"get":{"tags":["screener"],"summary":"Screen stocks by financial criteria","description":"Screen stocks based on financial criteria using yfinance.\n\nFilters stocks from US exchanges (NYSE, NASDAQ, AMEX, NYSE_ARCA) by market cap,\nvolume, price, P/E ratio, sector, and more. Results are paginated and cached for\n5 minutes.\n\n**Exchange mapping**:\n- `NYSE` → NYQ\n- `NASDAQ` → NMS, NGM, NCM\n- `AMEX` → ASE\n- `NYSE_ARCA` → PCX\n\n**Important limitations**:\n- `page_size` maximum is 250 (Yahoo Finance API limit)\n- `sector` filtering works but sector is NOT returned per-stock in the response\n- Results reflect real-time Yahoo Finance data\n\n**Example**:\n```\nGET /screener/stocks?market_cap_min=500000000&market_cap_max=10000000000\n &exchange=NYSE,NASDAQ&min_avg_volume=500000&exclude_types=ETF,FUND\n &sort_by=market_cap&page=1&page_size=100\n```","operationId":"screen_stocks_api_v1_screener_stocks_get","parameters":[{"name":"market_cap_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Minimum market cap in USD (e.g. 500000000 for $500M)","title":"Market Cap Min"},"description":"Minimum market cap in USD (e.g. 500000000 for $500M)"},{"name":"market_cap_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Maximum market cap in USD (e.g. 10000000000 for $10B)","title":"Market Cap Max"},"description":"Maximum market cap in USD (e.g. 10000000000 for $10B)"},{"name":"exchange","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated exchange names: NYSE, NASDAQ, AMEX, NYSE_ARCA. Omit for all US exchanges.","title":"Exchange"},"description":"Comma-separated exchange names: NYSE, NASDAQ, AMEX, NYSE_ARCA. Omit for all US exchanges."},{"name":"min_avg_volume","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":0},{"type":"null"}],"description":"Minimum 3-month average daily volume (e.g. 500000)","title":"Min Avg Volume"},"description":"Minimum 3-month average daily volume (e.g. 500000)"},{"name":"exclude_types","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated quote types to exclude (e.g. ETF,FUND). Only EQUITY results are kept when specified.","title":"Exclude Types"},"description":"Comma-separated quote types to exclude (e.g. ETF,FUND). Only EQUITY results are kept when specified."},{"name":"sector","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by sector (e.g. Technology, Healthcare, 'Financial Services'). Note: sector is not returned per-stock in the response.","title":"Sector"},"description":"Filter by sector (e.g. Technology, Healthcare, 'Financial Services'). Note: sector is not returned per-stock in the response."},{"name":"pe_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Minimum trailing P/E ratio","title":"Pe Min"},"description":"Minimum trailing P/E ratio"},{"name":"pe_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Maximum trailing P/E ratio","title":"Pe Max"},"description":"Maximum trailing P/E ratio"},{"name":"price_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Minimum stock price in USD","title":"Price Min"},"description":"Minimum stock price in USD"},{"name":"price_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Maximum stock price in USD","title":"Price Max"},"description":"Maximum stock price in USD"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number (1-based)","default":1,"title":"Page"},"description":"Page number (1-based)"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":250,"minimum":1,"description":"Results per page (max 250, Yahoo API limit)","default":100,"title":"Page Size"},"description":"Results per page (max 250, Yahoo API limit)"},{"name":"sort_by","in":"query","required":false,"schema":{"type":"string","description":"Sort field: market_cap, volume, avg_volume, price, pe_ratio, change_percent, name, eps, dividend_yield, forward_pe, price_to_book","default":"market_cap","title":"Sort By"},"description":"Sort field: market_cap, volume, avg_volume, price, pe_ratio, change_percent, name, eps, dividend_yield, forward_pe, price_to_book"},{"name":"sort_ascending","in":"query","required":false,"schema":{"type":"boolean","description":"Sort ascending (default: descending)","default":false,"title":"Sort Ascending"},"description":"Sort ascending (default: descending)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and fetch fresh data","default":false,"title":"Force Refresh"},"description":"Bypass cache and fetch fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/screener/fields":{"get":{"tags":["screener"],"summary":"Available screener filter options and valid values","description":"Return metadata about available screener filter options.\n\nUseful for building dynamic filter UIs — lists all valid exchange names,\nsectors, sort fields, and parameter descriptions.","operationId":"get_screener_fields_api_v1_screener_fields_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/attention/admin/resolve/{ticker}":{"post":{"tags":["attention","attention-admin"],"summary":"Resolve ticker → canonical entity","description":"Maps a ticker symbol to a canonical company entity by looking up the company name, normalizing it, and validating against Wikipedia. Stores the result (canonical name, wiki_title, gdelt_query) in `company_entity_map`.\n\nSkips re-resolution if `is_manual_override` is set. If the company name in the DB is a placeholder (e.g. 'AMZN Corporation'), falls back to SEC company_tickers.json to fetch the real name and updates the DB.","operationId":"admin_resolve_entity_api_v1_attention_admin_resolve__ticker__post","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/attention/admin/collect/wiki/{ticker}":{"post":{"tags":["attention","attention-admin"],"summary":"Collect Wikipedia pageviews for an event date","description":"Fetches daily Wikipedia pageview counts for the ticker's canonical wiki_title, covering `event_date` and enough lookback days (≥20) to compute spike and z-score. Safe to call on-demand — Wikipedia API has no meaningful rate limit for this use.\n\nRequires entity resolution to have been run first (`wiki_title` must be set).","operationId":"admin_collect_wiki_api_v1_attention_admin_collect_wiki__ticker__post","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"event_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Event date in YYYY-MM-DD format","title":"Event Date"},"description":"Event date in YYYY-MM-DD format"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionStatusResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/attention/admin/collect/gdelt/{ticker}":{"post":{"tags":["attention","attention-admin"],"summary":"Collect GDELT news articles for an event date","description":"Fetches news articles from GDELT V2 DOC API for the window `event_date ± 1 day`.\n\n**Coverage**: 2017-01-01 onwards. Requests for earlier dates return 0 immediately.\n\n**Rate limit**: GDELT enforces a global per-IP quota. This endpoint is protected by a process-wide lock (10s minimum interval) and retries with exponential backoff (30s → 60s → 120s) on 429 responses.\n\n⚠️ **Call this endpoint from a scheduler only** — never trigger it in response to user requests. Concurrent or rapid calls will exhaust the IP quota and cause temporary bans. The main `/event/{ticker}` endpoint intentionally does NOT collect GDELT on-demand for this reason.","operationId":"admin_collect_gdelt_api_v1_attention_admin_collect_gdelt__ticker__post","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"event_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Event date in YYYY-MM-DD format","title":"Event Date"},"description":"Event date in YYYY-MM-DD format"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionStatusResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/attention/entity/{ticker}":{"get":{"tags":["attention"],"summary":"Get entity mapping for a ticker","description":"Returns the stored entity mapping for a ticker: canonical name, Wikipedia title,\n GDELT query string, and resolver confidence score.\n\n Returns **404** if no mapping exists — run `POST /admin/resolve/{ticker}` first.\n\n **Example**: `GET /attention/entity/AAPL`","operationId":"get_entity_api_v1_attention_entity__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/attention/event/{ticker}":{"get":{"tags":["attention"],"summary":"Get attention features for a ticker on an event date","description":"Returns Wikipedia pageview spike/z-score and GDELT news volume for a ticker\n centered on a specific event date. Designed for event-driven backtesting.\n\n **Wikipedia signals** (collected on-demand):\n - `wiki.views` — raw pageview count on `event_date`\n - `wiki.spike_10d` — views / 10-day median baseline; >1 = above-average interest\n - `wiki.zscore_20d` — standard-deviation units above 20-day mean\n\n **GDELT news signals** (pre-populated by scheduler only):\n - `news.article_count_1d` — articles published on `event_date`\n - `news.article_count_3d` — articles in `event_date ± 1 day` window\n - `news.unique_domains_3d` — distinct publisher domains in that window\n - `news.gdelt_status` — data availability flag:\n - `collected` — scheduler ran; counts are accurate (0 = genuinely no articles)\n - `not_collected` — scheduler has not run yet; use `POST /admin/collect/gdelt/{ticker}`\n - `not_available` — event date is before GDELT V2 coverage (2017-01-01)\n\n **Auto-resolution**: if no entity mapping exists, resolution runs automatically first.\n\n **Examples**:\n - `GET /attention/event/AAPL?event_date=2024-02-01` — Q1 earnings day attention\n - `GET /attention/event/NVDA?event_date=2024-05-22` — post-earnings spike","operationId":"get_event_attention_api_v1_attention_event__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"event_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Event date in YYYY-MM-DD format","title":"Event Date"},"description":"Event date in YYYY-MM-DD format"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventAttentionResponse"}}}},"404":{"description":"Ticker not found or entity resolution failed"},"500":{"description":"Feature materialization or collection error"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/insider/transactions/{symbol}":{"get":{"tags":["insider"],"summary":"Get insider transactions for a symbol","description":"Query SEC Form 4 insider trading data. Auto-fetches from SEC EDGAR if data is missing.\n\n**데이터 소스**: SEC EDGAR (무료, API 키 불필요). 첫 조회 시 자동 인덱싱.\n\n**Transaction codes**: P=Purchase, S=Sale, A=Award, M=Exercise, G=Gift, F=Tax Withholding","operationId":"get_insider_transactions_api_v1_insider_transactions__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":3650,"minimum":1,"description":"Days to look back (max ~10 years)","default":90,"title":"Days"},"description":"Days to look back (max ~10 years)"},{"name":"transaction_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter: P=Purchase, S=Sale, A=Award, M=Exercise","title":"Transaction Type"},"description":"Filter: P=Purchase, S=Sale, A=Award, M=Exercise"},{"name":"insider_title","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by title keyword (e.g., CEO, CFO, Director)","title":"Insider Title"},"description":"Filter by title keyword (e.g., CEO, CFO, Director)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"description":"Max entries to return","default":50,"title":"Limit"},"description":"Max entries to return"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and re-fetch from SEC","default":false,"title":"Force Refresh"},"description":"Bypass cache and re-fetch from SEC"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsiderTransactionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/insider/summary/{symbol}":{"get":{"tags":["insider"],"summary":"Get insider trading summary","description":"Aggregated insider buy/sell activity for 3, 6, and 12 month periods.\n\nIncludes net buy/sell shares and values, plus top 5 notable transactions by value.","operationId":"get_insider_summary_api_v1_insider_summary__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsiderSummaryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/earnings/calendar/{symbol}":{"get":{"tags":["earnings"],"summary":"Get upcoming earnings dates for a symbol","description":"Upcoming earnings announcement dates with EPS estimates.\n\n**데이터 소스**: yfinance-plus (`Ticker.earnings_dates`). API 키 불필요.\n**earnings_time**: `pre_market` / `post_market` / `during_market` / `unknown`.\n\n**PIT (Point-in-Time) backtesting**: `as_of_date`를 지정하면 해당 날짜 기준 upcoming earnings를 반환합니다. 이미 보고된 earnings도 당시엔 예정이었으므로 `reported_eps`가 채워진 상태로 반환됩니다.\n\n**Note**: Revenue estimates are not available from this source.","operationId":"get_earnings_calendar_api_v1_earnings_calendar__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days_ahead","in":"query","required":false,"schema":{"type":"integer","maximum":365,"minimum":1,"description":"Days to look ahead from as_of_date (or today)","default":30,"title":"Days Ahead"},"description":"Days to look ahead from as_of_date (or today)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":20,"minimum":1,"description":"Max earnings dates to return","default":4,"title":"Limit"},"description":"Max earnings dates to return"},{"name":"as_of_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"PIT date for backtesting (YYYY-MM-DD). Defaults to today.","title":"As Of Date"},"description":"PIT date for backtesting (YYYY-MM-DD). Defaults to today."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and re-fetch from yfinance","default":false,"title":"Force Refresh"},"description":"Bypass cache and re-fetch from yfinance"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarningsCalendarResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/earnings/calendar/bulk":{"post":{"tags":["earnings"],"summary":"Bulk future earnings calendar","description":"Fetch upcoming earnings dates for multiple symbols (max 50).\n\nReturns a flat list of calendar entries sorted by `earnings_date` ascending.\nUseful for checking upcoming earnings of sector peers or candidates.","operationId":"get_bulk_earnings_calendar_api_v1_earnings_calendar_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkEarningsCalendarRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkEarningsCalendarResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/earnings/surprise/{symbol}":{"get":{"tags":["earnings"],"summary":"Get earnings surprise history","description":"Quarterly EPS surprise: reported vs analyst consensus estimate.\n\n**데이터 소스**: yfinance-plus (`Ticker.earnings_dates`). API 키 불필요.\n**커버리지**: ~25분기 (6년+). 첫 조회 시 자동 인덱싱.\n\n**surprise** = reported_eps - estimated_eps.\n**surprise_percentage** = (surprise / estimated) × 100.\n**streak**: 연속 beat (양수) 또는 miss (음수) 횟수.","operationId":"get_earnings_surprise_api_v1_earnings_surprise__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"quarters","in":"query","required":false,"schema":{"type":"integer","maximum":40,"minimum":1,"description":"Number of recent quarters (max ~25 available)","default":8,"title":"Quarters"},"description":"Number of recent quarters (max ~25 available)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and re-fetch from yfinance","default":false,"title":"Force Refresh"},"description":"Bypass cache and re-fetch from yfinance"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarningsSurpriseResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/universe/screen":{"get":{"tags":["universe"],"summary":"Screen stocks at a historical date","description":"Query monthly market_cap snapshots to find stocks matching criteria at a past date.\\n\\n**용도**: 백테스팅 전략 유니버스 구성 — 특정 시점 시총/섹터 기준 종목 필터링.\\n\\n**데이터 소스**: SEC EDGAR shares_outstanding × yfinance monthly close.\\n**제한**: 현재 상장 종목만 포함 (survivorship bias). 상폐 종목 미포함.\\n\\n**사전 조건**: `/universe/admin/discover` 후 `/universe/admin/build-snapshots` 실행 필요.","operationId":"screen_historical_api_v1_universe_screen_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Historical date YYYY-MM-DD (rounded to month start)","title":"Date"},"description":"Historical date YYYY-MM-DD (rounded to month start)"},{"name":"market_cap_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Min market cap (USD), e.g. 2e9","title":"Market Cap Min"},"description":"Min market cap (USD), e.g. 2e9"},{"name":"market_cap_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Max market cap (USD), e.g. 20e9","title":"Market Cap Max"},"description":"Max market cap (USD), e.g. 20e9"},{"name":"sector","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sector filter (e.g. Technology, Healthcare)","title":"Sector"},"description":"Sector filter (e.g. Technology, Healthcare)"},{"name":"exchange","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Exchange filter (NYSE, NASDAQ, AMEX)","title":"Exchange"},"description":"Exchange filter (NYSE, NASDAQ, AMEX)"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number","default":1,"title":"Page"},"description":"Page number"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"description":"Results per page","default":100,"title":"Page Size"},"description":"Results per page"},{"name":"sort_by","in":"query","required":false,"schema":{"type":"string","description":"Sort field: market_cap or ticker","default":"market_cap","title":"Sort By"},"description":"Sort field: market_cap or ticker"},{"name":"sort_ascending","in":"query","required":false,"schema":{"type":"boolean","description":"Sort direction","default":false,"title":"Sort Ascending"},"description":"Sort direction"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UniverseScreenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/universe/registry":{"get":{"tags":["universe"],"summary":"Browse registered ticker universe","description":"List tickers registered in the universe (populated via /admin/discover).","operationId":"get_registry_api_v1_universe_registry_get","parameters":[{"name":"sector","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by sector","title":"Sector"},"description":"Filter by sector"},{"name":"exchange","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by exchange","title":"Exchange"},"description":"Filter by exchange"},{"name":"is_active","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Filter by active status","title":"Is Active"},"description":"Filter by active status"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"default":100,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegistryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/universe/admin/discover":{"post":{"tags":["universe"],"summary":"Discover and register US tickers","description":"Scrapes US-listed stocks via yfinance screener and registers them in the universe.\\n\\n**소요 시간**: 약 1~5분 (시총 기준에 따라 다름).\\n**권장**: `market_cap_min=100000000` ($100M) → ~3000~5000 종목.","operationId":"discover_tickers_api_v1_universe_admin_discover_post","parameters":[{"name":"market_cap_min","in":"query","required":false,"schema":{"type":"number","description":"Min market cap for inclusion (USD). Default $100M.","default":100000000.0,"title":"Market Cap Min"},"description":"Min market cap for inclusion (USD). Default $100M."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/universe/admin/build-snapshots":{"post":{"tags":["universe"],"summary":"Build monthly market_cap snapshots","description":"Computes monthly market_cap snapshots for registered tickers and stores them in `universe_snapshot`.\\n\\n**데이터 소스**: SEC EDGAR companyfacts (shares_outstanding) + yfinance monthly close.\\n\\n**소요 시간**: 전체 유니버스(~4000 종목) × 10년 기준 30~60분. 백그라운드에서 실행되므로 응답은 즉시 반환됩니다.\\n\\n**권장 시작점**: `tickers=[AAPL,MSFT,GOOGL]`로 소규모 테스트 후 전체 빌드.","operationId":"build_snapshots_api_v1_universe_admin_build_snapshots_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotBuildRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/dividends/upcoming":{"get":{"tags":["dividends"],"summary":"PIT upcoming ex-dividend calendar","description":"Point-in-Time 배당락 캘린더. `as_of_date` 기준으로 당시 알려져 있었던 배당 일정 중 `from_ex_date` ~ `to_ex_date` 범위의 ex-date를 반환.\n\n**PIT 의미**: 같은 (ticker, ex_date)에 여러 revision이 있으면 `as_of_date <= query_as_of_date` 조건 내에서 가장 최신 revision만 반환.\n\n**데이터 소스**: yfinance-plus. API 키 불필요. symbols 파라미터 없이 조회 시 이미 인덱싱된 종목 전체 반환.\n\n**백필**: `POST /dividends/admin/ingest` 로 원하는 종목 선인덱싱 가능.","operationId":"get_upcoming_dividends_api_v1_dividends_upcoming_get","parameters":[{"name":"as_of_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"PIT 기준일 (YYYY-MM-DD). 생략 시 오늘.","title":"As Of Date"},"description":"PIT 기준일 (YYYY-MM-DD). 생략 시 오늘."},{"name":"from_ex_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Ex-date 시작 (YYYY-MM-DD). 생략 시 오늘.","title":"From Ex Date"},"description":"Ex-date 시작 (YYYY-MM-DD). 생략 시 오늘."},{"name":"to_ex_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Ex-date 끝 (YYYY-MM-DD). 생략 시 오늘 + 60일.","title":"To Ex Date"},"description":"Ex-date 끝 (YYYY-MM-DD). 생략 시 오늘 + 60일."},{"name":"symbols","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"종목 필터 (e.g. ?symbols=AAPL&symbols=MSFT). 생략 시 전체.","title":"Symbols"},"description":"종목 필터 (e.g. ?symbols=AAPL&symbols=MSFT). 생략 시 전체."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":5000,"minimum":1,"description":"최대 반환 개수","default":500,"title":"Limit"},"description":"최대 반환 개수"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"캐시 무시","default":false,"title":"Force Refresh"},"description":"캐시 무시"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DividendUpcomingResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/dividends/history/{symbol}":{"get":{"tags":["dividends"],"summary":"종목별 배당 이력","description":"단일 종목의 전체 배당 이력. yfinance 데이터가 없으면 자동 인덱싱.\n\n각 ex-date별 최신 revision을 반환 (ex-date 내림차순).\n\n`annual_yield_estimate`: 최근 12개월 배당 합산액 (주가 대비 yield는 클라이언트 계산 필요).","operationId":"get_dividend_history_api_v1_dividends_history__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"description":"최대 반환 개수","default":100,"title":"Limit"},"description":"최대 반환 개수"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"캐시 무시 + yfinance 재조회","default":false,"title":"Force Refresh"},"description":"캐시 무시 + yfinance 재조회"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DividendHistoryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/dividends/admin/ingest":{"post":{"tags":["dividends"],"summary":"배당 데이터 벌크 인제스트","description":"yfinance에서 지정 종목 배당 이력을 가져와 DB에 저장.\n\n**예시**:\n- `{\"symbols\": [\"AAPL\", \"MSFT\", \"JNJ\"]}` — 신규 종목 인덱싱\n- `{\"symbols\": [...], \"force_refresh\": true}` — 기존 데이터 재인제스트\n\n종목당 약 25년치 이력. 100종목 기준 5~10분 소요 (yfinance rate limit).\n\n이미 인덱싱된 종목은 `force_refresh: false`일 때 건너뜀 (멱등성).","operationId":"ingest_dividends_api_v1_dividends_admin_ingest_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DividendIngestRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DividendIngestResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AdminHealthResponse":{"properties":{"overlay_enabled":{"type":"boolean","title":"Overlay Enabled"},"sources":{"items":{"$ref":"#/components/schemas/SourceHealthItem"},"type":"array","title":"Sources"},"last_pipeline_run":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Pipeline Run"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["overlay_enabled","sources"],"title":"AdminHealthResponse"},"AlpacaMultiBarsResponse":{"properties":{"source":{"type":"string","title":"Source","default":"ALPACA"},"interval":{"type":"string","title":"Interval"},"count":{"type":"integer","title":"Count"},"bars":{"additionalProperties":{"items":{"additionalProperties":true,"type":"object"},"type":"array"},"type":"object","title":"Bars"}},"type":"object","required":["interval","count","bars"],"title":"AlpacaMultiBarsResponse","description":"Multi-ticker OHLCV bars from Alpaca (daily or intraday).\n\n``bars`` maps each ticker (using the original input symbol, e.g. BF-B)\nto a list of bar dicts. Daily bars include a ``date`` field; intraday\nbars include a ``timestamp`` field."},"AlpacaMultiSnapshotResponse":{"properties":{"source":{"type":"string","title":"Source","default":"ALPACA"},"count":{"type":"integer","title":"Count"},"snapshots":{"items":{"$ref":"#/components/schemas/AlpacaSnapshotResponse"},"type":"array","title":"Snapshots"}},"type":"object","required":["count","snapshots"],"title":"AlpacaMultiSnapshotResponse","description":"Real-time snapshots for multiple tickers."},"AlpacaSnapshotResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"source":{"type":"string","title":"Source","default":"ALPACA"},"timestamp":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Timestamp"},"price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price"},"trade_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Trade Size"},"bid":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Bid"},"ask":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Ask"},"bid_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Bid Size"},"ask_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ask Size"},"open":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Open"},"high":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"High"},"low":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Low"},"volume":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Volume"},"vwap":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Vwap"},"prev_close":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Prev Close"},"change":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Change"},"change_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Change Pct"}},"type":"object","required":["ticker"],"title":"AlpacaSnapshotResponse","description":"Real-time snapshot for a single ticker via Alpaca."},"BulkEarningsCalendarRequest":{"properties":{"symbols":{"items":{"type":"string"},"type":"array","maxItems":50,"minItems":1,"title":"Symbols"},"days_ahead":{"type":"integer","maximum":365.0,"minimum":1.0,"title":"Days Ahead","default":30},"limit":{"type":"integer","maximum":20.0,"minimum":1.0,"title":"Limit","default":4},"as_of_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"As Of Date","description":"PIT date for backtesting (YYYY-MM-DD). Defaults to today."}},"type":"object","required":["symbols"],"title":"BulkEarningsCalendarRequest"},"BulkEarningsCalendarResponse":{"properties":{"entries":{"items":{"$ref":"#/components/schemas/EarningsCalendarEntry"},"type":"array","title":"Entries"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["entries"],"title":"BulkEarningsCalendarResponse"},"BulkExhibitItem":{"properties":{"accession_number":{"type":"string","title":"Accession Number"},"exhibit_type":{"type":"string","title":"Exhibit Type"},"success":{"type":"boolean","title":"Success"},"content":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content"},"content_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Type"},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["accession_number","exhibit_type","success"],"title":"BulkExhibitItem"},"BulkExhibitRequest":{"properties":{"items":{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array","maxItems":50,"minItems":1,"title":"Items"}},"type":"object","required":["items"],"title":"BulkExhibitRequest","example":{"items":[{"accession_number":"0000320193-24-000006","exhibit_type":"EX-99.1"},{"accession_number":"0001045810-24-000010","exhibit_type":"EX-99.1"}]}},"BulkExhibitResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/BulkExhibitItem"},"type":"array","title":"Results"},"total_items":{"type":"integer","title":"Total Items"},"successful_count":{"type":"integer","title":"Successful Count"},"failed_count":{"type":"integer","title":"Failed Count"},"query_time_seconds":{"type":"number","title":"Query Time Seconds"}},"type":"object","required":["results","total_items","successful_count","failed_count","query_time_seconds"],"title":"BulkExhibitResponse"},"BulkFilingSearchItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"success":{"type":"boolean","title":"Success"},"filings":{"items":{"$ref":"#/components/schemas/FilingSummary"},"type":"array","title":"Filings","default":[]},"total_count":{"type":"integer","title":"Total Count","default":0},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["ticker","success"],"title":"BulkFilingSearchItem"},"BulkFilingSearchRequest":{"properties":{"tickers":{"items":{"type":"string"},"type":"array","maxItems":200,"minItems":1,"title":"Tickers"},"form_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Form Type"},"start_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Start Date"},"end_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"End Date"},"limit_per_ticker":{"type":"integer","maximum":100.0,"minimum":1.0,"title":"Limit Per Ticker","default":20}},"type":"object","required":["tickers"],"title":"BulkFilingSearchRequest","example":{"end_date":"2024-12-31","form_type":"8-K","limit_per_ticker":5,"start_date":"2024-01-01","tickers":["AAPL","MSFT","NVDA"]}},"BulkFilingSearchResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/BulkFilingSearchItem"},"type":"array","title":"Results"},"total_tickers":{"type":"integer","title":"Total Tickers"},"successful_count":{"type":"integer","title":"Successful Count"},"failed_count":{"type":"integer","title":"Failed Count"},"query_time_seconds":{"type":"number","title":"Query Time Seconds"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["results","total_tickers","successful_count","failed_count","query_time_seconds"],"title":"BulkFilingSearchResponse"},"BulkFinancialDataItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"success":{"type":"boolean","title":"Success"},"data":{"anyOf":[{"$ref":"#/components/schemas/FinancialDataResponse"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["ticker","success"],"title":"BulkFinancialDataItem"},"BulkFinancialDataRequest":{"properties":{"tickers":{"items":{"type":"string"},"type":"array","maxItems":500,"minItems":1,"title":"Tickers","description":"List of stock ticker symbols (max 500 for efficient bulk processing)"},"start_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Start Date","description":"Start date for data retrieval. Cannot be used with quarters or period."},"end_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"End Date","description":"End date for data retrieval. Cannot be used with quarters or period."},"quarters":{"anyOf":[{"items":{"type":"string"},"type":"array","maxItems":40,"minItems":1},{"type":"null"}],"title":"Quarters","description":"List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."},"period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period","description":"Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."},"period_type":{"$ref":"#/components/schemas/PeriodType","description":"Type of financial periods to retrieve","default":"all"},"include_metrics":{"type":"boolean","title":"Include Metrics","description":"Include calculated metrics in response","default":true},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Force refresh data from SEC","default":false}},"type":"object","required":["tickers"],"title":"BulkFinancialDataRequest"},"BulkFinancialDataResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/BulkFinancialDataItem"},"type":"array","title":"Results"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["results"],"title":"BulkFinancialDataResponse"},"BulkOverlayResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/OverlayScoreResponse"},"type":"array","title":"Results"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["results","total_count"],"title":"BulkOverlayResponse"},"BulkParseRequest":{"properties":{"tickers":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tickers"},"limit":{"type":"integer","maximum":1000.0,"minimum":1.0,"title":"Limit","default":100},"force_reparse":{"type":"boolean","title":"Force Reparse","description":"If True, also reparse filings with status succeeded or failed (resets to pending first)","default":false}},"type":"object","title":"BulkParseRequest","example":{"force_reparse":false,"limit":50,"tickers":["AVGO","AAPL"]}},"BulkParseResponse":{"properties":{"succeeded":{"type":"integer","title":"Succeeded"},"failed":{"type":"integer","title":"Failed"},"skipped":{"type":"integer","title":"Skipped"},"total":{"type":"integer","title":"Total"},"query_time_seconds":{"type":"number","title":"Query Time Seconds"}},"type":"object","required":["succeeded","failed","skipped","total","query_time_seconds"],"title":"BulkParseResponse"},"BulkPriceDataItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"success":{"type":"boolean","title":"Success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PriceDataResponse"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["ticker","success"],"title":"BulkPriceDataItem"},"BulkPriceDataRequest":{"properties":{"tickers":{"items":{"type":"string"},"type":"array","maxItems":500,"minItems":1,"title":"Tickers","description":"List of stock ticker symbols (max 500 for efficient bulk processing)"},"start_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Start Date","description":"Start date for data retrieval. Cannot be used with quarters or period."},"end_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"End Date","description":"End date for data retrieval. Cannot be used with quarters or period."},"quarters":{"anyOf":[{"items":{"type":"string"},"type":"array","maxItems":40,"minItems":1},{"type":"null"}],"title":"Quarters","description":"List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."},"period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period","description":"Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."},"interval":{"type":"string","title":"Interval","description":"Data interval: 1d, 1w, 1m, 5d, 1h, etc.","default":"1d"},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Force refresh data from Yahoo Finance","default":false}},"type":"object","required":["tickers"],"title":"BulkPriceDataRequest"},"BulkPriceDataResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/BulkPriceDataItem"},"type":"array","title":"Results"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["results"],"title":"BulkPriceDataResponse"},"CollectionStatusResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"source":{"type":"string","title":"Source"},"records_collected":{"type":"integer","title":"Records Collected"},"date_range":{"additionalProperties":true,"type":"object","title":"Date Range"},"status":{"type":"string","title":"Status"}},"type":"object","required":["ticker","source","records_collected","status"],"title":"CollectionStatusResponse","example":{"date_range":{"event_date":"2024-02-01"},"records_collected":22,"source":"wiki","status":"success","ticker":"AAPL"}},"CompanyInfo":{"properties":{"ticker":{"type":"string","title":"Ticker"},"name":{"type":"string","title":"Name"},"cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cik"},"sector":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sector"},"industry":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Industry"},"business_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Business Description"}},"type":"object","required":["ticker","name"],"title":"CompanyInfo"},"CrowdingResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"short_volume_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Short Volume Ratio"},"short_volume_spike_zscore":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Short Volume Spike Zscore"},"crowding_stress_z":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Crowding Stress Z"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol"],"title":"CrowdingResponse"},"DataCatalogItem":{"properties":{"field_name":{"type":"string","title":"Field Name"},"description":{"type":"string","title":"Description"},"data_type":{"type":"string","title":"Data Type"},"unit":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Unit"},"calculation":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Calculation"},"source":{"type":"string","title":"Source"}},"type":"object","required":["field_name","description","data_type","source"],"title":"DataCatalogItem"},"DataCatalogResponse":{"properties":{"categories":{"additionalProperties":{"items":{"$ref":"#/components/schemas/DataCatalogItem"},"type":"array"},"type":"object","title":"Categories"},"last_updated":{"type":"string","format":"date-time","title":"Last Updated"}},"type":"object","required":["categories","last_updated"],"title":"DataCatalogResponse"},"DividendCalendarEntry":{"properties":{"ticker":{"type":"string","title":"Ticker"},"ex_dividend_date":{"type":"string","format":"date","title":"Ex Dividend Date"},"amount":{"type":"number","title":"Amount"},"declaration_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Declaration Date"},"record_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Record Date"},"payment_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Payment Date"},"currency":{"type":"string","title":"Currency","default":"USD"},"dividend_type":{"type":"string","title":"Dividend Type","default":"regular"},"frequency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Frequency"},"as_of_date":{"type":"string","format":"date","title":"As Of Date"},"source":{"type":"string","title":"Source"}},"type":"object","required":["ticker","ex_dividend_date","amount","as_of_date","source"],"title":"DividendCalendarEntry"},"DividendHistoryResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"dividends":{"items":{"$ref":"#/components/schemas/DividendCalendarEntry"},"type":"array","title":"Dividends"},"total_count":{"type":"integer","title":"Total Count"},"annual_yield_estimate":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Annual Yield Estimate"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","dividends","total_count"],"title":"DividendHistoryResponse","description":"Response for single-symbol dividend history."},"DividendIngestRequest":{"properties":{"symbols":{"items":{"type":"string"},"type":"array","maxItems":200,"minItems":1,"title":"Symbols"},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Re-ingest even if data exists","default":false}},"type":"object","required":["symbols"],"title":"DividendIngestRequest","description":"Request body for bulk backfill ingest."},"DividendIngestResponse":{"properties":{"symbols_processed":{"type":"integer","title":"Symbols Processed"},"total_records_upserted":{"type":"integer","title":"Total Records Upserted"},"failed_symbols":{"items":{"type":"string"},"type":"array","title":"Failed Symbols"},"status":{"type":"string","title":"Status"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbols_processed","total_records_upserted","status"],"title":"DividendIngestResponse","description":"Response for admin ingest endpoint."},"DividendUpcomingResponse":{"properties":{"dividends":{"items":{"$ref":"#/components/schemas/DividendCalendarEntry"},"type":"array","title":"Dividends"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["dividends","total_count"],"title":"DividendUpcomingResponse","description":"Response for PIT upcoming dividends query."},"ETFHoldingsOut":{"properties":{"success":{"type":"boolean","title":"Success"},"ticker":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ticker"},"as_of_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"As Of Date"},"cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cik"},"holdings_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Holdings Count"},"holdings":{"anyOf":[{"items":{},"type":"array"},{"type":"null"}],"title":"Holdings"},"availability":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Availability"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["success"],"title":"ETFHoldingsOut"},"EarningsCalendarEntry":{"properties":{"symbol":{"type":"string","title":"Symbol"},"earnings_date":{"type":"string","format":"date-time","title":"Earnings Date"},"earnings_time":{"type":"string","title":"Earnings Time"},"estimated_eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Estimated Eps"},"reported_eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Reported Eps"},"source":{"type":"string","title":"Source","default":"yfinance"},"fetched_at":{"type":"string","format":"date-time","title":"Fetched At"}},"type":"object","required":["symbol","earnings_date","earnings_time","fetched_at"],"title":"EarningsCalendarEntry"},"EarningsCalendarResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"upcoming_earnings":{"items":{"$ref":"#/components/schemas/EarningsCalendarEntry"},"type":"array","title":"Upcoming Earnings"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","upcoming_earnings"],"title":"EarningsCalendarResponse"},"EarningsSurpriseEntry":{"properties":{"fiscal_date_ending":{"type":"string","format":"date","title":"Fiscal Date Ending"},"reported_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Reported Date"},"reported_eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Reported Eps"},"estimated_eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Estimated Eps"},"surprise":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Surprise"},"surprise_percentage":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Surprise Percentage"},"beat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Beat"}},"type":"object","required":["fiscal_date_ending"],"title":"EarningsSurpriseEntry"},"EarningsSurpriseResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"quarters":{"items":{"$ref":"#/components/schemas/EarningsSurpriseEntry"},"type":"array","title":"Quarters"},"streak":{"type":"integer","title":"Streak","default":0},"avg_surprise_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Avg Surprise Pct"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","quarters"],"title":"EarningsSurpriseResponse"},"EntityInfo":{"properties":{"ticker":{"type":"string","title":"Ticker"},"canonical_name":{"type":"string","title":"Canonical Name","description":"Normalized company name with legal suffixes stripped (e.g. 'Apple')"},"wiki_title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Wiki Title","description":"Matched Wikipedia article title; null if unresolved"},"gdelt_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gdelt Query","description":"GDELT DOC API query string (quoted OR phrases)"},"aliases":{"items":{"type":"string"},"type":"array","title":"Aliases","description":"Intermediate forms used during name normalization"},"resolver_confidence":{"type":"number","title":"Resolver Confidence","description":"Wikipedia match confidence [0, 1]","default":0.0},"is_manual_override":{"type":"boolean","title":"Is Manual Override","description":"If true, automated re-resolution is skipped","default":false}},"type":"object","required":["ticker","canonical_name"],"title":"EntityInfo","example":{"aliases":["Apple Inc."],"canonical_name":"Apple","gdelt_query":"\"Apple\" OR \"Apple Inc.\"","is_manual_override":false,"resolver_confidence":0.92,"ticker":"AAPL","wiki_title":"Apple Inc."}},"EntityResolveResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"entity":{"$ref":"#/components/schemas/EntityInfo"},"status":{"type":"string","title":"Status"},"message":{"type":"string","title":"Message"}},"type":"object","required":["ticker","entity","status","message"],"title":"EntityResolveResponse","example":{"entity":{"aliases":["Apple Inc."],"canonical_name":"Apple","gdelt_query":"\"Apple\" OR \"Apple Inc.\"","is_manual_override":false,"resolver_confidence":0.92,"ticker":"AAPL","wiki_title":"Apple Inc."},"message":"Entity resolved: wiki_title='Apple Inc.' confidence=0.92","status":"resolved","ticker":"AAPL"}},"ErrorLogListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ErrorLogResponse"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"page":{"type":"integer","title":"Page"},"page_size":{"type":"integer","title":"Page Size"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["items","total","page","page_size","total_pages"],"title":"ErrorLogListResponse","description":"Response schema for error log list"},"ErrorLogResponse":{"properties":{"request_id":{"type":"string","title":"Request Id"},"endpoint":{"type":"string","title":"Endpoint"},"method":{"type":"string","title":"Method"},"path":{"type":"string","title":"Path"},"query_params":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Query Params"},"request_body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Request Body"},"error_type":{"type":"string","title":"Error Type"},"error_message":{"type":"string","title":"Error Message"},"error_detail":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Error Detail"},"status_code":{"type":"integer","title":"Status Code"},"stack_trace":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stack Trace"},"user_agent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Agent"},"client_ip":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Ip"},"response_time_ms":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Response Time Ms"},"id":{"type":"integer","title":"Id"},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers"},"is_resolved":{"type":"boolean","title":"Is Resolved","default":false},"resolved_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resolved At"},"resolution_notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resolution Notes"},"created_at":{"type":"string","title":"Created At"}},"type":"object","required":["request_id","endpoint","method","path","error_type","error_message","status_code","id","created_at"],"title":"ErrorLogResponse","description":"Response schema for error log"},"ErrorLogStats":{"properties":{"total_errors":{"type":"integer","title":"Total Errors"},"resolved_errors":{"type":"integer","title":"Resolved Errors"},"unresolved_errors":{"type":"integer","title":"Unresolved Errors"},"resolution_rate":{"type":"number","title":"Resolution Rate"},"errors_by_type":{"additionalProperties":{"type":"integer"},"type":"object","title":"Errors By Type"},"errors_by_status_code":{"additionalProperties":{"type":"integer"},"type":"object","title":"Errors By Status Code"},"errors_by_endpoint":{"additionalProperties":{"type":"integer"},"type":"object","title":"Errors By Endpoint"},"average_response_time_ms":{"type":"number","title":"Average Response Time Ms"},"hourly_trend":{"additionalProperties":{"type":"integer"},"type":"object","title":"Hourly Trend"},"start_date":{"type":"string","title":"Start Date"},"end_date":{"type":"string","title":"End Date"}},"type":"object","required":["total_errors","resolved_errors","unresolved_errors","resolution_rate","errors_by_type","errors_by_status_code","errors_by_endpoint","average_response_time_ms","hourly_trend","start_date","end_date"],"title":"ErrorLogStats","description":"Statistics about error logs"},"ErrorLogUpdate":{"properties":{"is_resolved":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Resolved"},"resolution_notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resolution Notes"}},"type":"object","title":"ErrorLogUpdate","description":"Schema for updating error log"},"ErrorResponse":{"properties":{"error_type":{"$ref":"#/components/schemas/ErrorType"},"message":{"type":"string","title":"Message"},"detail":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Detail"},"timestamp":{"type":"string","format":"date-time","title":"Timestamp"}},"type":"object","required":["error_type","message"],"title":"ErrorResponse"},"ErrorType":{"type":"string","enum":["PARSING_ERROR","DATA_NOT_FOUND","INVALID_PERIOD","SEC_API_ERROR","DATABASE_ERROR","VALIDATION_ERROR","AUTHENTICATION_ERROR","RATE_LIMIT_ERROR"],"title":"ErrorType"},"EventAttentionResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"event_date":{"type":"string","format":"date","title":"Event Date"},"entity":{"$ref":"#/components/schemas/EntityInfo"},"wiki":{"$ref":"#/components/schemas/WikiFeatures"},"news":{"$ref":"#/components/schemas/NewsFeatures"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","event_date","entity","wiki","news"],"title":"EventAttentionResponse","example":{"entity":{"aliases":["Apple Inc."],"canonical_name":"Apple","gdelt_query":"\"Apple\" OR \"Apple Inc.\"","is_manual_override":false,"resolver_confidence":0.92,"ticker":"AAPL","wiki_title":"Apple Inc."},"event_date":"2024-02-01","metadata":{"resolver_confidence":0.92,"wiki_title":"Apple Inc."},"news":{"article_count_1d":18,"article_count_3d":52,"gdelt_status":"collected","unique_domains_3d":34,"us_article_count_3d":41},"ticker":"AAPL","wiki":{"baseline_10d":12400.0,"spike_10d":3.65,"views":45230,"zscore_20d":4.21}}},"ExhibitContentResponse":{"properties":{"accession_number":{"type":"string","title":"Accession Number"},"exhibit_type":{"type":"string","title":"Exhibit Type"},"content":{"type":"string","title":"Content"},"content_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Type"},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"}},"type":"object","required":["accession_number","exhibit_type","content"],"title":"ExhibitContentResponse","example":{"accession_number":"0000320193-24-000006","content":"Apple Reports First Quarter Results...\nCUPERTINO, California — February 1, 2024 — Apple Inc. today announced financial results for its fiscal 2024 first quarter...","content_type":"text/html","exhibit_type":"EX-99.1","filename":"ex991pressrelease.htm","url":"https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/ex991pressrelease.htm"}},"FilingDocumentInfo":{"properties":{"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"size":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Size"}},"type":"object","title":"FilingDocumentInfo","example":{"description":"Press Release","filename":"ex991pressrelease.htm","size":"42 KB","type":"EX-99.1","url":"https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/ex991pressrelease.htm"}},"FilingDocumentListResponse":{"properties":{"accession_number":{"type":"string","title":"Accession Number"},"documents":{"items":{"$ref":"#/components/schemas/FilingDocumentInfo"},"type":"array","title":"Documents"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["accession_number","documents"],"title":"FilingDocumentListResponse","example":{"accession_number":"0000320193-24-000006","documents":[{"description":"8-K","filename":"a8-k20240201.htm","size":"8 KB","type":"8-K","url":"https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/a8-k20240201.htm"},{"description":"Press Release","filename":"ex991pressrelease.htm","size":"42 KB","type":"EX-99.1","url":"https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/ex991pressrelease.htm"}],"metadata":{"total_documents":4}}},"FilingEventResponse":{"properties":{"id":{"type":"string","title":"Id"},"ticker":{"type":"string","title":"Ticker"},"accession_number":{"type":"string","title":"Accession Number"},"form_type":{"type":"string","title":"Form Type"},"filing_date":{"type":"string","title":"Filing Date"},"item_number":{"type":"string","title":"Item Number"},"event_type":{"type":"string","title":"Event Type"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"content_source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Source"}},"type":"object","required":["id","ticker","accession_number","form_type","filing_date","item_number","event_type"],"title":"FilingEventResponse","example":{"accession_number":"0001193125-26-144028","content_source":"primary_doc","event_type":"other_material_event","filing_date":"2026-04-06","form_type":"8-K","id":"550e8400-e29b-41d4-a716-446655440000","item_number":"8.01","summary":"Broadcom Inc. and Google LLC have entered into a Long Term Agreement...","ticker":"AVGO","title":"Other Events"}},"FilingEventsSearchResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"events":{"items":{"$ref":"#/components/schemas/FilingEventResponse"},"type":"array","title":"Events"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","events","total_count"],"title":"FilingEventsSearchResponse"},"FilingSearchResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"filings":{"items":{"$ref":"#/components/schemas/FilingSummary"},"type":"array","title":"Filings"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","filings","total_count"],"title":"FilingSearchResponse","example":{"filings":[{"accepted_at":"2024-02-01T21:00:05+00:00","accession_number":"0000320193-24-000006","documents_count":4,"filing_date":"2024-02-01","filing_description":"Results of Operations and Financial Condition","form_type":"8-K","primary_document":"a8-k20240201.htm"}],"metadata":{"form_types":["8-K"],"limit":20,"offset":0},"ticker":"AAPL","total_count":42}},"FilingSummary":{"properties":{"accession_number":{"type":"string","title":"Accession Number"},"form_type":{"type":"string","title":"Form Type"},"filing_date":{"type":"string","title":"Filing Date"},"accepted_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accepted At"},"primary_document":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Primary Document"},"filing_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filing Description"},"documents_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Documents Count"},"parsed_status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parsed Status"},"items":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Items"}},"type":"object","required":["accession_number","form_type","filing_date"],"title":"FilingSummary","example":{"accepted_at":"2024-02-01T21:00:05+00:00","accession_number":"0000320193-24-000006","documents_count":4,"filing_date":"2024-02-01","filing_description":"Results of Operations and Financial Condition","form_type":"8-K","items":["2.02","9.01"],"parsed_status":"succeeded","primary_document":"a8-k20240201.htm"}},"FinancialDataPoint":{"properties":{"period_date":{"type":"string","format":"date-time","title":"Period Date"},"period_type":{"type":"string","title":"Period Type"},"filing_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filing Type"},"revenue":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Revenue"},"gross_profit":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Gross Profit"},"operating_income":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Operating Income"},"net_income":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Net Income"},"eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Eps"},"total_assets":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Assets"},"total_equity":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Equity"},"total_debt":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Debt"},"cash":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cash"},"shares_outstanding":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Shares Outstanding"},"operating_cash_flow":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Operating Cash Flow"},"free_cash_flow":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Free Cash Flow"},"capex":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Capex"},"pe_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Pe Ratio"},"pb_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Pb Ratio"},"ps_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Ps Ratio"},"roe":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Roe"},"roa":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Roa"},"gross_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Gross Margin"},"operating_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Operating Margin"},"net_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Net Margin"},"debt_to_equity":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Debt To Equity"},"debt_to_assets":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Debt To Assets"},"ocf_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Ocf Margin"},"fcf_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Fcf Margin"},"market_cap":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Market Cap"},"data_source":{"type":"string","title":"Data Source"},"is_estimated":{"type":"boolean","title":"Is Estimated"}},"type":"object","required":["period_date","period_type","data_source","is_estimated"],"title":"FinancialDataPoint"},"FinancialDataRequest":{"properties":{"ticker":{"type":"string","maxLength":10,"minLength":1,"title":"Ticker","description":"Stock ticker symbol"},"start_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Start Date","description":"Start date for data retrieval. Cannot be used with quarters or period."},"end_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"End Date","description":"End date for data retrieval. Cannot be used with quarters or period."},"quarters":{"anyOf":[{"items":{"type":"string"},"type":"array","maxItems":40,"minItems":1},{"type":"null"}],"title":"Quarters","description":"List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."},"period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period","description":"Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."},"period_type":{"$ref":"#/components/schemas/PeriodType","description":"Type of financial periods to retrieve","default":"all"},"include_metrics":{"type":"boolean","title":"Include Metrics","description":"Include calculated metrics in response","default":true},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Force refresh data from SEC","default":false}},"type":"object","required":["ticker"],"title":"FinancialDataRequest","description":"Request for financial data with flexible time period specification.\n\n**Three ways to specify time period (choose one):**\n1. **Date Range**: Use start_date and end_date \n2. **Quarters**: Use quarters list (e.g., ['2024Q1', '2024Q2'])\n3. **Period**: Use period string (e.g., '1d', '3m', '2y')\n\n**Important**: Cannot mix approaches in the same request."},"FinancialDataResponse":{"properties":{"company":{"$ref":"#/components/schemas/CompanyInfo"},"financial_data":{"items":{"$ref":"#/components/schemas/FinancialDataPoint"},"type":"array","title":"Financial Data"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["company","financial_data"],"title":"FinancialDataResponse"},"FinraSourceDetail":{"properties":{"short_volume_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Short Volume Ratio"},"short_volume_spike_zscore":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Short Volume Spike Zscore"}},"type":"object","title":"FinraSourceDetail"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HeadlineItem":{"properties":{"title":{"type":"string","title":"Title"},"publisher":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Publisher"},"published_at":{"type":"string","format":"date-time","title":"Published At"},"article_guid":{"type":"string","title":"Article Guid"}},"type":"object","required":["title","published_at","article_guid"],"title":"HeadlineItem"},"HeadlinesResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"headlines":{"items":{"$ref":"#/components/schemas/HeadlineItem"},"type":"array","title":"Headlines"},"headline_count_6h":{"type":"integer","title":"Headline Count 6H","default":0},"headline_count_24h":{"type":"integer","title":"Headline Count 24H","default":0},"publisher_breadth_24h":{"type":"integer","title":"Publisher Breadth 24H","default":0},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","headlines"],"title":"HeadlinesResponse"},"HealthCheckResponse":{"properties":{"status":{"type":"string","title":"Status"},"version":{"type":"string","title":"Version"},"database":{"type":"string","title":"Database"},"cache":{"type":"string","title":"Cache"},"sec_data_available":{"type":"boolean","title":"Sec Data Available"},"timestamp":{"type":"string","format":"date-time","title":"Timestamp"}},"type":"object","required":["status","version","database","cache","sec_data_available","timestamp"],"title":"HealthCheckResponse"},"IngestResponse":{"properties":{"date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Date"},"date_range":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Date Range"},"records_ingested":{"type":"integer","title":"Records Ingested"},"status":{"type":"string","title":"Status"}},"type":"object","required":["records_ingested","status"],"title":"IngestResponse"},"InsiderSummaryPeriod":{"properties":{"period_label":{"type":"string","title":"Period Label"},"buy_count":{"type":"integer","title":"Buy Count","default":0},"sell_count":{"type":"integer","title":"Sell Count","default":0},"buy_shares":{"type":"number","title":"Buy Shares","default":0.0},"sell_shares":{"type":"number","title":"Sell Shares","default":0.0},"buy_value":{"type":"number","title":"Buy Value","default":0.0},"sell_value":{"type":"number","title":"Sell Value","default":0.0},"net_shares":{"type":"number","title":"Net Shares","default":0.0},"net_value":{"type":"number","title":"Net Value","default":0.0},"unique_buyers":{"type":"integer","title":"Unique Buyers","default":0},"unique_sellers":{"type":"integer","title":"Unique Sellers","default":0}},"type":"object","required":["period_label"],"title":"InsiderSummaryPeriod"},"InsiderSummaryResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"periods":{"items":{"$ref":"#/components/schemas/InsiderSummaryPeriod"},"type":"array","title":"Periods"},"notable_transactions":{"items":{"$ref":"#/components/schemas/InsiderTransactionEntry"},"type":"array","title":"Notable Transactions"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","periods","notable_transactions"],"title":"InsiderSummaryResponse"},"InsiderTransactionEntry":{"properties":{"filing_date":{"type":"string","format":"date","title":"Filing Date"},"transaction_date":{"type":"string","format":"date","title":"Transaction Date"},"owner_name":{"type":"string","title":"Owner Name"},"owner_cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Cik"},"is_officer":{"type":"boolean","title":"Is Officer","default":false},"is_director":{"type":"boolean","title":"Is Director","default":false},"is_ten_percent_owner":{"type":"boolean","title":"Is Ten Percent Owner","default":false},"officer_title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Officer Title"},"security_title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Security Title"},"transaction_code":{"type":"string","title":"Transaction Code"},"transaction_type":{"type":"string","title":"Transaction Type"},"shares":{"type":"number","title":"Shares"},"price_per_share":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Share"},"total_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Value"},"shares_owned_after":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Shares Owned After"},"is_derivative":{"type":"boolean","title":"Is Derivative","default":false}},"type":"object","required":["filing_date","transaction_date","owner_name","transaction_code","transaction_type","shares"],"title":"InsiderTransactionEntry"},"InsiderTransactionResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"transactions":{"items":{"$ref":"#/components/schemas/InsiderTransactionEntry"},"type":"array","title":"Transactions"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","transactions","total_count"],"title":"InsiderTransactionResponse"},"IntradayCandle":{"properties":{"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"open":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Open"},"high":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"High"},"low":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Low"},"close":{"type":"number","title":"Close"},"volume":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Volume"}},"type":"object","required":["timestamp","close"],"title":"IntradayCandle"},"IntradayResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"interval":{"type":"string","title":"Interval"},"period":{"type":"string","title":"Period"},"candles":{"items":{"$ref":"#/components/schemas/IntradayCandle"},"type":"array","title":"Candles"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","interval","period","candles"],"title":"IntradayResponse"},"JobLogEntry":{"properties":{"id":{"type":"string","title":"Id"},"job_type":{"type":"string","title":"Job Type"},"status":{"type":"string","title":"Status"},"started_at":{"type":"string","format":"date-time","title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"},"records_processed":{"type":"integer","title":"Records Processed","default":0},"error_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Message"}},"type":"object","required":["id","job_type","status","started_at"],"title":"JobLogEntry"},"JobLogResponse":{"properties":{"logs":{"items":{"$ref":"#/components/schemas/JobLogEntry"},"type":"array","title":"Logs"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["logs","total_count"],"title":"JobLogResponse"},"MigrationRequest":{"properties":{"source_url":{"type":"string","title":"Source Url","description":"Source API URL to migrate from"},"api_key":{"type":"string","title":"Api Key","description":"API key for authentication"},"tickers":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tickers","description":"Specific tickers to migrate, or all if not specified"},"start_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Date"},"end_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Date"}},"type":"object","required":["source_url","api_key"],"title":"MigrationRequest"},"MigrationResponse":{"properties":{"status":{"type":"string","title":"Status"},"total_records":{"type":"integer","title":"Total Records"},"migrated_records":{"type":"integer","title":"Migrated Records"},"failed_records":{"type":"integer","title":"Failed Records"},"errors":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Errors"},"duration_seconds":{"type":"number","title":"Duration Seconds"}},"type":"object","required":["status","total_records","migrated_records","failed_records","duration_seconds"],"title":"MigrationResponse"},"NewsFeatures":{"properties":{"article_count_1d":{"type":"integer","title":"Article Count 1D","description":"GDELT articles published on the event date","default":0},"article_count_3d":{"type":"integer","title":"Article Count 3D","description":"GDELT articles in the event_date ± 1 day window","default":0},"unique_domains_3d":{"type":"integer","title":"Unique Domains 3D","description":"Distinct publisher domains in the 3-day window","default":0},"us_article_count_3d":{"type":"integer","title":"Us Article Count 3D","description":"US-sourced articles in the 3-day window","default":0},"gdelt_status":{"type":"string","title":"Gdelt Status","description":"GDELT data availability for this event date. 'collected' — scheduler has run; counts are accurate (0 means genuinely no articles). 'not_collected' — scheduler has not run yet; POST /admin/collect/gdelt/{ticker}?event_date=... to populate. 'not_available' — event date is before GDELT V2 coverage start (2017-01-01).","default":"not_collected"}},"type":"object","title":"NewsFeatures","example":{"article_count_1d":18,"article_count_3d":52,"gdelt_status":"collected","unique_domains_3d":34,"us_article_count_3d":41}},"NewsOnlyResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"retrieved_at":{"type":"string","title":"Retrieved At"},"news":{"additionalProperties":true,"type":"object","title":"News"},"summary":{"additionalProperties":true,"type":"object","title":"Summary"}},"type":"object","required":["ticker","retrieved_at","news","summary"],"title":"NewsOnlyResponse"},"NewsSocialResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"retrieved_at":{"type":"string","title":"Retrieved At"},"news":{"additionalProperties":true,"type":"object","title":"News","description":"News articles and sources breakdown"},"social_media":{"additionalProperties":true,"type":"object","title":"Social Media","description":"Social media posts and platforms breakdown"},"summary":{"$ref":"#/components/schemas/NewsSocialSummarySchema"}},"type":"object","required":["ticker","retrieved_at","news","social_media","summary"],"title":"NewsSocialResponse","description":"Complete response for ticker news and social data"},"NewsSocialSummarySchema":{"properties":{"total_items":{"type":"integer","title":"Total Items"},"time_range_days":{"type":"integer","title":"Time Range Days"},"oldest_item":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Oldest Item"},"newest_item":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Newest Item"}},"type":"object","required":["total_items","time_range_days"],"title":"NewsSocialSummarySchema","description":"Summary of news and social data"},"OverlayFeatures":{"properties":{"headline_burst_z":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Headline Burst Z"},"youtube_influence_z":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Youtube Influence Z"},"wiki_attention_z":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Wiki Attention Z"},"theme_heat_z":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Theme Heat Z"},"crowding_stress_z":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Crowding Stress Z"}},"type":"object","title":"OverlayFeatures"},"OverlayHistoryPoint":{"properties":{"as_of_ts":{"type":"string","format":"date-time","title":"As Of Ts"},"overlay_score":{"type":"number","title":"Overlay Score"},"overlay_confidence":{"type":"number","title":"Overlay Confidence"},"overlay_band":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Overlay Band"}},"type":"object","required":["as_of_ts","overlay_score","overlay_confidence"],"title":"OverlayHistoryPoint"},"OverlayHistoryResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"history":{"items":{"$ref":"#/components/schemas/OverlayHistoryPoint"},"type":"array","title":"History"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","history"],"title":"OverlayHistoryResponse"},"OverlayMetadata":{"properties":{"feature_version":{"type":"string","title":"Feature Version","default":"v1"},"data_freshness":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Data Freshness"},"next_update_expected":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Next Update Expected"}},"type":"object","title":"OverlayMetadata"},"OverlayScoreResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"as_of_ts":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"As Of Ts"},"overlay_score":{"type":"number","title":"Overlay Score","default":0.0},"overlay_confidence":{"type":"number","title":"Overlay Confidence","default":0.0},"overlay_band":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Overlay Band"},"hold_extension_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Hold Extension Hint"},"add_on_eligibility":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Add On Eligibility"},"features":{"$ref":"#/components/schemas/OverlayFeatures"},"source_presence":{"$ref":"#/components/schemas/OverlaySourcePresence"},"source_details":{"$ref":"#/components/schemas/OverlaySourceDetails"},"metadata":{"$ref":"#/components/schemas/OverlayMetadata"}},"type":"object","required":["symbol"],"title":"OverlayScoreResponse"},"OverlaySourceDetails":{"properties":{"yahoo":{"anyOf":[{"$ref":"#/components/schemas/YahooSourceDetail"},{"type":"null"}]},"youtube":{"anyOf":[{"$ref":"#/components/schemas/YouTubeSourceDetail"},{"type":"null"}]},"wikimedia":{"anyOf":[{"$ref":"#/components/schemas/WikiSourceDetail"},{"type":"null"}]},"finra":{"anyOf":[{"$ref":"#/components/schemas/FinraSourceDetail"},{"type":"null"}]}},"type":"object","title":"OverlaySourceDetails"},"OverlaySourcePresence":{"properties":{"yahoo":{"type":"boolean","title":"Yahoo","default":false},"youtube":{"type":"boolean","title":"Youtube","default":false},"wikimedia":{"type":"boolean","title":"Wikimedia","default":false},"google_trends":{"type":"boolean","title":"Google Trends","default":false},"finra":{"type":"boolean","title":"Finra","default":false}},"type":"object","title":"OverlaySourcePresence"},"OverlayTopMover":{"properties":{"symbol":{"type":"string","title":"Symbol"},"overlay_score":{"type":"number","title":"Overlay Score"},"overlay_band":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Overlay Band"},"as_of_ts":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"As Of Ts"}},"type":"object","required":["symbol","overlay_score"],"title":"OverlayTopMover"},"PeriodType":{"type":"string","enum":["quarterly","annual","all"],"title":"PeriodType"},"PriceDataPoint":{"properties":{"date":{"type":"string","format":"date","title":"Date"},"open":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Open"},"high":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"High"},"low":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Low"},"close":{"type":"number","title":"Close"},"volume":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Volume"},"adjusted_close":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Adjusted Close"},"data_source":{"type":"string","title":"Data Source"}},"type":"object","required":["date","close","data_source"],"title":"PriceDataPoint"},"PriceDataRequest":{"properties":{"ticker":{"type":"string","maxLength":10,"minLength":1,"title":"Ticker","description":"Stock ticker symbol"},"start_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Start Date","description":"Start date for data retrieval. Cannot be used with quarters or period."},"end_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"End Date","description":"End date for data retrieval. Cannot be used with quarters or period."},"quarters":{"anyOf":[{"items":{"type":"string"},"type":"array","maxItems":40,"minItems":1},{"type":"null"}],"title":"Quarters","description":"List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."},"period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period","description":"Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."},"interval":{"type":"string","title":"Interval","description":"Data interval: 1d, 1w, 1m, 5d, 1h, etc.","default":"1d"},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Force refresh data from Yahoo Finance","default":false}},"type":"object","required":["ticker"],"title":"PriceDataRequest","description":"Request for price data with flexible time period specification.\n\n**Three ways to specify time period (choose one):**\n1. **Date Range**: Use start_date and end_date\n2. **Quarters**: Use quarters list (e.g., ['2024Q1', '2024Q2'])\n3. **Period**: Use period string (e.g., '1d', '3m', '2y')\n\n**Important**: Cannot mix approaches in the same request."},"PriceDataResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"interval":{"type":"string","title":"Interval"},"data":{"items":{"$ref":"#/components/schemas/PriceDataPoint"},"type":"array","title":"Data"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","interval","data"],"title":"PriceDataResponse"},"QuoteResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"price":{"type":"number","title":"Price"},"regular_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Regular Price"},"pre_market_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Pre Market Price"},"post_market_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Post Market Price"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"},"exchange":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Exchange"},"market_state":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Market State"},"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"source":{"type":"string","title":"Source","default":"YAHOO_FINANCE"},"delayed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Delayed","default":true}},"type":"object","required":["ticker","price","timestamp"],"title":"QuoteResponse"},"RefreshMapsOut":{"properties":{"cusip_rows":{"type":"integer","title":"Cusip Rows"},"etf_rows":{"type":"integer","title":"Etf Rows"}},"type":"object","required":["cusip_rows","etf_rows"],"title":"RefreshMapsOut"},"RegistryResponse":{"properties":{"tickers":{"items":{"$ref":"#/components/schemas/TickerRegistryItem"},"type":"array","title":"Tickers"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"page_size":{"type":"integer","title":"Page Size"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["tickers","total_count","page","page_size","total_pages"],"title":"RegistryResponse"},"RequestLogListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/RequestLogResponse"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"page":{"type":"integer","title":"Page"},"page_size":{"type":"integer","title":"Page Size"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["items","total","page","page_size","total_pages"],"title":"RequestLogListResponse","description":"Response schema for paginated request logs"},"RequestLogResponse":{"properties":{"id":{"type":"integer","title":"Id"},"request_id":{"type":"string","title":"Request Id"},"endpoint":{"type":"string","title":"Endpoint"},"method":{"type":"string","title":"Method"},"path":{"type":"string","title":"Path"},"query_params":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Query Params"},"request_body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Request Body"},"headers":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Headers"},"status_code":{"type":"integer","title":"Status Code"},"response_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Response Size"},"user_agent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Agent"},"client_ip":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Ip"},"response_time_ms":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Response Time Ms"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"}},"type":"object","required":["id","request_id","endpoint","method","path","status_code"],"title":"RequestLogResponse","description":"Response schema for individual request log"},"RequestLogStats":{"properties":{"total_requests":{"type":"integer","title":"Total Requests"},"success_requests":{"type":"integer","title":"Success Requests"},"client_error_requests":{"type":"integer","title":"Client Error Requests"},"server_error_requests":{"type":"integer","title":"Server Error Requests"},"success_rate":{"type":"number","title":"Success Rate"},"requests_by_method":{"additionalProperties":{"type":"integer"},"type":"object","title":"Requests By Method"},"requests_by_status_code":{"additionalProperties":{"type":"integer"},"type":"object","title":"Requests By Status Code"},"requests_by_endpoint":{"additionalProperties":{"type":"integer"},"type":"object","title":"Requests By Endpoint"},"average_response_time_ms":{"type":"number","title":"Average Response Time Ms"},"hourly_trend":{"additionalProperties":{"type":"integer"},"type":"object","title":"Hourly Trend"},"start_date":{"type":"string","title":"Start Date"},"end_date":{"type":"string","title":"End Date"}},"type":"object","required":["total_requests","success_requests","client_error_requests","server_error_requests","success_rate","requests_by_method","requests_by_status_code","requests_by_endpoint","average_response_time_ms","hourly_trend","start_date","end_date"],"title":"RequestLogStats","description":"Response schema for request log statistics"},"ShortRatioHistoryResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"history":{"items":{"$ref":"#/components/schemas/ShortRatioPoint"},"type":"array","title":"History"},"avg_short_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Avg Short Ratio"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","history"],"title":"ShortRatioHistoryResponse"},"ShortRatioPoint":{"properties":{"date":{"type":"string","format":"date","title":"Date"},"short_volume":{"type":"number","title":"Short Volume"},"short_exempt_volume":{"type":"number","title":"Short Exempt Volume"},"total_volume":{"type":"number","title":"Total Volume"},"short_ratio":{"type":"number","title":"Short Ratio"}},"type":"object","required":["date","short_volume","short_exempt_volume","total_volume","short_ratio"],"title":"ShortRatioPoint"},"ShortVolumeEntry":{"properties":{"date":{"type":"string","format":"date","title":"Date"},"symbol":{"type":"string","title":"Symbol"},"short_volume":{"type":"number","title":"Short Volume"},"short_exempt_volume":{"type":"number","title":"Short Exempt Volume","default":0.0},"total_volume":{"type":"number","title":"Total Volume"},"market":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Market"},"short_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Short Ratio"}},"type":"object","required":["date","symbol","short_volume","total_volume"],"title":"ShortVolumeEntry"},"ShortVolumeResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"entries":{"items":{"$ref":"#/components/schemas/ShortVolumeEntry"},"type":"array","title":"Entries"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","entries","total_count"],"title":"ShortVolumeResponse"},"SnapshotBuildRequest":{"properties":{"tickers":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tickers","description":"Specific tickers to build. Omit for all registry tickers."},"start_date":{"type":"string","title":"Start Date","description":"Start date YYYY-MM-DD (e.g. 2015-01-01)"},"end_date":{"type":"string","title":"End Date","description":"End date YYYY-MM-DD (e.g. 2025-12-01)"},"force_rebuild":{"type":"boolean","title":"Force Rebuild","description":"Delete existing snapshots for these tickers before rebuilding","default":false}},"type":"object","required":["start_date","end_date"],"title":"SnapshotBuildRequest"},"SocialOnlyResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"retrieved_at":{"type":"string","title":"Retrieved At"},"social_media":{"additionalProperties":true,"type":"object","title":"Social Media"},"summary":{"additionalProperties":true,"type":"object","title":"Summary"}},"type":"object","required":["ticker","retrieved_at","social_media","summary"],"title":"SocialOnlyResponse"},"SourceHealthItem":{"properties":{"source":{"type":"string","title":"Source"},"last_collected_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Collected At"},"status":{"type":"string","title":"Status","default":"unknown"},"success_rate_24h":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Success Rate 24H"},"records_24h":{"type":"integer","title":"Records 24H","default":0}},"type":"object","required":["source"],"title":"SourceHealthItem"},"TickerRegistryItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"sector":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sector"},"industry":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Industry"},"exchange":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Exchange"},"cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cik"},"is_active":{"type":"boolean","title":"Is Active","default":true}},"type":"object","required":["ticker"],"title":"TickerRegistryItem"},"TodayOHLCResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"date":{"type":"string","format":"date","title":"Date"},"open":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Open"},"high":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"High"},"low":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Low"},"close":{"type":"number","title":"Close"},"volume":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Volume"},"source":{"type":"string","title":"Source","default":"YAHOO_FINANCE"},"method":{"type":"string","title":"Method","default":"daily"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","date","close"],"title":"TodayOHLCResponse"},"TopMoversResponse":{"properties":{"top_movers":{"items":{"$ref":"#/components/schemas/OverlayTopMover"},"type":"array","title":"Top Movers"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["top_movers","total_count"],"title":"TopMoversResponse"},"TrendPoint":{"properties":{"observed_at":{"type":"string","format":"date-time","title":"Observed At"},"interest_value":{"type":"integer","title":"Interest Value"},"topic_id":{"type":"string","title":"Topic Id"},"topic_label":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Topic Label"}},"type":"object","required":["observed_at","interest_value","topic_id"],"title":"TrendPoint"},"TrendsResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"trends":{"items":{"$ref":"#/components/schemas/TrendPoint"},"type":"array","title":"Trends"},"theme_heat_z":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Theme Heat Z"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","trends"],"title":"TrendsResponse"},"TriggerPipelineResponse":{"properties":{"status":{"type":"string","title":"Status"},"message":{"type":"string","title":"Message"},"job_ids":{"items":{"type":"string"},"type":"array","title":"Job Ids"}},"type":"object","required":["status","message"],"title":"TriggerPipelineResponse"},"UniverseScreenResponse":{"properties":{"stocks":{"items":{"$ref":"#/components/schemas/UniverseSnapshotItem"},"type":"array","title":"Stocks"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"page_size":{"type":"integer","title":"Page Size"},"total_pages":{"type":"integer","title":"Total Pages"},"snapshot_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Snapshot Date"},"filters_applied":{"additionalProperties":true,"type":"object","title":"Filters Applied","default":{}},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata","default":{}}},"type":"object","required":["stocks","total_count","page","page_size","total_pages"],"title":"UniverseScreenResponse"},"UniverseSnapshotItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"market_cap":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Market Cap"},"close_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Close Price"},"shares_outstanding":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Shares Outstanding"},"sector":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sector"},"industry":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Industry"},"exchange":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Exchange"},"snapshot_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Snapshot Date"}},"type":"object","required":["ticker"],"title":"UniverseSnapshotItem"},"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"},"VideoItem":{"properties":{"video_id":{"type":"string","title":"Video Id"},"channel_id":{"type":"string","title":"Channel Id"},"title":{"type":"string","title":"Title"},"view_count":{"type":"integer","title":"View Count","default":0},"comment_count":{"type":"integer","title":"Comment Count","default":0},"published_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Published At"},"channel_weight":{"type":"number","title":"Channel Weight","default":0.5}},"type":"object","required":["video_id","channel_id","title"],"title":"VideoItem"},"WikiFeatures":{"properties":{"views":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Views","description":"Wikipedia pageviews on the event date"},"baseline_10d":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Baseline 10D","description":"Median pageviews over the prior 10 days"},"spike_10d":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Spike 10D","description":"views / baseline_10d; >1 means above-average attention"},"zscore_20d":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Zscore 20D","description":"Z-score vs prior 20-day mean/stdev; null if stdev=0"}},"type":"object","title":"WikiFeatures","example":{"baseline_10d":12400.0,"spike_10d":3.65,"views":45230,"zscore_20d":4.21}},"WikiPageviewPoint":{"properties":{"date":{"type":"string","format":"date-time","title":"Date"},"views":{"type":"integer","title":"Views"},"page_title":{"type":"string","title":"Page Title"}},"type":"object","required":["date","views","page_title"],"title":"WikiPageviewPoint"},"WikiResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"pageviews":{"items":{"$ref":"#/components/schemas/WikiPageviewPoint"},"type":"array","title":"Pageviews"},"views_1d":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Views 1D"},"views_7d_avg":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Views 7D Avg"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","pageviews"],"title":"WikiResponse"},"WikiSourceDetail":{"properties":{"page_views_1d":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Page Views 1D"},"page_views_7d_avg":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Page Views 7D Avg"}},"type":"object","title":"WikiSourceDetail"},"YahooSourceDetail":{"properties":{"headline_count_6h":{"type":"integer","title":"Headline Count 6H","default":0},"headline_count_24h":{"type":"integer","title":"Headline Count 24H","default":0},"publisher_breadth_24h":{"type":"integer","title":"Publisher Breadth 24H","default":0}},"type":"object","title":"YahooSourceDetail"},"YouTubeResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"videos":{"items":{"$ref":"#/components/schemas/VideoItem"},"type":"array","title":"Videos"},"mentions_24h":{"type":"integer","title":"Mentions 24H","default":0},"weighted_views_24h":{"type":"number","title":"Weighted Views 24H","default":0.0},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","videos"],"title":"YouTubeResponse"},"YouTubeSourceDetail":{"properties":{"mentions_24h":{"type":"integer","title":"Mentions 24H","default":0},"weighted_views_24h":{"type":"number","title":"Weighted Views 24H","default":0.0}},"type":"object","title":"YouTubeSourceDetail"}}},"tags":[{"name":"health","description":"Health check endpoints"},{"name":"financial","description":"Financial data retrieval endpoints"},{"name":"price","description":"Price data endpoints (OHLCV)"},{"name":"news","description":"News and social media endpoints"},{"name":"metadata","description":"Data catalog and metadata endpoints"},{"name":"filings","description":"SEC filings search, document listing, and exhibit extraction (8-K, 6-K, 20-F, 40-F)"},{"name":"etf","description":"ETF holdings endpoints"},{"name":"alpaca","description":"Alpaca Market Data endpoints (OHLCV bars, connection status)"},{"name":"finra","description":"FINRA RegSHO short sale volume data (ingest, query, ratio history)"},{"name":"admin","description":"Administrative endpoints (migration, etc.)"},{"name":"overlay","description":"Attention Overlay - retail investor interest, media diffusion, crowding signals"},{"name":"overlay-admin","description":"Overlay administrative endpoints (pipeline trigger, health, job log)"},{"name":"screener","description":"Stock screener — condition-based filtering by market cap, volume, price, P/E, sector, exchange"},{"name":"stocks","description":"Stock market data — most active, 52-week gainers, trending, and index constituents (S&P 500 / Nasdaq 100)"},{"name":"attention","description":"Attention signals — Wikipedia pageview spikes and GDELT news article counts for event-centric backtesting"},{"name":"attention-admin","description":"Attention administrative endpoints — entity resolution, Wikipedia and GDELT data collection"},{"name":"database","description":"Database inspection — record counts, date ranges, raw data browsing, and ETF snapshot history"},{"name":"fred","description":"FRED (Federal Reserve Economic Data) — macroeconomic series via FRED API proxy"},{"name":"error-logs","description":"Error log management — browse and clear server-side error records"},{"name":"request-logs","description":"Request log management — browse API request history and latency records"}]} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Stock Oracle","version":"1.0.0"},"paths":{"/api/v1/health":{"get":{"tags":["health"],"summary":"Health check","description":"Check the health status of the API and its dependencies","operationId":"health_check_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthCheckResponse"}}}}}}},"/api/v1/financial/data":{"post":{"tags":["financial"],"summary":"Get SEC EDGAR financial data for a ticker","description":"Retrieve comprehensive financial data directly from SEC EDGAR filings for a specific ticker and time period.\n \n **🔥 Three Ways to Specify Time Period (choose one):**\n \n 1. **Period String** (NEW! Most convenient):\n - `period`: \"1d\", \"7d\", \"30d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - Examples: `{\"ticker\": \"AAPL\", \"period\": \"1y\"}` - Last 1 year of data\n - Example: `{\"ticker\": \"TSLA\", \"period\": \"max\"}` - All available data from listing date to SEC limits\n \n 2. **Date Range** (Traditional):\n - `start_date` + `end_date`: Specific date range\n - Example: `{\"ticker\": \"AAPL\", \"start_date\": \"2024-01-01\", \"end_date\": \"2024-12-31\"}`\n \n 3. **Quarters** (Quarter-based):\n - `quarters`: List of quarters like [\"2024Q1\", \"2024Q2\"]\n - Example: `{\"ticker\": \"AAPL\", \"quarters\": [\"2024Q1\", \"2024Q2\", \"2024Q3\"]}`\n \n **Data Sources:**\n - **Financial Data**: Direct SEC EDGAR API calls (revenue, income, assets, cash flow)\n - **Price Data**: Available via separate price data endpoints using yfinance-plus\n \n **This endpoint returns:**\n - Company information (name, CIK, sector, industry)\n - Financial statements data from SEC filings (income statement, balance sheet, cash flow)\n - Calculated financial metrics (ratios, margins, growth rates)\n - Period types: quarterly (10-Q) and annual (10-K) filings\n \n **Performance Features:**\n - Database caching to avoid repeated SEC API calls\n - Historical data available from 1994-present\n - 15+ years of data typically available for most companies\n - Use `force_refresh=true` to fetch fresh data from SEC EDGAR\n \n **Data Quality:**\n - All financial data sourced directly from official SEC filings\n - No estimated or synthetic data - only actual reported figures\n - Automatic validation and error handling for missing periods\n \n **Example Requests:**\n ```json\n // Using period (simplest)\n {\n \"ticker\": \"AAPL\",\n \"period\": \"1y\",\n \"include_metrics\": true\n }\n \n // Using date range\n {\n \"ticker\": \"MSFT\",\n \"start_date\": \"2024-01-01\",\n \"end_date\": \"2024-12-31\",\n \"period_type\": \"quarterly\"\n }\n \n // Using quarters\n {\n \"ticker\": \"GOOGL\",\n \"quarters\": [\"2024Q1\", \"2024Q2\"],\n \"include_metrics\": true\n }\n ```","operationId":"get_financial_data_api_v1_financial_data_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FinancialDataRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FinancialDataResponse"}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Data not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/financial/data/{ticker}":{"get":{"tags":["financial"],"summary":"Get financial data by ticker (simplified)","description":"Simplified GET endpoint to retrieve financial data with query parameters.\n \n **Time Period Options:**\n - Use `period` for convenience: \"1d\", \"7d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - OR use `start_date` and `end_date` for specific date range\n - Cannot use both approaches simultaneously\n \n **Examples:**\n - `/api/v1/financial/data/AAPL?period=1y&include_metrics=true` - Last year of financial data\n - `/api/v1/financial/data/AAPL?start_date=2024-01-01&end_date=2024-12-31&period_type=quarterly` - Specific date range","operationId":"get_financial_data_simple_api_v1_financial_data__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"period","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'","title":"Period"},"description":"Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Start date for data retrieval (use with end_date, not with period)","title":"Start Date"},"description":"Start date for data retrieval (use with end_date, not with period)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"End date for data retrieval (use with start_date, not with period)","title":"End Date"},"description":"End date for data retrieval (use with start_date, not with period)"},{"name":"period_type","in":"query","required":false,"schema":{"type":"string","description":"Period type: quarterly, annual, or all","default":"all","title":"Period Type"},"description":"Period type: quarterly, annual, or all"},{"name":"include_metrics","in":"query","required":false,"schema":{"type":"boolean","description":"Include calculated metrics","default":true,"title":"Include Metrics"},"description":"Include calculated metrics"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Force refresh from SEC","default":false,"title":"Force Refresh"},"description":"Force refresh from SEC"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FinancialDataResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/financial/data/bulk":{"post":{"tags":["financial"],"summary":"Get SEC EDGAR financial data for multiple tickers","description":"Retrieve comprehensive financial data for multiple tickers in a single request directly from SEC EDGAR filings.\n \n **🔥 Three Ways to Specify Time Period (choose one):**\n \n 1. **Period String** (NEW! Most convenient):\n - `period`: \"1d\", \"7d\", \"30d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - Example: Last 1 year for multiple tickers, or \"max\" for all available data\n \n 2. **Date Range** (Traditional):\n - `start_date` + `end_date`: Specific date range\n - Example: Specific date range for all tickers\n \n 3. **Quarters** (Quarter-based):\n - `quarters`: List of quarters like [\"2024Q1\", \"2024Q2\"]\n - Example: Specific quarters for all tickers\n \n **Data Sources:**\n - **Financial Data**: Direct SEC EDGAR API calls (revenue, income, assets, cash flow)\n - **Price Data**: Available via separate price data endpoints using yfinance-plus\n \n **Bulk Processing Features:**\n - Processes up to 100 tickers in parallel for maximum efficiency\n - Returns individual success/failure results for each ticker\n - Handles partial failures gracefully (some tickers can fail while others succeed)\n - Uses the same robust SEC data retrieval logic as single ticker endpoint\n \n **SEC EDGAR Integration:**\n - Direct API calls to official SEC EDGAR database\n - All financial data sourced from actual SEC filings (10-K, 10-Q)\n - No estimated or synthetic data - only actual reported figures\n - Historical data available from 1994-present (15+ years for most companies)\n - Automatic validation and error handling for missing periods\n \n **Data Quality & Features:**\n - Company information (name, CIK, sector, industry, business description)\n - Comprehensive financial statements (income statement, balance sheet, cash flow)\n - Calculated financial metrics (ratios, margins, growth rates)\n - Period types: quarterly (10-Q) and annual (10-K) filings\n - Database caching to avoid repeated SEC API calls\n \n **Performance:**\n - Parallel processing for bulk requests\n - Intelligent caching and rate limiting\n - Use `force_refresh=true` to fetch fresh data from SEC EDGAR\n \n **Example Requests:**\n ```json\n // Using period (simplest)\n {\n \"tickers\": [\"AAPL\", \"MSFT\", \"GOOGL\"],\n \"period\": \"1y\",\n \"include_metrics\": true\n }\n \n // Using date range\n {\n \"tickers\": [\"NVDA\", \"AMD\", \"INTC\"],\n \"start_date\": \"2024-01-01\",\n \"end_date\": \"2024-12-31\",\n \"period_type\": \"quarterly\"\n }\n \n // Using quarters\n {\n \"tickers\": [\"TSLA\", \"F\", \"GM\"],\n \"quarters\": [\"2024Q1\", \"2024Q2\"],\n \"include_metrics\": true\n }\n ```\n \n Each ticker result includes the same comprehensive financial data structure as the single ticker endpoint.\n Failed tickers will have detailed error messages while successful ones will have complete SEC filing data.","operationId":"get_bulk_financial_data_api_v1_financial_data_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkFinancialDataRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkFinancialDataResponse"}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/data":{"post":{"tags":["price"],"summary":"Get enhanced price data via yfinance-plus","description":"Retrieve historical price data for a specific ticker using enhanced yfinance-plus integration.\n \n **🔥 Three Ways to Specify Time Period (choose one):**\n \n 1. **Period String** (NEW! Most convenient):\n - `period`: \"1d\", \"7d\", \"30d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - Example: `{\"ticker\": \"AAPL\", \"period\": \"3m\", \"interval\": \"1d\"}` - Last 3 months, daily prices\n - Example: `{\"ticker\": \"TSLA\", \"period\": \"max\", \"interval\": \"1d\"}` - Maximum 20 years of data\n \n 2. **Date Range** (Traditional):\n - `start_date` + `end_date`: Specific date range\n - Example: `{\"ticker\": \"AAPL\", \"start_date\": \"2024-01-01\", \"end_date\": \"2024-12-31\", \"interval\": \"1d\"}`\n \n 3. **Quarters** (Quarter-based):\n - `quarters`: List of quarters like [\"2024Q1\", \"2024Q2\"]\n - Example: `{\"ticker\": \"AAPL\", \"quarters\": [\"2024Q1\", \"2024Q2\"], \"interval\": \"1d\"}`\n \n **Data Source:**\n - **Price Data**: Yahoo Finance via yfinance-plus with enhanced rate limiting and caching\n - **Financial Data**: Available via separate financial endpoints using SEC EDGAR\n \n **This endpoint returns:**\n - OHLCV data (Open, High, Low, Close, Volume)\n - Adjusted close prices with dividend/split adjustments\n - Multiple intervals: 1d, 1w, 1m, 1h (where available)\n - Extensive historical data (decades for most symbols)\n \n **Enhanced Features (yfinance-plus):**\n - Intelligent rate limiting to prevent API throttling\n - Multi-threaded bulk downloads for better performance\n - Advanced caching with cache management\n - Automatic retry with exponential backoff\n - Multiple user agents for improved reliability\n - Enhanced error handling and recovery\n \n **Performance:**\n - Database caching to minimize external API calls\n - Bulk mode capable of 59+ tickers/second throughput\n - 4.3x faster than individual ticker requests\n - Use `force_refresh=true` to fetch fresh data from Yahoo Finance\n \n **Example Requests:**\n ```json\n // Using period (simplest)\n {\n \"ticker\": \"AAPL\",\n \"period\": \"6m\",\n \"interval\": \"1d\"\n }\n \n // Using date range\n {\n \"ticker\": \"TSLA\",\n \"start_date\": \"2024-01-01\",\n \"end_date\": \"2024-12-31\",\n \"interval\": \"1w\"\n }\n \n // Using quarters\n {\n \"ticker\": \"NVDA\",\n \"quarters\": [\"2024Q1\", \"2024Q2\"],\n \"interval\": \"1d\",\n \"force_refresh\": true\n }\n ```","operationId":"get_price_data_api_v1_price_data_post","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceDataRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceDataResponse"}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Data not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["price","price"],"summary":"Get daily bars for multiple tickers via yfinance (DB-backed)","description":"Fetch OHLCV daily bars for one or more tickers via Yahoo Finance (yfinance-plus). Results are stored in DB; subsequent calls for the same range skip the external API.\n\n- `tickers` or `ticker`: comma-separated list, e.g. `AAPL,MSFT` or single `QQQ`\n- `force_refresh=true`: re-fetch from Yahoo Finance even if DB has data\n- Up to 1000 tickers per request (auto-chunked internally)\n\n**경로 파라미터 대안**: 단일 종목은 `/data/{ticker}?start_date=...&end_date=...` 도 동일하게 동작합니다.","operationId":"get_multi_ticker_daily_bars_api_v1_price_data_get","parameters":[{"name":"tickers","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated tickers, e.g. AAPL,MSFT,QQQ","title":"Tickers"},"description":"Comma-separated tickers, e.g. AAPL,MSFT,QQQ"},{"name":"ticker","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Alias for tickers (single ticker shorthand)","title":"Ticker"},"description":"Alias for tickers (single ticker shorthand)"},{"name":"start_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Start date (YYYY-MM-DD)","title":"Start Date"},"description":"Start date (YYYY-MM-DD)"},{"name":"end_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"End date (YYYY-MM-DD)","title":"End Date"},"description":"End date (YYYY-MM-DD)"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Bar interval: 1d, 1w, 1m","default":"1d","title":"Interval"},"description":"Bar interval: 1d, 1w, 1m"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Re-fetch from Yahoo Finance even if DB has data","default":false,"title":"Force Refresh"},"description":"Re-fetch from Yahoo Finance even if DB has data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiBarsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/data/{ticker}":{"get":{"tags":["price"],"summary":"Get price data by ticker (simplified)","description":"Simplified GET endpoint to retrieve price data with query parameters.\n \n **Time Period Options:**\n - Use `period` for convenience: \"1d\", \"7d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - OR use `start_date` and `end_date` for specific date range\n - Cannot use both approaches simultaneously\n \n **Examples:**\n - `/api/v1/price/data/AAPL?period=1y&interval=1d` - Last year of daily prices\n - `/api/v1/price/data/TSLA?period=max&interval=1d` - Maximum 20 years of data for Tesla\n - `/api/v1/price/data/AAPL?start_date=2024-01-01&end_date=2024-12-31&interval=1d` - Specific date range","operationId":"get_price_data_simple_api_v1_price_data__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"period","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'","title":"Period"},"description":"Period like '1d', '7d', '1m', '3m', '6m', '1y', '2y', '5y', 'max'"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Start date for data retrieval (use with end_date, not with period)","title":"Start Date"},"description":"Start date for data retrieval (use with end_date, not with period)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"End date for data retrieval (use with start_date, not with period)","title":"End Date"},"description":"End date for data retrieval (use with start_date, not with period)"},{"name":"start","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Alias for start_date","title":"Start"},"description":"Alias for start_date"},{"name":"end","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Alias for end_date","title":"End"},"description":"Alias for end_date"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Data interval: 1d, 1w, 1m, 5d, 1h, etc.","default":"1d","title":"Interval"},"description":"Data interval: 1d, 1w, 1m, 5d, 1h, etc."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Force refresh from Yahoo Finance","default":false,"title":"Force Refresh"},"description":"Force refresh from Yahoo Finance"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceDataResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/data/bulk":{"post":{"tags":["price"],"summary":"Get enhanced price data for multiple tickers via yfinance-plus","description":"Retrieve historical price data for multiple tickers in a single request using enhanced yfinance-plus integration.\n \n **🔥 Three Ways to Specify Time Period (choose one):**\n \n 1. **Period String** (NEW! Most convenient):\n - `period`: \"1d\", \"7d\", \"30d\", \"1m\", \"3m\", \"6m\", \"1y\", \"2y\", \"5y\", \"max\"\n - Example: Last 3 months for multiple tickers, or \"max\" for maximum 20 years of data\n \n 2. **Date Range** (Traditional):\n - `start_date` + `end_date`: Specific date range\n - Example: Specific date range for all tickers\n \n 3. **Quarters** (Quarter-based):\n - `quarters`: List of quarters like [\"2024Q1\", \"2024Q2\"]\n - Example: Specific quarters for all tickers\n \n **Data Source:**\n - **Price Data**: Yahoo Finance via yfinance-plus with enhanced rate limiting and caching\n - **Financial Data**: Available via separate financial endpoints using SEC EDGAR\n \n **Bulk Processing Features:**\n - Processes up to 100 tickers in parallel for maximum throughput\n - Returns individual success/failure results for each ticker\n - Handles partial failures gracefully (some tickers can fail while others succeed)\n - Uses the same enhanced data retrieval logic as single ticker endpoint\n \n **Enhanced Performance (yfinance-plus):**\n - Multi-threaded bulk downloads with intelligent rate limiting\n - 4.3x faster than individual ticker requests\n - Bulk mode capable of 59+ tickers/second throughput\n - Advanced caching and automatic retry with exponential backoff\n - Enhanced error handling and recovery mechanisms\n \n **Data Quality:**\n - OHLCV data with dividend/split adjustments\n - Multiple intervals: 1d, 1w, 1m, 1h (where available)\n - Extensive historical data (decades for most symbols)\n - Database caching to minimize external API calls\n \n **Example Requests:**\n ```json\n // Using period (simplest)\n {\n \"tickers\": [\"AAPL\", \"MSFT\", \"GOOGL\"],\n \"period\": \"3m\",\n \"interval\": \"1d\"\n }\n \n // Using date range\n {\n \"tickers\": [\"NVDA\", \"AMD\", \"INTC\"],\n \"start_date\": \"2024-01-01\",\n \"end_date\": \"2024-12-31\",\n \"interval\": \"1w\"\n }\n \n // Using quarters\n {\n \"tickers\": [\"TSLA\", \"F\", \"GM\"],\n \"quarters\": [\"2024Q1\", \"2024Q2\"],\n \"interval\": \"1d\",\n \"force_refresh\": true\n }\n ```\n \n Each ticker result includes the same comprehensive price data structure as the single ticker endpoint.\n Failed tickers will have detailed error messages while successful ones will have complete OHLCV data.","operationId":"get_bulk_price_data_api_v1_price_data_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkPriceDataRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkPriceDataResponse"}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/latest/{ticker}":{"get":{"tags":["price"],"summary":"Get latest price for a ticker","description":"Get the most recent price data point for a ticker","operationId":"get_latest_price_api_v1_price_latest__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceDataPoint"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/quote/{ticker}":{"get":{"tags":["price"],"summary":"Get latest quote (regular/pre/post)","description":"Return latest price with regular/pre/post market fields from yfinance-plus","operationId":"get_quote_api_v1_price_quote__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"use_prepost","in":"query","required":false,"schema":{"type":"boolean","description":"Include pre/post market prices if available","default":true,"title":"Use Prepost"},"description":"Include pre/post market prices if available"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/intraday":{"get":{"tags":["price"],"summary":"Get intraday bars for multiple tickers via Yahoo Finance","description":"Fetch intraday OHLCV bars for up to ~500 tickers using Yahoo Finance.\n\n**⚠️ Yahoo Finance 분봉 데이터 한계**\n\n| 항목 | 내용 |\n|------|------|\n| 지연 | **15분 지연** (실시간 아님) |\n| `1m` 최대 조회 기간 | 최근 **7일** 이내 |\n| `2m`/`5m`/`15m`/`30m`/`90m` | 최근 **60일** 이내 |\n| `1h` | 최근 **730일** 이내 |\n| 실시간 거래 전략 | **부적합** — 15분 지연으로 ORB 등 당일 전략에 사용 불가 |\n| 데이터 품질 | Yahoo Finance 자체 집계, 간헐적 누락/오류 가능 |\n\n**권장 용도**: 백테스트, 과거 분봉 분석 (60일 이내)\n\n**실시간 당일 분봉이 필요하면** → `GET /api/v1/alpaca/intraday` 사용 (Alpaca IEX 피드, 실시간)\n\n- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n- Redis 5분 TTL 캐시 적용","operationId":"get_multi_ticker_intraday_api_v1_price_intraday_get","parameters":[{"name":"tickers","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated tickers","title":"Tickers"},"description":"Comma-separated tickers"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Interval: 1m, 5m, 15m, 30m, 1h","default":"5m","title":"Interval"},"description":"Interval: 1m, 5m, 15m, 30m, 1h"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Start date (YYYY-MM-DD)","title":"Start Date"},"description":"Start date (YYYY-MM-DD)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"End date (YYYY-MM-DD)","title":"End Date"},"description":"End date (YYYY-MM-DD)"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiBarsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/intraday/{ticker}":{"get":{"tags":["price"],"summary":"Get intraday candles","description":"Return intraday candles using yfinance-plus history(period,interval)","operationId":"get_intraday_api_v1_price_intraday__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"interval","in":"query","required":false,"schema":{"type":"string","default":"1m","title":"Interval"}},{"name":"period","in":"query","required":false,"schema":{"type":"string","default":"1d","title":"Period"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntradayResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/price/today/{ticker}":{"get":{"tags":["price"],"summary":"Get today's OHLC","description":"Return today's OHLC. If daily not finalized yet, aggregate from 1m intraday.","operationId":"get_today_ohlc_api_v1_price_today__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TodayOHLCResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/stocks/index/{index_name}":{"get":{"tags":["stocks"],"summary":"Get index constituents (S&P 500 / Nasdaq 100)","description":"Get current constituents of a major stock index from Wikipedia.\n\nReturns each stock's symbol, company name, GICS Sector, and GICS Sub-Industry.\n\n**Supported values for `index_name`**:\n- `sp500` — S&P 500 (~503 stocks)\n- `nasdaq100` — Nasdaq 100 (~101 stocks)\n\n**Data Source**: Wikipedia\n**Cache TTL**: 24 hours (`X-Cache: HIT/MISS`, `ETag` headers included)\n**Timeout**: 30 seconds (Wikipedia fetch)\n\n**Error codes**:\n- `400` — unsupported `index_name`\n- `504` — Wikipedia response timed out","operationId":"get_index_constituents_api_v1_stocks_index__index_name__get","parameters":[{"name":"index_name","in":"path","required":true,"schema":{"type":"string","title":"Index Name"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"If true, bypasses cache and fetches fresh data","default":false,"title":"Force Refresh"},"description":"If true, bypasses cache and fetches fresh data"}],"responses":{"200":{"description":"List of constituent stocks with symbol, name, sector, and industry","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/stocks/most-active":{"get":{"tags":["stocks"],"summary":"Most actively traded stocks by volume","description":"Get most actively traded stocks from Yahoo Finance.\n\n**⚠️ 실시간 전용**: DB에 저장되지 않음. 과거 데이터 조회 불가.\n캐시 TTL: 1시간 (`X-Cache: HIT/MISS` 헤더 포함).","operationId":"get_most_active_stocks_api_v1_stocks_most_active_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":500,"minimum":1},{"type":"null"}],"description":"Maximum number of stocks to return (1-500). If not specified, returns all available stocks.","title":"Limit"},"description":"Maximum number of stocks to return (1-500). If not specified, returns all available stocks."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"If true, bypasses cache and fetches fresh data","default":false,"title":"Force Refresh"},"description":"If true, bypasses cache and fetches fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/stocks/52-week-gainers":{"get":{"tags":["stocks"],"summary":"Top 52-week gaining stocks","description":"Get 52-week top gaining stocks from Yahoo Finance.\n\n**⚠️ 실시간 전용**: DB에 저장되지 않음. 과거 데이터 조회 불가.\n캐시 TTL: 1시간. 첫 호출 시 15-30초 소요 (웹 스크래핑).","operationId":"get_52week_gainers_api_v1_stocks_52_week_gainers_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":1000,"minimum":1},{"type":"null"}],"description":"Maximum number of stocks to return (1-1000). If not specified, returns first 600 stocks (3 pages) for performance.","title":"Limit"},"description":"Maximum number of stocks to return (1-1000). If not specified, returns first 600 stocks (3 pages) for performance."},{"name":"max_pages","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":10,"minimum":1},{"type":"null"}],"description":"Maximum pages to fetch (1-10). Each page has ~200 stocks. Higher values may cause rate limiting.","default":3,"title":"Max Pages"},"description":"Maximum pages to fetch (1-10). Each page has ~200 stocks. Higher values may cause rate limiting."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/stocks/trending":{"get":{"tags":["stocks"],"summary":"Trending stocks combining most active and 52-week gainers","description":"Get trending stocks by combining most-active + 52-week gainers.\n\n**⚠️ 실시간 전용**: DB에 저장되지 않음. 과거 데이터 조회 불가.\n캐시 TTL: 30분. 병렬 스크래핑으로 최적화.","operationId":"get_trending_stocks_api_v1_stocks_trending_get","parameters":[{"name":"n","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Total number of trending stocks to return after combining most active + gainers (default: 500)","default":500,"title":"N"},"description":"Total number of trending stocks to return after combining most active + gainers (default: 500)"},{"name":"most_active_limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Number of most active stocks to include. If not specified, returns all available stocks (~170).","title":"Most Active Limit"},"description":"Number of most active stocks to include. If not specified, returns all available stocks (~170)."},{"name":"gainers_limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":1},{"type":"null"}],"description":"Number of 52-week gainers to fetch. If not specified, fetches enough to reach target 'n' after combining with most active.","title":"Gainers Limit"},"description":"Number of 52-week gainers to fetch. If not specified, fetches enough to reach target 'n' after combining with most active."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/fred/stats/usage":{"get":{"tags":["fred"],"summary":"FRED API usage statistics and cache performance","description":"Get FRED API usage statistics and cache performance\n\nReturns detailed statistics about API usage, cache performance, and daily limits.\nNow includes enhanced proxy service statistics.\n\n**Example Response**:\n```json\n{\n \"success\": true,\n \"data\": {\n \"daily_limit\": 1000,\n \"used_today\": 45,\n \"remaining_today\": 955,\n \"usage_percentage\": 4.5,\n \"can_make_requests\": true,\n \"daily_stats\": [\n {\n \"date\": \"2025-01-14\",\n \"total_calls\": 45,\n \"successful_calls\": 44,\n \"total_records\": 1250,\n \"success_rate\": 97.8\n }\n ],\n \"endpoint_stats\": [\n {\n \"endpoint\": \"series\",\n \"call_count\": 25\n }\n ],\n \"proxy_info\": {\n \"mode\": \"pass_through_proxy\",\n \"supported_endpoints\": \"all_fred_endpoints\"\n }\n }\n}\n```\n\n**Parameters**:\n- `days`: Number of days to include in historical statistics (1-30)\n- `use_proxy_stats`: Use enhanced proxy service statistics (recommended)\n\n**Metrics Included**:\n- Daily API usage and remaining quota\n- Historical usage patterns \n- Endpoint-specific usage statistics (NEW!)\n- Success rates and error tracking\n- Proxy service information (NEW!)","operationId":"get_fred_usage_stats_api_v1_fred_stats_usage_get","parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to include in stats","default":7,"title":"Days"},"description":"Number of days to include in stats"},{"name":"use_proxy_stats","in":"query","required":false,"schema":{"type":"boolean","description":"Use enhanced proxy service statistics","default":true,"title":"Use Proxy Stats"},"description":"Use enhanced proxy service statistics"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/fred/proxy/{endpoint}":{"get":{"tags":["fred"],"summary":"Universal FRED API proxy","description":"FRED API Pass-through Proxy\n\nUniversal proxy endpoint that forwards requests to any FRED API endpoint while maintaining\nour caching and rate limiting logic.\n\n**Supported Endpoints**: All FRED API endpoints are supported\n\n**Examples**:\n```bash\n# Series information\nGET /api/v1/fred/proxy/series?series_id=GDP\n\n# Series observations \nGET /api/v1/fred/proxy/series/observations?series_id=UNRATE&limit=12\n\n# Category information\nGET /api/v1/fred/proxy/category?category_id=125\n\n# Category children\nGET /api/v1/fred/proxy/category/children?category_id=13\n\n# Release information\nGET /api/v1/fred/proxy/release?release_id=53\n\n# Search series\nGET /api/v1/fred/proxy/series/search?search_text=unemployment&limit=25\n\n# Sources\nGET /api/v1/fred/proxy/sources\n\n# Tags\nGET /api/v1/fred/proxy/tags?limit=100\n```\n\n**Key Features**:\n- **Universal Access**: Support for all FRED API endpoints\n- **Smart Caching**: 24-hour DB caching for series and observations (NEW!)\n- **Permanent Storage**: Historical data permanently stored in database (NEW!)\n- **Rate Limiting**: Respects 1,000/day limit with usage tracking \n- **Parameter Forwarding**: Automatically forwards all supported parameters\n- **Error Handling**: Comprehensive error handling and logging\n- **Usage Statistics**: Tracks endpoint usage and performance\n\n**Parameters**:\nAll standard FRED API parameters are supported including:\n- `series_id`, `category_id`, `release_id`, `source_id`\n- `realtime_start`, `realtime_end`, `observation_start`, `observation_end` \n- `limit`, `offset`, `order_by`, `sort_order`\n- `search_text`, `search_type`, `frequency`, `aggregation_method`\n- `force_refresh`: Bypass cache and fetch fresh data from FRED API\n- `bypass_limit_check`: Skip daily limit validation (admin only)\n- And many more...\n\n**Caching Strategy**:\n- **Cache Hit**: Returns instantly from database (no API call)\n- **Cache Miss**: Fetches from FRED API and stores for 24 hours \n- **Permanent Storage**: Historical observations stored permanently\n- **API Limit Reached**: Returns cached data even if expired\n\n**Response Format**: Returns original FRED API response with additional metadata","operationId":"fred_proxy_endpoint_api_v1_fred_proxy__endpoint__get","parameters":[{"name":"endpoint","in":"path","required":true,"schema":{"type":"string","title":"Endpoint"}},{"name":"series_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Series ID parameter","title":"Series Id"},"description":"Series ID parameter"},{"name":"category_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Category ID parameter","title":"Category Id"},"description":"Category ID parameter"},{"name":"release_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Release ID parameter","title":"Release Id"},"description":"Release ID parameter"},{"name":"source_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Source ID parameter","title":"Source Id"},"description":"Source ID parameter"},{"name":"tag_names","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Tag names parameter","title":"Tag Names"},"description":"Tag names parameter"},{"name":"realtime_start","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Realtime start date (YYYY-MM-DD)","title":"Realtime Start"},"description":"Realtime start date (YYYY-MM-DD)"},{"name":"realtime_end","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Realtime end date (YYYY-MM-DD)","title":"Realtime End"},"description":"Realtime end date (YYYY-MM-DD)"},{"name":"observation_start","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Observation start date (YYYY-MM-DD)","title":"Observation Start"},"description":"Observation start date (YYYY-MM-DD)"},{"name":"observation_end","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Observation end date (YYYY-MM-DD)","title":"Observation End"},"description":"Observation end date (YYYY-MM-DD)"},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":100000,"minimum":1},{"type":"null"}],"description":"Limit number of results","title":"Limit"},"description":"Limit number of results"},{"name":"offset","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":0},{"type":"null"}],"description":"Offset for pagination","title":"Offset"},"description":"Offset for pagination"},{"name":"order_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Order by parameter","title":"Order By"},"description":"Order by parameter"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order (asc/desc)","title":"Sort Order"},"description":"Sort order (asc/desc)"},{"name":"search_text","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Search text","title":"Search Text"},"description":"Search text"},{"name":"search_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Search type","title":"Search Type"},"description":"Search type"},{"name":"frequency","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Data frequency","title":"Frequency"},"description":"Data frequency"},{"name":"aggregation_method","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Aggregation method","title":"Aggregation Method"},"description":"Aggregation method"},{"name":"output_type","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Output type","title":"Output Type"},"description":"Output type"},{"name":"vintage_dates","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Vintage dates","title":"Vintage Dates"},"description":"Vintage dates"},{"name":"exclude_tag_names","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Exclude tag names","title":"Exclude Tag Names"},"description":"Exclude tag names"},{"name":"tag_group_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Tag group ID","title":"Tag Group Id"},"description":"Tag group ID"},{"name":"bypass_limit_check","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass daily limit check (admin only)","default":false,"title":"Bypass Limit Check"},"description":"Bypass daily limit check (admin only)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Force refresh from API, bypass cache","default":false,"title":"Force Refresh"},"description":"Force refresh from API, bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/fred/endpoints":{"get":{"tags":["fred"],"summary":"List supported FRED API endpoints","description":"Get list of supported FRED API endpoints\n\nReturns comprehensive list of all FRED API endpoints that can be accessed\nthrough the proxy service.\n\n**Usage**: Use this to discover available endpoints and their categories.\n\n**Example Response**:\n```json\n{\n \"series_endpoints\": [\n \"series\",\n \"series/observations\", \n \"series/search\",\n \"...\"\n ],\n \"category_endpoints\": [\"...\"],\n \"release_endpoints\": [\"...\"]\n}\n```","operationId":"get_supported_fred_endpoints_api_v1_fred_endpoints_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/news/{ticker}":{"get":{"tags":["news"],"summary":"Get news and social media for a ticker","description":"Fetch recent news articles and social media posts for a ticker from multiple sources.\n\n **News sources**: Yahoo Finance, NewsAPI\n **Social sources**: Reddit (r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis, r/ValueInvesting)\n\n Both sources are fetched in parallel. Results are deduplicated and ranked by relevance.\n Cached for **10 minutes**.\n\n **Examples**:\n - `GET /news/AAPL` — last 7 days, up to 20 articles + 15 posts\n - `GET /news/TSLA?days_back=14&max_articles=50&include_social=false` — news-only, 2 weeks","operationId":"get_ticker_news_and_social_api_v1_news__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"days_back","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to look back for articles (1-30)","default":7,"title":"Days Back"},"description":"Number of days to look back for articles (1-30)"},{"name":"max_articles","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Maximum number of news articles to return (1-100)","default":20,"title":"Max Articles"},"description":"Maximum number of news articles to return (1-100)"},{"name":"max_social_posts","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":0,"description":"Maximum number of social media posts to return (0-50)","default":15,"title":"Max Social Posts"},"description":"Maximum number of social media posts to return (0-50)"},{"name":"include_social","in":"query","required":false,"schema":{"type":"boolean","description":"Whether to include social media data","default":true,"title":"Include Social"},"description":"Whether to include social media data"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and fetch fresh data","default":false,"title":"Force Refresh"},"description":"Bypass cache and fetch fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsSocialResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/news/{ticker}/news-only":{"get":{"tags":["news"],"summary":"Get news articles for a ticker (no social media)","description":"Faster endpoint that returns only news articles, skipping social media API calls.\n\n **Sources**: Yahoo Finance, NewsAPI\n Cached for **10 minutes**.\n\n **Example**: `GET /news/NVDA/news-only?days_back=3&max_articles=30`","operationId":"get_ticker_news_only_api_v1_news__ticker__news_only_get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"days_back","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to look back for articles (1-30)","default":7,"title":"Days Back"},"description":"Number of days to look back for articles (1-30)"},{"name":"max_articles","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Maximum number of news articles to return (1-100)","default":30,"title":"Max Articles"},"description":"Maximum number of news articles to return (1-100)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and fetch fresh data","default":false,"title":"Force Refresh"},"description":"Bypass cache and fetch fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NewsOnlyResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/news/{ticker}/social-only":{"get":{"tags":["news"],"summary":"Get social media posts for a ticker","description":"Returns only Reddit posts for a ticker, skipping news API calls.\n\n **Subreddits**: r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis,\n r/StockMarket, r/ValueInvesting, r/financialindependence\n Cached for **10 minutes**.\n\n **Example**: `GET /news/GME/social-only?days_back=3&max_social_posts=30`","operationId":"get_ticker_social_only_api_v1_news__ticker__social_only_get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"days_back","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to look back for posts (1-30)","default":7,"title":"Days Back"},"description":"Number of days to look back for posts (1-30)"},{"name":"max_social_posts","in":"query","required":false,"schema":{"type":"integer","maximum":50,"minimum":1,"description":"Maximum number of social media posts to return (1-50)","default":20,"title":"Max Social Posts"},"description":"Maximum number of social media posts to return (1-50)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and fetch fresh data","default":false,"title":"Force Refresh"},"description":"Bypass cache and fetch fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SocialOnlyResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/news/v2/headlines":{"get":{"tags":["news-v2"],"summary":"Raw multi-source news headlines","description":"Multi-source raw headline rows. Filter by symbols, time window, and source. Sources: `alpaca_benzinga`, `stocktwits`, `finnhub`, `gdelt`.","operationId":"get_headlines_api_v1_news_v2_headlines_get","parameters":[{"name":"symbols","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"CSV ticker list, max 50 (e.g. AAPL,MSFT)","title":"Symbols"},"description":"CSV ticker list, max 50 (e.g. AAPL,MSFT)"},{"name":"start","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Start time (UTC ISO)","title":"Start"},"description":"Start time (UTC ISO)"},{"name":"end","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"End time (UTC ISO)","title":"End"},"description":"End time (UTC ISO)"},{"name":"sources","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"CSV source filter, subset of ['alpaca_benzinga', 'finnhub', 'gdelt', 'stocktwits']","title":"Sources"},"description":"CSV source filter, subset of ['alpaca_benzinga', 'finnhub', 'gdelt', 'stocktwits']"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"default":100,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"published_at_lt cursor (ISO datetime)","title":"Cursor"},"description":"published_at_lt cursor (ISO datetime)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app__api__v1__endpoints__news_v2__HeadlinesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/news/v2/session_aggregate":{"get":{"tags":["news-v2"],"summary":"Session-aggregated news for one ticker","operationId":"get_session_aggregate_api_v1_news_v2_session_aggregate_get","parameters":[{"name":"symbol","in":"query","required":true,"schema":{"type":"string","description":"Ticker symbol","title":"Symbol"},"description":"Ticker symbol"},{"name":"session_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"ET session date (YYYY-MM-DD)","title":"Session Date"},"description":"ET session date (YYYY-MM-DD)"},{"name":"window","in":"query","required":false,"schema":{"type":"string","description":"One of ['full_session', 'intraday', 'post', 'premarket']","default":"premarket","title":"Window"},"description":"One of ['full_session', 'intraday', 'post', 'premarket']"},{"name":"sources","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"CSV source filter, subset of ['alpaca_benzinga', 'finnhub', 'gdelt', 'stocktwits']","title":"Sources"},"description":"CSV source filter, subset of ['alpaca_benzinga', 'finnhub', 'gdelt', 'stocktwits']"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionAggregateItem"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/news/v2/session_aggregate/batch":{"post":{"tags":["news-v2"],"summary":"Session-aggregated news for many tickers in one call","description":"Batch variant. Caching is intentionally NOT applied at this layer — fithia2 maintains a client-side disk cache as the primary defense; Oracle absorbs only burst load. Use the GET single endpoint for Redis-cached single-ticker reads.","operationId":"post_session_aggregate_batch_api_v1_news_v2_session_aggregate_batch_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionAggregateBatchRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionAggregateBatchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/news/v2/coverage":{"get":{"tags":["news-v2"],"summary":"Per-source ingest coverage probe","operationId":"get_coverage_api_v1_news_v2_coverage_get","parameters":[{"name":"source","in":"query","required":true,"schema":{"type":"string","description":"One of ['alpaca_benzinga', 'finnhub', 'gdelt', 'stocktwits']","title":"Source"},"description":"One of ['alpaca_benzinga', 'finnhub', 'gdelt', 'stocktwits']"},{"name":"symbol","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Optional ticker filter","title":"Symbol"},"description":"Optional ticker filter"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force Refresh"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CoverageResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/etf/holdings/{ticker}":{"get":{"tags":["etf"],"summary":"Get ETF portfolio holdings","description":"Fetch the constituent holdings of an ETF (e.g., SPY, QQQ, IWM). Data is sourced from SEC 13-F filings and cached for 1 hour.\n\nUse `top_n` to limit to the N largest positions, or `top_percentage` to return the minimal set of holdings that covers X% of the portfolio (e.g., `top_percentage=0.8` for the holdings making up 80% of the ETF).\n\n**Examples**:\n- `GET /etf/holdings/SPY` — all holdings\n- `GET /etf/holdings/QQQ?top_n=10` — top 10 positions\n- `GET /etf/holdings/IWM?top_percentage=0.5` — holdings covering 50% of portfolio","operationId":"get_etf_holdings_api_v1_etf_holdings__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"as_of_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"YYYY-MM-DD","title":"As Of Date"},"description":"YYYY-MM-DD"},{"name":"top_n","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Return top N holdings by weight/value (mutually exclusive with top_percentage)","title":"Top N"},"description":"Return top N holdings by weight/value (mutually exclusive with top_percentage)"},{"name":"top_percentage","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Return minimal set covering X percent (e.g., 0.5 or 50 for 50%). Mutually exclusive with top_n","title":"Top Percentage"},"description":"Return minimal set covering X percent (e.g., 0.5 or 50 for 50%). Mutually exclusive with top_n"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ETFHoldingsOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/etf/admin/refresh-maps":{"post":{"tags":["etf","etf"],"summary":"Refresh ETF CIK and CUSIP mapping tables","description":"Re-fetches and upserts the ETF→CIK and CUSIP→ticker mapping tables from SEC data. Run this when new ETFs need to be supported. Returns the number of rows updated.","operationId":"refresh_etf_maps_api_v1_etf_admin_refresh_maps_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshMapsOut"}}}}}}},"/api/v1/filings/search/{ticker}":{"get":{"tags":["filings"],"summary":"Search SEC filings for a ticker","description":"Search SEC filings for the given ticker. Supported form types: **8-K, 6-K, 20-F, 40-F**.\n\nAuto-indexes filings from EDGAR on first request (or when `force_refresh=true`). Results are cached for 1 hour.\n\n**현재 DB 보유**: 1994-01-05 ~ 현재, 1598 티커. 처음 조회하는 티커는 SEC EDGAR에서 자동 인덱싱 (수 초 소요).\n\n**Example**: `GET /filings/search/AAPL?form_type=8-K&limit=10`","operationId":"search_filings_api_v1_filings_search__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"form_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated form types (e.g. '8-K,6-K'). Default: all supported.","title":"Form Type"},"description":"Comma-separated form types (e.g. '8-K,6-K'). Default: all supported."},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Start date YYYY-MM-DD","title":"Start Date"},"description":"Start date YYYY-MM-DD"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"End date YYYY-MM-DD","title":"End Date"},"description":"End date YYYY-MM-DD"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":20,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Force re-indexing from SEC","default":false,"title":"Force Refresh"},"description":"Force re-indexing from SEC"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilingSearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/documents/{accession_number}":{"get":{"tags":["filings"],"summary":"List documents in a SEC filing","description":"List all documents attached to a SEC filing by accession number.\n\nReturns filename, document type, size, and SEC URL for each document. Cached for 24 hours.\n\n**Example**: `GET /filings/documents/0001193125-24-123456`","operationId":"get_filing_documents_api_v1_filings_documents__accession_number__get","parameters":[{"name":"accession_number","in":"path","required":true,"schema":{"type":"string","title":"Accession Number"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FilingDocumentListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/exhibit/{accession_number}":{"get":{"tags":["filings"],"summary":"Extract exhibit content from a filing","description":"Extract the text content of a specific exhibit (e.g., press release **EX-99.1**) from a SEC filing.\n\nReturns the full text content along with content type, filename, and SEC URL. 404 responses are negative-cached for 1 hour. Cached for 24 hours.\n\n**Example**: `GET /filings/exhibit/0001193125-24-123456?exhibit_type=EX-99.1`","operationId":"get_exhibit_content_api_v1_filings_exhibit__accession_number__get","parameters":[{"name":"accession_number","in":"path","required":true,"schema":{"type":"string","title":"Accession Number"}},{"name":"exhibit_type","in":"query","required":false,"schema":{"type":"string","description":"Exhibit type (e.g. EX-99.1)","default":"EX-99.1","title":"Exhibit Type"},"description":"Exhibit type (e.g. EX-99.1)"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExhibitContentResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/search/bulk":{"post":{"tags":["filings"],"summary":"Bulk search SEC filings for multiple tickers","description":"Search SEC filings for up to many tickers in a single request. Auto-indexes from EDGAR for any ticker not yet in the database.\n\n**Timeout**: 600 seconds. Each ticker is processed concurrently.\n\n**Example body**:\n```json\n{\"tickers\": [\"AAPL\", \"MSFT\", \"NVDA\"], \"form_type\": \"8-K\", \"start_date\": \"2024-01-01\", \"limit_per_ticker\": 5}\n```","operationId":"search_filings_bulk_api_v1_filings_search_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkFilingSearchRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkFilingSearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/exhibit/bulk":{"post":{"tags":["filings"],"summary":"Bulk fetch exhibit content","description":"Fetch exhibit content for multiple accession numbers in one request. Up to 4 concurrent fetches; max 300 second timeout.\n\n**Example body**:\n```json\n{\"items\": [{\"accession_number\": \"0001193125-24-123456\", \"exhibit_type\": \"EX-99.1\"}]}\n```","operationId":"get_exhibit_bulk_api_v1_filings_exhibit_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkExhibitRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkExhibitResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/events/{ticker}":{"get":{"tags":["filings"],"summary":"Get parsed 8-K events for a ticker","description":"Returns structured events parsed from 8-K filings. Each event corresponds to one 8-K Item (e.g., Item 8.01 → other_material_event, Item 2.02 → earnings_result).\\n\\nIf there are unprocessed (pending) filings, they are lazily parsed on first request.\\n\\n**Example**: `GET /filings/events/AVGO?start_date=2026-04-01&event_type=other_material_event`","operationId":"get_filing_events_api_v1_filings_events__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Start date YYYY-MM-DD","title":"Start Date"},"description":"Start date YYYY-MM-DD"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"End date YYYY-MM-DD","title":"End Date"},"description":"End date YYYY-MM-DD"},{"name":"event_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by event type (e.g. other_material_event)","title":"Event Type"},"description":"Filter by event type (e.g. other_material_event)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":20,"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":{"$ref":"#/components/schemas/FilingEventsSearchResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/events/parse/bulk":{"post":{"tags":["filings"],"summary":"Bulk parse pending 8-K filings","description":"Parse 8-K filings and create structured events.\\n\\n- **Default**: processes only `pending` filings.\\n- **`force_reparse=true`**: resets `succeeded`/`failed` filings to `pending` and re-parses them.\\n\\n**Example — reparse specific ticker**: `{\"tickers\": [\"AVGO\"], \"limit\": 50, \"force_reparse\": true}`\\n**Example — backfill all pending**: `{\"limit\": 200}`","operationId":"parse_8k_bulk_api_v1_filings_events_parse_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkParseRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkParseResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/filings/events/parse/{accession_number}":{"post":{"tags":["filings"],"summary":"Force-reparse a single 8-K filing","description":"Reparse a specific filing by accession number, regardless of current `parsed_status`.\\n\\n**Example**: `POST /filings/events/parse/0001193125-26-144028`","operationId":"parse_8k_single_api_v1_filings_events_parse__accession_number__post","parameters":[{"name":"accession_number","in":"path","required":true,"schema":{"type":"string","title":"Accession Number"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkParseResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/metadata/catalog":{"get":{"tags":["metadata"],"summary":"Get data catalog","description":"Get a comprehensive catalog of all available data fields.\n \n This endpoint returns:\n - All available financial metrics and their descriptions\n - Data types and units for each field\n - Calculation methods where applicable\n - Data sources for each field\n \n The catalog is organized by categories:\n - Company Information\n - Income Statement\n - Balance Sheet\n - Cash Flow Statement\n - Valuation Ratios\n - Profitability Metrics\n - Growth Metrics\n - Liquidity & Solvency\n - Efficiency Metrics\n - Market Data (Future)","operationId":"get_catalog_api_v1_metadata_catalog_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DataCatalogResponse"}}}}}}},"/api/v1/admin/migrate":{"post":{"tags":["admin"],"summary":"Migrate data from another instance","description":"Migrate financial data from another SEC Investment API instance.\n \n This endpoint allows you to:\n - Transfer all data from one instance to another\n - Migrate specific tickers only\n - Migrate data within specific date ranges\n \n Requires valid migration API key in X-API-Key header.","operationId":"migrate_data_api_v1_admin_migrate_post","parameters":[{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrationRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/migration/export/{ticker}":{"get":{"tags":["admin"],"summary":"Export data for migration","description":"Export financial data for a specific ticker (used by migration process)","operationId":"export_data_api_v1_admin_migration_export__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Date"}},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Date"}},{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/database/stats":{"get":{"tags":["database"],"summary":"Database record counts and date ranges","description":"Returns aggregate statistics across all core tables:\n\n - `companies` — total companies, how many have financial/price data\n - `financial_data` — total records, real vs estimated, date range, breakdown by source\n - `price_data` — total records, date range, list of tickers\n - `calculated_metrics` — total records and date range","operationId":"get_database_stats_api_v1_database_stats_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Database Stats Api V1 Database Stats Get"}}}}}}},"/api/v1/database/health":{"get":{"tags":["database"],"summary":"Database connection health check","description":"데이터베이스 연결 상태를 확인합니다.","operationId":"get_database_health_api_v1_database_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Database Health Api V1 Database Health Get"}}}}}}},"/api/v1/database/tables":{"get":{"tags":["database"],"summary":"Table row counts for all core tables","description":"데이터베이스 테이블 정보를 반환합니다.","operationId":"get_table_info_api_v1_database_tables_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Get Table Info Api V1 Database Tables Get"}}}}}}},"/api/v1/database/cleanup/duplicates":{"post":{"tags":["database"],"summary":"Remove duplicate financial and metrics records","description":"Remove duplicate financial and metrics records, keeping the most recent real data.","operationId":"cleanup_duplicate_records_api_v1_database_cleanup_duplicates_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":true,"type":"object","title":"Response Cleanup Duplicate Records Api V1 Database Cleanup Duplicates Post"}}}}}}},"/api/v1/database/tickers":{"get":{"tags":["database"],"summary":"List tickers available in the database","description":"사용 가능한 종목 목록을 반환합니다.","operationId":"get_available_tickers_api_v1_database_tickers_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object","title":"Response Get Available Tickers Api V1 Database Tickers Get"}}}}}}},"/api/v1/database/etf/snapshots":{"get":{"tags":["database"],"summary":"List persisted ETF holdings snapshots","description":"Browse ETF holdings snapshots stored in the database. Each snapshot represents\n the portfolio as reported in a SEC 13-F filing.\n\n Filter by `ticker`, `start_date`, `end_date`. Results are ordered by snapshot date (newest first).\n\n **Example**: `GET /database/etf/snapshots?ticker=SPY&limit=10`","operationId":"list_etf_snapshots_api_v1_database_etf_snapshots_get","parameters":[{"name":"ticker","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ticker"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Start Date"}},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"End Date"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","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/v1/database/etf/snapshot/{snapshot_id}":{"get":{"tags":["database"],"summary":"Get ETF snapshot with full holdings list","operationId":"get_etf_snapshot_api_v1_database_etf_snapshot__snapshot_id__get","parameters":[{"name":"snapshot_id","in":"path","required":true,"schema":{"type":"string","title":"Snapshot Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/database/financial/records":{"get":{"tags":["database"],"summary":"Browse raw financial data records","description":"List raw financial data rows from the `financial_data` table.\n\n Supports filtering by `ticker`, `period_type` (`quarterly`/`annual`),\n `start_date`, and `end_date`. Results ordered by `period_date` descending.\n\n **Example**: `GET /database/financial/records?ticker=AAPL&period_type=quarterly&limit=8`","operationId":"list_financial_records_api_v1_database_financial_records_get","parameters":[{"name":"ticker","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ticker"}},{"name":"period_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period Type"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Start Date"}},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"End Date"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","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/v1/admin/errors/logs":{"get":{"tags":["error-logs"],"summary":"Get error logs","description":"Retrieve error logs with filtering and pagination options.\n \n **Filters:**\n - Date range (start_date, end_date)\n - Error type\n - Status code range\n - Endpoint pattern\n - Resolution status\n \n **Sorting:**\n - By date (newest first by default)\n - By status code\n - By response time\n \n **Pagination:**\n - Configurable page size (default: 50, max: 200)\n - Page-based navigation","operationId":"get_error_logs_api_v1_admin_errors_logs_get","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number","default":1,"title":"Page"},"description":"Page number"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"description":"Items per page","default":50,"title":"Page Size"},"description":"Items per page"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter by start date","title":"Start Date"},"description":"Filter by start date"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter by end date","title":"End Date"},"description":"Filter by end date"},{"name":"error_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by error type","title":"Error Type"},"description":"Filter by error type"},{"name":"status_code","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by status code","title":"Status Code"},"description":"Filter by status code"},{"name":"endpoint","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by endpoint (supports wildcards)","title":"Endpoint"},"description":"Filter by endpoint (supports wildcards)"},{"name":"is_resolved","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Filter by resolution status","title":"Is Resolved"},"description":"Filter by resolution status"},{"name":"sort_by","in":"query","required":false,"schema":{"type":"string","description":"Sort field: created_at, status_code, response_time_ms","default":"created_at","title":"Sort By"},"description":"Sort field: created_at, status_code, response_time_ms"},{"name":"sort_order","in":"query","required":false,"schema":{"type":"string","description":"Sort order: asc or desc","default":"desc","title":"Sort Order"},"description":"Sort order: asc or desc"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["error-logs"],"summary":"Delete all error logs","description":"Delete all error logs (use with caution)","operationId":"delete_all_error_logs_api_v1_admin_errors_logs_delete","parameters":[{"name":"confirm","in":"query","required":false,"schema":{"type":"boolean","description":"Must be true to confirm deletion","default":false,"title":"Confirm"},"description":"Must be true to confirm deletion"},{"name":"only_resolved","in":"query","required":false,"schema":{"type":"boolean","description":"Only delete resolved errors","default":false,"title":"Only Resolved"},"description":"Only delete resolved errors"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/errors/logs/{log_id}":{"get":{"tags":["error-logs"],"summary":"Get error log by ID","description":"Retrieve detailed information about a specific error log","operationId":"get_error_log_api_v1_admin_errors_logs__log_id__get","parameters":[{"name":"log_id","in":"path","required":true,"schema":{"type":"integer","title":"Log Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["error-logs"],"summary":"Update error log","description":"Update error log resolution status and notes","operationId":"update_error_log_api_v1_admin_errors_logs__log_id__patch","parameters":[{"name":"log_id","in":"path","required":true,"schema":{"type":"integer","title":"Log Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/errors/by-request/{request_id}":{"get":{"tags":["error-logs"],"summary":"Get error log by request ID","description":"Retrieve error log information for a specific request ID","operationId":"get_error_by_request_id_api_v1_admin_errors_by_request__request_id__get","parameters":[{"name":"request_id","in":"path","required":true,"schema":{"type":"string","title":"Request Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/errors/stats":{"get":{"tags":["error-logs"],"summary":"Get error statistics","description":"Get aggregated statistics about errors.\n \n **Statistics include:**\n - Total error count\n - Errors by type\n - Errors by status code\n - Errors by endpoint\n - Time-based trends\n - Resolution rate","operationId":"get_error_stats_api_v1_admin_errors_stats_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Start date for statistics","title":"Start Date"},"description":"Start date for statistics"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"End date for statistics","title":"End Date"},"description":"End date for statistics"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorLogStats"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/errors/logs/old":{"delete":{"tags":["error-logs"],"summary":"Delete old error logs","description":"Delete error logs older than specified days","operationId":"delete_old_logs_api_v1_admin_errors_logs_old_delete","parameters":[{"name":"days_old","in":"query","required":false,"schema":{"type":"integer","maximum":365,"minimum":1,"description":"Delete logs older than this many days","default":30,"title":"Days Old"},"description":"Delete logs older than this many days"},{"name":"only_resolved","in":"query","required":false,"schema":{"type":"boolean","description":"Only delete resolved errors","default":true,"title":"Only Resolved"},"description":"Only delete resolved errors"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/requests/logs":{"get":{"tags":["request-logs"],"summary":"Get request logs","description":"Retrieve request logs with filtering and pagination options.\n \n **Filters:**\n - Date range (start_date, end_date)\n - HTTP method\n - Status code range\n - Endpoint pattern\n - Response time range\n \n **Sorting:**\n - By date (newest first by default)\n - By status code\n - By response time\n \n **Pagination:**\n - Configurable page size (default: 50, max: 200)\n - Page-based navigation","operationId":"get_request_logs_api_v1_admin_requests_logs_get","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number","default":1,"title":"Page"},"description":"Page number"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"description":"Items per page","default":50,"title":"Page Size"},"description":"Items per page"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter by start date","title":"Start Date"},"description":"Filter by start date"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter by end date","title":"End Date"},"description":"Filter by end date"},{"name":"method","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by HTTP method","title":"Method"},"description":"Filter by HTTP method"},{"name":"status_code","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by exact status code","title":"Status Code"},"description":"Filter by exact status code"},{"name":"min_status_code","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by minimum status code (e.g. 500 for all 5xx)","title":"Min Status Code"},"description":"Filter by minimum status code (e.g. 500 for all 5xx)"},{"name":"max_status_code","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by maximum status code (e.g. 599 for all 5xx)","title":"Max Status Code"},"description":"Filter by maximum status code (e.g. 599 for all 5xx)"},{"name":"endpoint","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by endpoint (supports wildcards)","title":"Endpoint"},"description":"Filter by endpoint (supports wildcards)"},{"name":"min_response_time","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Minimum response time in ms","title":"Min Response Time"},"description":"Minimum response time in ms"},{"name":"max_response_time","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Maximum response time in ms","title":"Max Response Time"},"description":"Maximum response time in ms"},{"name":"sort_by","in":"query","required":false,"schema":{"type":"string","description":"Sort field: created_at, status_code, response_time_ms","default":"created_at","title":"Sort By"},"description":"Sort field: created_at, status_code, response_time_ms"},{"name":"sort_order","in":"query","required":false,"schema":{"type":"string","description":"Sort order: asc or desc","default":"desc","title":"Sort Order"},"description":"Sort order: asc or desc"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestLogListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["request-logs"],"summary":"Delete all request logs","description":"Delete all request logs (use with caution)","operationId":"delete_all_request_logs_api_v1_admin_requests_logs_delete","parameters":[{"name":"confirm","in":"query","required":false,"schema":{"type":"boolean","description":"Must be true to confirm deletion","default":false,"title":"Confirm"},"description":"Must be true to confirm deletion"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/requests/logs/{log_id}":{"get":{"tags":["request-logs"],"summary":"Get request log by ID","description":"Retrieve detailed information about a specific request log","operationId":"get_request_log_api_v1_admin_requests_logs__log_id__get","parameters":[{"name":"log_id","in":"path","required":true,"schema":{"type":"integer","title":"Log Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/requests/stats":{"get":{"tags":["request-logs"],"summary":"Get request statistics","description":"Get aggregated statistics about API requests.\n \n **Statistics include:**\n - Total request count\n - Success/error rates\n - Requests by method\n - Requests by status code\n - Requests by endpoint\n - Time-based trends\n - Average response time","operationId":"get_request_stats_api_v1_admin_requests_stats_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Start date for statistics","title":"Start Date"},"description":"Start date for statistics"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"End date for statistics","title":"End Date"},"description":"End date for statistics"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestLogStats"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/admin/requests/logs/old":{"delete":{"tags":["request-logs"],"summary":"Delete old request logs","description":"Delete request logs older than specified days","operationId":"delete_old_request_logs_api_v1_admin_requests_logs_old_delete","parameters":[{"name":"days_old","in":"query","required":false,"schema":{"type":"integer","maximum":365,"minimum":1,"description":"Delete logs older than this many days","default":30,"title":"Days Old"},"description":"Delete logs older than this many days"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/alpaca/status":{"get":{"tags":["alpaca"],"summary":"Alpaca connection status","description":"Check Alpaca API key validity and connection health.","operationId":"alpaca_status_api_v1_alpaca_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/alpaca/intraday":{"get":{"tags":["alpaca"],"summary":"Get historical intraday bars for multiple tickers (SIP feed, DB-backed)","description":"멀티 종목 과거 분봉 데이터를 Alpaca **SIP 피드**로 가져옵니다. DB에 저장되며 재요청 시 Alpaca 미호출.\n\n**⚠️ 장 중 당일 데이터 불가** — 장 마감(오후 4시 ET) 후에는 당일 날짜도 조회 가능\n\n| 항목 | 내용 |\n|------|------|\n| 피드 | **SIP** (전체 미국 거래소 통합) |\n| 거래량 | **100%** 정확 |\n| 조회 범위 | **2016년~오늘(장 마감 후)** |\n| DB 저장 | 있음 (재요청 시 Alpaca 미사용) |\n\n**권장 용도**: 백테스트, 과거 분봉 분석\n\n- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n- 내부 100개 단위 자동 배치 분할 (500종목 → Alpaca 5회 호출)\n- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`","operationId":"get_alpaca_intraday_multi_api_v1_alpaca_intraday_get","parameters":[{"name":"tickers","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B","title":"Tickers"},"description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Interval: 1m, 5m, 15m, 30m, 1h","default":"5m","title":"Interval"},"description":"Interval: 1m, 5m, 15m, 30m, 1h"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Start date (YYYY-MM-DD). Default: yesterday","title":"Start Date"},"description":"Start date (YYYY-MM-DD). Default: yesterday"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"End date (YYYY-MM-DD). Must be before today. Default: yesterday","title":"End Date"},"description":"End date (YYYY-MM-DD). Must be before today. Default: yesterday"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Re-fetch from Alpaca even if DB has data","default":false,"title":"Force Refresh"},"description":"Re-fetch from Alpaca even if DB has data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiBarsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/alpaca/intraday/today":{"get":{"tags":["alpaca"],"summary":"Get today's real-time intraday bars for multiple tickers (IEX feed, DB-backed)","description":"당일(오늘) 실시간 분봉 데이터를 Alpaca **IEX 피드**로 가져옵니다. 장 중 재요청 시 항상 Alpaca에서 최신 데이터를 가져옵니다.\n\n**⚠️ 오늘 데이터만 조회 가능** — 과거 데이터는 `/intraday` 사용\n\n| 항목 | 내용 |\n|------|------|\n| 피드 | **IEX** (IEX 거래소 단일) |\n| 지연 | **실시간** (지연 없음) |\n| 거래량 | 실제의 약 **2~5%** (IEX 거래소 거래만 집계) |\n| High/Low range | SIP 대비 좁게 표시될 수 있음 |\n| DB 저장 | 있음 (장 중 항상 재조회) |\n\n**권장 용도**: 당일 ORB 전략, 실시간 장 중 모니터링\n\n- `tickers`: comma-separated, e.g. `AAPL,MSFT,BF-B`\n- `interval`: `1m`, `5m`, `15m`, `30m`, `1h`\n- 내부 100개 단위 자동 배치 분할\n- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`","operationId":"get_alpaca_intraday_today_api_v1_alpaca_intraday_today_get","parameters":[{"name":"tickers","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B","title":"Tickers"},"description":"Comma-separated tickers, e.g. AAPL,MSFT,BF-B"},{"name":"interval","in":"query","required":false,"schema":{"type":"string","description":"Interval: 1m, 5m, 15m, 30m, 1h","default":"5m","title":"Interval"},"description":"Interval: 1m, 5m, 15m, 30m, 1h"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiBarsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/alpaca/snapshot":{"get":{"tags":["alpaca"],"summary":"Real-time snapshots for multiple tickers (IEX feed)","description":"멀티 종목 실시간 스냅샷. 최신 체결가, bid/ask, 당일 OHLCV, 전일 대비 변동률 포함.\n\n단일 종목도 `?tickers=AAPL`로 조회 가능.\n\n| 항목 | 내용 |\n|------|------|\n| 피드 | **IEX** — 무료 플랜에서 snapshot은 SIP 불가 |\n| 지연 | **실시간** (지연 없음) |\n| 거래량 | IEX 기준 (실제의 2~5%) |\n| 캐시 | **없음** — 매 요청마다 Alpaca 직접 호출 |\n\n- Requires `ALPACA_API_KEY` / `ALPACA_SECRET_KEY`","operationId":"get_snapshots_api_v1_alpaca_snapshot_get","parameters":[{"name":"tickers","in":"query","required":true,"schema":{"type":"string","description":"Comma-separated ticker symbols, e.g. AAPL,MSFT,NVDA","title":"Tickers"},"description":"Comma-separated ticker symbols, e.g. AAPL,MSFT,NVDA"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlpacaMultiSnapshotResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/finra/short-volume/{symbol}":{"get":{"tags":["finra"],"summary":"Get short volume data for a symbol","description":"Query FINRA RegSHO short sale volume. Auto-ingests if data is missing.\n\n**DB 보유**: 2021년 ~ 현재 (5년치 백필 완료). 추가 백필: `POST /finra/admin/ingest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD`\n\n**데이터 소스**: FINRA RegSHO CDN (공개, API 키 불필요). 주말/공휴일 데이터 없음.","operationId":"get_short_volume_api_v1_finra_short_volume__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":3650,"minimum":1,"description":"Number of days to look back (max ~10 years)","default":30,"title":"Days"},"description":"Number of days to look back (max ~10 years)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":10000,"minimum":1,"description":"Max entries to return","default":100,"title":"Limit"},"description":"Max entries to return"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShortVolumeResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/finra/short-ratio/{symbol}":{"get":{"tags":["finra"],"summary":"Get short ratio history for a symbol","description":"Return daily short_ratio (aggregated across markets) for the last N days.\n\n**DB 보유**: 2021년 ~ 현재 (5년치). days 최대 3650 (10년).\n\n추가 백필: `POST /finra/admin/ingest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD`","operationId":"get_short_ratio_api_v1_finra_short_ratio__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":3650,"minimum":1,"description":"Number of days (max ~10 years)","default":60,"title":"Days"},"description":"Number of days (max ~10 years)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShortRatioHistoryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/finra/admin/ingest":{"post":{"tags":["finra"],"summary":"Manually ingest FINRA short volume data","description":"Download and ingest FINRA short volume file(s) for a specific date or date range.\n\n**백필 예시**:\n- 단일 날짜: `?date=2025-01-15`\n- 날짜 범위: `?start_date=2025-01-01&end_date=2025-12-31`\n- 이미 있는 데이터 재인제스트: `?start_date=...&end_date=...&force=true`\n\n주말/공휴일은 자동으로 건너뜀. 1년치 기준 약 20-40분 소요.","operationId":"ingest_short_volume_api_v1_finra_admin_ingest_post","parameters":[{"name":"date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Single date (YYYY-MM-DD)","title":"Date"},"description":"Single date (YYYY-MM-DD)"},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Range start (YYYY-MM-DD)","title":"Start Date"},"description":"Range start (YYYY-MM-DD)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Range end (YYYY-MM-DD)","title":"End Date"},"description":"Range end (YYYY-MM-DD)"},{"name":"force","in":"query","required":false,"schema":{"type":"boolean","description":"Re-ingest even if data exists","default":false,"title":"Force"},"description":"Re-ingest even if data exists"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/admin/job-log":{"get":{"tags":["overlay","overlay-admin"],"summary":"Overlay job log","operationId":"get_job_log_api_v1_overlay_admin_job_log_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobLogResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/overlay/{symbol}/headlines":{"get":{"tags":["overlay"],"summary":"Recent headlines for a symbol","operationId":"get_headlines_api_v1_overlay__symbol__headlines_get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"hours","in":"query","required":false,"schema":{"type":"integer","maximum":168,"minimum":1,"default":24,"title":"Hours"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/app__api__v1__endpoints__overlay__HeadlinesResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/screener/stocks":{"get":{"tags":["screener"],"summary":"Screen stocks by financial criteria","description":"Screen stocks based on financial criteria using yfinance.\n\nFilters stocks from US exchanges (NYSE, NASDAQ, AMEX, NYSE_ARCA) by market cap,\nvolume, price, P/E ratio, sector, and more. Results are paginated and cached for\n5 minutes.\n\n**Exchange mapping**:\n- `NYSE` → NYQ\n- `NASDAQ` → NMS, NGM, NCM\n- `AMEX` → ASE\n- `NYSE_ARCA` → PCX\n\n**Important limitations**:\n- `page_size` maximum is 250 (Yahoo Finance API limit)\n- `sector` filtering works but sector is NOT returned per-stock in the response\n- Results reflect real-time Yahoo Finance data\n\n**Example**:\n```\nGET /screener/stocks?market_cap_min=500000000&market_cap_max=10000000000\n &exchange=NYSE,NASDAQ&min_avg_volume=500000&exclude_types=ETF,FUND\n &sort_by=market_cap&page=1&page_size=100\n```","operationId":"screen_stocks_api_v1_screener_stocks_get","parameters":[{"name":"market_cap_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Minimum market cap in USD (e.g. 500000000 for $500M)","title":"Market Cap Min"},"description":"Minimum market cap in USD (e.g. 500000000 for $500M)"},{"name":"market_cap_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Maximum market cap in USD (e.g. 10000000000 for $10B)","title":"Market Cap Max"},"description":"Maximum market cap in USD (e.g. 10000000000 for $10B)"},{"name":"exchange","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated exchange names: NYSE, NASDAQ, AMEX, NYSE_ARCA. Omit for all US exchanges.","title":"Exchange"},"description":"Comma-separated exchange names: NYSE, NASDAQ, AMEX, NYSE_ARCA. Omit for all US exchanges."},{"name":"min_avg_volume","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","minimum":0},{"type":"null"}],"description":"Minimum 3-month average daily volume (e.g. 500000)","title":"Min Avg Volume"},"description":"Minimum 3-month average daily volume (e.g. 500000)"},{"name":"exclude_types","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Comma-separated quote types to exclude (e.g. ETF,FUND). Only EQUITY results are kept when specified.","title":"Exclude Types"},"description":"Comma-separated quote types to exclude (e.g. ETF,FUND). Only EQUITY results are kept when specified."},{"name":"sector","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by sector (e.g. Technology, Healthcare, 'Financial Services'). Note: sector is not returned per-stock in the response.","title":"Sector"},"description":"Filter by sector (e.g. Technology, Healthcare, 'Financial Services'). Note: sector is not returned per-stock in the response."},{"name":"pe_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Minimum trailing P/E ratio","title":"Pe Min"},"description":"Minimum trailing P/E ratio"},{"name":"pe_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Maximum trailing P/E ratio","title":"Pe Max"},"description":"Maximum trailing P/E ratio"},{"name":"price_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Minimum stock price in USD","title":"Price Min"},"description":"Minimum stock price in USD"},{"name":"price_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number","minimum":0},{"type":"null"}],"description":"Maximum stock price in USD","title":"Price Max"},"description":"Maximum stock price in USD"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number (1-based)","default":1,"title":"Page"},"description":"Page number (1-based)"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":250,"minimum":1,"description":"Results per page (max 250, Yahoo API limit)","default":100,"title":"Page Size"},"description":"Results per page (max 250, Yahoo API limit)"},{"name":"sort_by","in":"query","required":false,"schema":{"type":"string","description":"Sort field: market_cap, volume, avg_volume, price, pe_ratio, change_percent, name, eps, dividend_yield, forward_pe, price_to_book","default":"market_cap","title":"Sort By"},"description":"Sort field: market_cap, volume, avg_volume, price, pe_ratio, change_percent, name, eps, dividend_yield, forward_pe, price_to_book"},{"name":"sort_ascending","in":"query","required":false,"schema":{"type":"boolean","description":"Sort ascending (default: descending)","default":false,"title":"Sort Ascending"},"description":"Sort ascending (default: descending)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and fetch fresh data","default":false,"title":"Force Refresh"},"description":"Bypass cache and fetch fresh data"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/screener/fields":{"get":{"tags":["screener"],"summary":"Available screener filter options and valid values","description":"Return metadata about available screener filter options.\n\nUseful for building dynamic filter UIs — lists all valid exchange names,\nsectors, sort fields, and parameter descriptions.","operationId":"get_screener_fields_api_v1_screener_fields_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/attention/admin/resolve/{ticker}":{"post":{"tags":["attention","attention-admin"],"summary":"Resolve ticker → canonical entity","description":"Maps a ticker symbol to a canonical company entity by looking up the company name, normalizing it, and validating against Wikipedia. Stores the result (canonical name, wiki_title, gdelt_query) in `company_entity_map`.\n\nSkips re-resolution if `is_manual_override` is set. If the company name in the DB is a placeholder (e.g. 'AMZN Corporation'), falls back to SEC company_tickers.json to fetch the real name and updates the DB.","operationId":"admin_resolve_entity_api_v1_attention_admin_resolve__ticker__post","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/attention/admin/collect/wiki/{ticker}":{"post":{"tags":["attention","attention-admin"],"summary":"Collect Wikipedia pageviews for an event date","description":"Fetches daily Wikipedia pageview counts for the ticker's canonical wiki_title, covering `event_date` and enough lookback days (≥20) to compute spike and z-score. Safe to call on-demand — Wikipedia API has no meaningful rate limit for this use.\n\nRequires entity resolution to have been run first (`wiki_title` must be set).","operationId":"admin_collect_wiki_api_v1_attention_admin_collect_wiki__ticker__post","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"event_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Event date in YYYY-MM-DD format","title":"Event Date"},"description":"Event date in YYYY-MM-DD format"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionStatusResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/attention/admin/collect/gdelt/{ticker}":{"post":{"tags":["attention","attention-admin"],"summary":"Collect GDELT news articles for an event date","description":"Fetches news articles from GDELT V2 DOC API for the window `event_date ± 1 day`.\n\n**Coverage**: 2017-01-01 onwards. Requests for earlier dates return 0 immediately.\n\n**Rate limit**: GDELT enforces a global per-IP quota. This endpoint is protected by a process-wide lock (10s minimum interval) and retries with exponential backoff (30s → 60s → 120s) on 429 responses.\n\n⚠️ **Call this endpoint from a scheduler only** — never trigger it in response to user requests. Concurrent or rapid calls will exhaust the IP quota and cause temporary bans. The main `/event/{ticker}` endpoint intentionally does NOT collect GDELT on-demand for this reason.","operationId":"admin_collect_gdelt_api_v1_attention_admin_collect_gdelt__ticker__post","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"event_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Event date in YYYY-MM-DD format","title":"Event Date"},"description":"Event date in YYYY-MM-DD format"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CollectionStatusResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/attention/entity/{ticker}":{"get":{"tags":["attention"],"summary":"Get entity mapping for a ticker","description":"Returns the stored entity mapping for a ticker: canonical name, Wikipedia title,\n GDELT query string, and resolver confidence score.\n\n Returns **404** if no mapping exists — run `POST /admin/resolve/{ticker}` first.\n\n **Example**: `GET /attention/entity/AAPL`","operationId":"get_entity_api_v1_attention_entity__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EntityResolveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/attention/event/{ticker}":{"get":{"tags":["attention"],"summary":"Get attention features for a ticker on an event date","description":"Returns Wikipedia pageview spike/z-score and GDELT news volume for a ticker\n centered on a specific event date. Designed for event-driven backtesting.\n\n **Wikipedia signals** (collected on-demand):\n - `wiki.views` — raw pageview count on `event_date`\n - `wiki.spike_10d` — views / 10-day median baseline; >1 = above-average interest\n - `wiki.zscore_20d` — standard-deviation units above 20-day mean\n\n **GDELT news signals** (pre-populated by scheduler only):\n - `news.article_count_1d` — articles published on `event_date`\n - `news.article_count_3d` — articles in `event_date ± 1 day` window\n - `news.unique_domains_3d` — distinct publisher domains in that window\n - `news.gdelt_status` — data availability flag:\n - `collected` — scheduler ran; counts are accurate (0 = genuinely no articles)\n - `not_collected` — scheduler has not run yet; use `POST /admin/collect/gdelt/{ticker}`\n - `not_available` — event date is before GDELT V2 coverage (2017-01-01)\n\n **Auto-resolution**: if no entity mapping exists, resolution runs automatically first.\n\n **Examples**:\n - `GET /attention/event/AAPL?event_date=2024-02-01` — Q1 earnings day attention\n - `GET /attention/event/NVDA?event_date=2024-05-22` — post-earnings spike","operationId":"get_event_attention_api_v1_attention_event__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"event_date","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Event date in YYYY-MM-DD format","title":"Event Date"},"description":"Event date in YYYY-MM-DD format"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EventAttentionResponse"}}}},"404":{"description":"Ticker not found or entity resolution failed"},"500":{"description":"Feature materialization or collection error"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/insider/transactions/{symbol}":{"get":{"tags":["insider"],"summary":"Get insider transactions for a symbol","description":"Query SEC Form 4 insider trading data. Auto-fetches from SEC EDGAR if data is missing.\n\n**데이터 소스**: SEC EDGAR (무료, API 키 불필요). 첫 조회 시 자동 인덱싱.\n\n**Transaction codes**: P=Purchase, S=Sale, A=Award, M=Exercise, G=Gift, F=Tax Withholding","operationId":"get_insider_transactions_api_v1_insider_transactions__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":3650,"minimum":1,"description":"Days to look back (max ~10 years)","default":90,"title":"Days"},"description":"Days to look back (max ~10 years)"},{"name":"transaction_type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter: P=Purchase, S=Sale, A=Award, M=Exercise","title":"Transaction Type"},"description":"Filter: P=Purchase, S=Sale, A=Award, M=Exercise"},{"name":"insider_title","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by title keyword (e.g., CEO, CFO, Director)","title":"Insider Title"},"description":"Filter by title keyword (e.g., CEO, CFO, Director)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"description":"Max entries to return","default":50,"title":"Limit"},"description":"Max entries to return"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and re-fetch from SEC","default":false,"title":"Force Refresh"},"description":"Bypass cache and re-fetch from SEC"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsiderTransactionResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/insider/summary/{symbol}":{"get":{"tags":["insider"],"summary":"Get insider trading summary","description":"Aggregated insider buy/sell activity for 3, 6, and 12 month periods.\n\nIncludes net buy/sell shares and values, plus top 5 notable transactions by value.","operationId":"get_insider_summary_api_v1_insider_summary__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache","default":false,"title":"Force Refresh"},"description":"Bypass cache"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsiderSummaryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/insider/form4/{ticker}":{"get":{"tags":["insider"],"summary":"PIT-safe Form 4 insider transactions","description":"Returns Form 4 transactions for a ticker where **filing_date ≤ as_of** (point-in-time safe).\n\n`as_of` is required to prevent lookahead in backtests.\n\n`start`/`end` also filter by `filing_date` (not transaction_date).\n\nIf no data exists for the ticker, auto-fetches ~2 years of history from SEC EDGAR (first call may take 1–3 min).","operationId":"get_form4_api_v1_insider_form4__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"as_of","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Point-in-time cutoff (filing_date ≤ as_of). Required.","title":"As Of"},"description":"Point-in-time cutoff (filing_date ≤ as_of). Required."},{"name":"start","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Window start (filing_date ≥ start)","title":"Start"},"description":"Window start (filing_date ≥ start)"},{"name":"end","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Window end (filing_date ≤ end)","title":"End"},"description":"Window end (filing_date ≤ end)"},{"name":"buy_only","in":"query","required":false,"schema":{"type":"boolean","description":"Only return open-market purchases (transaction_code=P, shares > 0). Excludes awards/grants.","default":false,"title":"Buy Only"},"description":"Only return open-market purchases (transaction_code=P, shares > 0). Excludes awards/grants."},{"name":"csuite_only","in":"query","required":false,"schema":{"type":"boolean","description":"Only return C-suite insider transactions","default":false,"title":"Csuite Only"},"description":"Only return C-suite insider transactions"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Re-fetch ~2yr of Form 4 history from SEC EDGAR before querying. Slow on first call.","default":false,"title":"Force Refresh"},"description":"Re-fetch ~2yr of Form 4 history from SEC EDGAR before querying. Slow on first call."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Form4Response"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/insider/form4/by-date/{filing_date}":{"get":{"tags":["insider"],"summary":"Form 4 filings by a specific date (cross-ticker)","description":"Returns all Form 4 transactions where filing_date equals the given date. Useful for pre-market screening.","operationId":"get_form4_by_date_api_v1_insider_form4_by_date__filing_date__get","parameters":[{"name":"filing_date","in":"path","required":true,"schema":{"type":"string","format":"date","title":"Filing Date"}},{"name":"buy_only","in":"query","required":false,"schema":{"type":"boolean","description":"Only return open-market purchases (transaction_code=P). Excludes awards/grants.","default":false,"title":"Buy Only"},"description":"Only return open-market purchases (transaction_code=P). Excludes awards/grants."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Form4ByDateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/insider/form4/aggregate/{ticker}":{"get":{"tags":["insider"],"summary":"Aggregate Form 4 buy activity (PIT-safe)","description":"Aggregated insider buy metrics within [as_of - window_days, as_of].\n\nAll based on `filing_date` (PIT-safe). Returns buy_count, buy_dollar_total, cluster_size (unique insiders), csuite_count, avg_pct_of_holding, recency_days.\n\nIf no data exists for the ticker, auto-fetches ~2 years of history from SEC EDGAR.","operationId":"get_form4_aggregate_api_v1_insider_form4_aggregate__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"as_of","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Point-in-time cutoff. Required.","title":"As Of"},"description":"Point-in-time cutoff. Required."},{"name":"window_days","in":"query","required":false,"schema":{"type":"integer","maximum":365,"minimum":1,"description":"Lookback window in days","default":30,"title":"Window Days"},"description":"Lookback window in days"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Re-fetch ~2yr of Form 4 history from SEC EDGAR before querying.","default":false,"title":"Force Refresh"},"description":"Re-fetch ~2yr of Form 4 history from SEC EDGAR before querying."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Form4AggregateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/earnings/calendar/{symbol}":{"get":{"tags":["earnings"],"summary":"Get upcoming earnings dates for a symbol","description":"Upcoming earnings announcement dates with EPS estimates.\n\n**데이터 소스**: yfinance-plus (`Ticker.earnings_dates`). API 키 불필요.\n**earnings_time**: `pre_market` / `post_market` / `during_market` / `unknown`.\n\n**PIT (Point-in-Time) backtesting**: `as_of_date`를 지정하면 해당 날짜 기준 upcoming earnings를 반환합니다. 이미 보고된 earnings도 당시엔 예정이었으므로 `reported_eps`가 채워진 상태로 반환됩니다.\n\n**Note**: Revenue estimates are not available from this source.","operationId":"get_earnings_calendar_api_v1_earnings_calendar__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"days_ahead","in":"query","required":false,"schema":{"type":"integer","maximum":365,"minimum":1,"description":"Days to look ahead from as_of_date (or today)","default":30,"title":"Days Ahead"},"description":"Days to look ahead from as_of_date (or today)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":20,"minimum":1,"description":"Max earnings dates to return","default":4,"title":"Limit"},"description":"Max earnings dates to return"},{"name":"as_of_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"PIT date for backtesting (YYYY-MM-DD). Defaults to today.","title":"As Of Date"},"description":"PIT date for backtesting (YYYY-MM-DD). Defaults to today."},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and re-fetch from yfinance","default":false,"title":"Force Refresh"},"description":"Bypass cache and re-fetch from yfinance"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarningsCalendarResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/earnings/calendar/bulk":{"post":{"tags":["earnings"],"summary":"Bulk future earnings calendar","description":"Fetch upcoming earnings dates for multiple symbols (max 50).\n\nReturns a flat list of calendar entries sorted by `earnings_date` ascending.\nUseful for checking upcoming earnings of sector peers or candidates.","operationId":"get_bulk_earnings_calendar_api_v1_earnings_calendar_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkEarningsCalendarRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkEarningsCalendarResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/earnings/surprise/{symbol}":{"get":{"tags":["earnings"],"summary":"Get earnings surprise history","description":"Quarterly EPS surprise: reported vs analyst consensus estimate.\n\n**데이터 소스**: yfinance-plus (`Ticker.earnings_dates`). API 키 불필요.\n**커버리지**: ~25분기 (6년+). 첫 조회 시 자동 인덱싱.\n\n**surprise** = reported_eps - estimated_eps.\n**surprise_percentage** = (surprise / estimated) × 100.\n**streak**: 연속 beat (양수) 또는 miss (음수) 횟수.","operationId":"get_earnings_surprise_api_v1_earnings_surprise__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"quarters","in":"query","required":false,"schema":{"type":"integer","maximum":40,"minimum":1,"description":"Number of recent quarters (max ~25 available)","default":8,"title":"Quarters"},"description":"Number of recent quarters (max ~25 available)"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"Bypass cache and re-fetch from yfinance","default":false,"title":"Force Refresh"},"description":"Bypass cache and re-fetch from yfinance"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EarningsSurpriseResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/universe/screen":{"get":{"tags":["universe"],"summary":"Screen stocks at a historical date","description":"Query monthly market_cap snapshots to find stocks matching criteria at a past date.\\n\\n**용도**: 백테스팅 전략 유니버스 구성 — 특정 시점 시총/섹터 기준 종목 필터링.\\n\\n**데이터 소스**: SEC EDGAR shares_outstanding × yfinance monthly close.\\n**제한**: 현재 상장 종목만 포함 (survivorship bias). 상폐 종목 미포함.\\n\\n**사전 조건**: `/universe/admin/discover` 후 `/universe/admin/build-snapshots` 실행 필요.","operationId":"screen_historical_api_v1_universe_screen_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Historical date YYYY-MM-DD (rounded to month start)","title":"Date"},"description":"Historical date YYYY-MM-DD (rounded to month start)"},{"name":"market_cap_min","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Min market cap (USD), e.g. 2e9","title":"Market Cap Min"},"description":"Min market cap (USD), e.g. 2e9"},{"name":"market_cap_max","in":"query","required":false,"schema":{"anyOf":[{"type":"number"},{"type":"null"}],"description":"Max market cap (USD), e.g. 20e9","title":"Market Cap Max"},"description":"Max market cap (USD), e.g. 20e9"},{"name":"sector","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sector filter (e.g. Technology, Healthcare)","title":"Sector"},"description":"Sector filter (e.g. Technology, Healthcare)"},{"name":"exchange","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Exchange filter (NYSE, NASDAQ, AMEX)","title":"Exchange"},"description":"Exchange filter (NYSE, NASDAQ, AMEX)"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number","default":1,"title":"Page"},"description":"Page number"},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"description":"Results per page","default":100,"title":"Page Size"},"description":"Results per page"},{"name":"sort_by","in":"query","required":false,"schema":{"type":"string","description":"Sort field: market_cap or ticker","default":"market_cap","title":"Sort By"},"description":"Sort field: market_cap or ticker"},{"name":"sort_ascending","in":"query","required":false,"schema":{"type":"boolean","description":"Sort direction","default":false,"title":"Sort Ascending"},"description":"Sort direction"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UniverseScreenResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/universe/registry":{"get":{"tags":["universe"],"summary":"Browse registered ticker universe","description":"List tickers registered in the universe (populated via /admin/discover).","operationId":"get_registry_api_v1_universe_registry_get","parameters":[{"name":"sector","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by sector","title":"Sector"},"description":"Filter by sector"},{"name":"exchange","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by exchange","title":"Exchange"},"description":"Filter by exchange"},{"name":"is_active","in":"query","required":false,"schema":{"anyOf":[{"type":"boolean"},{"type":"null"}],"description":"Filter by active status","title":"Is Active"},"description":"Filter by active status"},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"page_size","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"default":100,"title":"Page Size"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegistryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/universe/admin/discover":{"post":{"tags":["universe"],"summary":"Discover and register US tickers","description":"Scrapes US-listed stocks via yfinance screener and registers them in the universe.\\n\\n**소요 시간**: 약 1~5분 (시총 기준에 따라 다름).\\n**권장**: `market_cap_min=100000000` ($100M) → ~3000~5000 종목.","operationId":"discover_tickers_api_v1_universe_admin_discover_post","parameters":[{"name":"market_cap_min","in":"query","required":false,"schema":{"type":"number","description":"Min market cap for inclusion (USD). Default $100M.","default":100000000.0,"title":"Market Cap Min"},"description":"Min market cap for inclusion (USD). Default $100M."}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/universe/admin/build-snapshots":{"post":{"tags":["universe"],"summary":"Build monthly market_cap snapshots","description":"Computes monthly market_cap snapshots for registered tickers and stores them in `universe_snapshot`.\\n\\n**데이터 소스**: SEC EDGAR companyfacts (shares_outstanding) + yfinance monthly close.\\n\\n**소요 시간**: 전체 유니버스(~4000 종목) × 10년 기준 30~60분. 백그라운드에서 실행되므로 응답은 즉시 반환됩니다.\\n\\n**권장 시작점**: `tickers=[AAPL,MSFT,GOOGL]`로 소규모 테스트 후 전체 빌드.","operationId":"build_snapshots_api_v1_universe_admin_build_snapshots_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotBuildRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/dividends/upcoming":{"get":{"tags":["dividends"],"summary":"PIT upcoming ex-dividend calendar","description":"Point-in-Time 배당락 캘린더. `as_of_date` 기준으로 당시 알려져 있었던 배당 일정 중 `from_ex_date` ~ `to_ex_date` 범위의 ex-date를 반환.\n\n**PIT 의미**: 같은 (ticker, ex_date)에 여러 revision이 있으면 `as_of_date <= query_as_of_date` 조건 내에서 가장 최신 revision만 반환.\n\n**데이터 소스**: yfinance-plus. API 키 불필요. symbols 파라미터 없이 조회 시 이미 인덱싱된 종목 전체 반환.\n\n**백필**: `POST /dividends/admin/ingest` 로 원하는 종목 선인덱싱 가능.","operationId":"get_upcoming_dividends_api_v1_dividends_upcoming_get","parameters":[{"name":"as_of_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"PIT 기준일 (YYYY-MM-DD). 생략 시 오늘.","title":"As Of Date"},"description":"PIT 기준일 (YYYY-MM-DD). 생략 시 오늘."},{"name":"from_ex_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Ex-date 시작 (YYYY-MM-DD). 생략 시 오늘.","title":"From Ex Date"},"description":"Ex-date 시작 (YYYY-MM-DD). 생략 시 오늘."},{"name":"to_ex_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Ex-date 끝 (YYYY-MM-DD). 생략 시 오늘 + 60일.","title":"To Ex Date"},"description":"Ex-date 끝 (YYYY-MM-DD). 생략 시 오늘 + 60일."},{"name":"symbols","in":"query","required":false,"schema":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"description":"종목 필터 (e.g. ?symbols=AAPL&symbols=MSFT). 생략 시 전체.","title":"Symbols"},"description":"종목 필터 (e.g. ?symbols=AAPL&symbols=MSFT). 생략 시 전체."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":5000,"minimum":1,"description":"최대 반환 개수","default":500,"title":"Limit"},"description":"최대 반환 개수"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"캐시 무시","default":false,"title":"Force Refresh"},"description":"캐시 무시"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DividendUpcomingResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/dividends/history/{symbol}":{"get":{"tags":["dividends"],"summary":"종목별 배당 이력","description":"단일 종목의 전체 배당 이력. yfinance 데이터가 없으면 자동 인덱싱.\n\n각 ex-date별 최신 revision을 반환 (ex-date 내림차순).\n\n`annual_yield_estimate`: 최근 12개월 배당 합산액 (주가 대비 yield는 클라이언트 계산 필요).","operationId":"get_dividend_history_api_v1_dividends_history__symbol__get","parameters":[{"name":"symbol","in":"path","required":true,"schema":{"type":"string","title":"Symbol"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"description":"최대 반환 개수","default":100,"title":"Limit"},"description":"최대 반환 개수"},{"name":"force_refresh","in":"query","required":false,"schema":{"type":"boolean","description":"캐시 무시 + yfinance 재조회","default":false,"title":"Force Refresh"},"description":"캐시 무시 + yfinance 재조회"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DividendHistoryResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/dividends/admin/ingest":{"post":{"tags":["dividends"],"summary":"배당 데이터 벌크 인제스트","description":"yfinance에서 지정 종목 배당 이력을 가져와 DB에 저장.\n\n**예시**:\n- `{\"symbols\": [\"AAPL\", \"MSFT\", \"JNJ\"]}` — 신규 종목 인덱싱\n- `{\"symbols\": [...], \"force_refresh\": true}` — 기존 데이터 재인제스트\n\n종목당 약 25년치 이력. 100종목 기준 5~10분 소요 (yfinance rate limit).\n\n이미 인덱싱된 종목은 `force_refresh: false`일 때 건너뜀 (멱등성).","operationId":"ingest_dividends_api_v1_dividends_admin_ingest_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DividendIngestRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DividendIngestResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/company/{ticker}":{"get":{"tags":["company"],"summary":"Get company metadata","description":"Returns sector, industry, exchange, market_cap, country, and other metadata for a ticker. Valid tickers without financial statements still return 200. Unknown tickers return 404.","operationId":"get_company_api_v1_company__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CompanyMetadataResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/company/bulk":{"post":{"tags":["company"],"summary":"Bulk company metadata","description":"Fetch metadata for up to 100 tickers in one request. Partial failures are allowed — each item has either `data` or `error`.","operationId":"bulk_company_api_v1_company_bulk_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkCompanyRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkCompanyResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/ownership/13dg/active":{"get":{"tags":["ownership"],"summary":"Active activist positions as-of a date","description":"Returns the latest SC 13D/13G filing per (filer, issuer) pair where **filing_date ≤ as_of** and **ownership_pct ≥ min_ownership_pct**.\n\nPositions with `ownership_pct = null` (not yet enriched) are excluded.\n\n`as_of` is required.","operationId":"get_13dg_active_api_v1_ownership_13dg_active_get","parameters":[{"name":"as_of","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Point-in-time cutoff. Required.","title":"As Of"},"description":"Point-in-time cutoff. Required."},{"name":"min_ownership_pct","in":"query","required":false,"schema":{"type":"number","maximum":100.0,"minimum":0.0,"description":"Minimum ownership %","default":5.0,"title":"Min Ownership Pct"},"description":"Minimum ownership %"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivistActiveResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/ownership/13dg/{ticker}":{"get":{"tags":["ownership"],"summary":"SC 13D/13G activist ownership events for a ticker","description":"Returns SC 13D and SC 13G filings (including amendments) where **filing_date ≤ as_of**.\n\n`as_of` is required for PIT safety in backtests.\n\nNote: `ownership_pct` / `shares_owned` will be `null` until background enrichment runs (~30 min).","operationId":"get_13dg_events_api_v1_ownership_13dg__ticker__get","parameters":[{"name":"ticker","in":"path","required":true,"schema":{"type":"string","title":"Ticker"}},{"name":"as_of","in":"query","required":true,"schema":{"type":"string","format":"date","description":"Point-in-time cutoff (filing_date ≤ as_of). Required.","title":"As Of"},"description":"Point-in-time cutoff (filing_date ≤ as_of). Required."},{"name":"start","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Window start (filing_date ≥ start)","title":"Start"},"description":"Window start (filing_date ≥ start)"},{"name":"end","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"description":"Window end (filing_date ≤ end)","title":"End"},"description":"Window end (filing_date ≤ end)"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ActivistEventsResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"ActivistActiveResponse":{"properties":{"as_of":{"type":"string","format":"date","title":"As Of"},"min_ownership_pct":{"type":"number","title":"Min Ownership Pct"},"positions":{"items":{"$ref":"#/components/schemas/ActivistEventEntry"},"type":"array","title":"Positions"},"total_count":{"type":"integer","title":"Total Count"}},"type":"object","required":["as_of","min_ownership_pct","positions","total_count"],"title":"ActivistActiveResponse"},"ActivistEventEntry":{"properties":{"symbol":{"type":"string","title":"Symbol"},"filing_date":{"type":"string","format":"date","title":"Filing Date"},"filer_name":{"type":"string","title":"Filer Name"},"filer_cik":{"type":"string","title":"Filer Cik"},"form_type":{"type":"string","title":"Form Type"},"ownership_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Ownership Pct"},"shares_owned":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Shares Owned"},"is_amendment":{"type":"boolean","title":"Is Amendment","default":false},"change_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Change Pct"},"accession_number":{"type":"string","title":"Accession Number"},"parse_status":{"type":"string","title":"Parse Status"}},"type":"object","required":["symbol","filing_date","filer_name","filer_cik","form_type","accession_number","parse_status"],"title":"ActivistEventEntry"},"ActivistEventsResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"as_of":{"type":"string","format":"date","title":"As Of"},"window":{"additionalProperties":true,"type":"object","title":"Window"},"events":{"items":{"$ref":"#/components/schemas/ActivistEventEntry"},"type":"array","title":"Events"},"total_count":{"type":"integer","title":"Total Count"}},"type":"object","required":["symbol","as_of","events","total_count"],"title":"ActivistEventsResponse"},"AlpacaMultiBarsResponse":{"properties":{"source":{"type":"string","title":"Source","default":"ALPACA"},"interval":{"type":"string","title":"Interval"},"count":{"type":"integer","title":"Count"},"bars":{"additionalProperties":{"items":{"additionalProperties":true,"type":"object"},"type":"array"},"type":"object","title":"Bars"}},"type":"object","required":["interval","count","bars"],"title":"AlpacaMultiBarsResponse","description":"Multi-ticker OHLCV bars from Alpaca (daily or intraday).\n\n``bars`` maps each ticker (using the original input symbol, e.g. BF-B)\nto a list of bar dicts. Daily bars include a ``date`` field; intraday\nbars include a ``timestamp`` field."},"AlpacaMultiSnapshotResponse":{"properties":{"source":{"type":"string","title":"Source","default":"ALPACA"},"count":{"type":"integer","title":"Count"},"snapshots":{"items":{"$ref":"#/components/schemas/AlpacaSnapshotResponse"},"type":"array","title":"Snapshots"}},"type":"object","required":["count","snapshots"],"title":"AlpacaMultiSnapshotResponse","description":"Real-time snapshots for multiple tickers."},"AlpacaSnapshotResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"source":{"type":"string","title":"Source","default":"ALPACA"},"timestamp":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Timestamp"},"price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price"},"trade_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Trade Size"},"bid":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Bid"},"ask":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Ask"},"bid_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Bid Size"},"ask_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Ask Size"},"open":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Open"},"high":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"High"},"low":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Low"},"volume":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Volume"},"vwap":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Vwap"},"prev_close":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Prev Close"},"change":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Change"},"change_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Change Pct"}},"type":"object","required":["ticker"],"title":"AlpacaSnapshotResponse","description":"Real-time snapshot for a single ticker via Alpaca."},"BulkCompanyItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"data":{"anyOf":[{"$ref":"#/components/schemas/CompanyMetadataResponse"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["ticker"],"title":"BulkCompanyItem"},"BulkCompanyRequest":{"properties":{"tickers":{"items":{"type":"string"},"type":"array","title":"Tickers"}},"type":"object","required":["tickers"],"title":"BulkCompanyRequest"},"BulkCompanyResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/BulkCompanyItem"},"type":"array","title":"Results"},"total":{"type":"integer","title":"Total"},"success_count":{"type":"integer","title":"Success Count"},"error_count":{"type":"integer","title":"Error Count"}},"type":"object","required":["results","total","success_count","error_count"],"title":"BulkCompanyResponse"},"BulkEarningsCalendarRequest":{"properties":{"symbols":{"items":{"type":"string"},"type":"array","maxItems":50,"minItems":1,"title":"Symbols"},"days_ahead":{"type":"integer","maximum":365.0,"minimum":1.0,"title":"Days Ahead","default":30},"limit":{"type":"integer","maximum":20.0,"minimum":1.0,"title":"Limit","default":4},"as_of_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"As Of Date","description":"PIT date for backtesting (YYYY-MM-DD). Defaults to today."}},"type":"object","required":["symbols"],"title":"BulkEarningsCalendarRequest"},"BulkEarningsCalendarResponse":{"properties":{"entries":{"items":{"$ref":"#/components/schemas/EarningsCalendarEntry"},"type":"array","title":"Entries"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["entries"],"title":"BulkEarningsCalendarResponse"},"BulkExhibitItem":{"properties":{"accession_number":{"type":"string","title":"Accession Number"},"exhibit_type":{"type":"string","title":"Exhibit Type"},"success":{"type":"boolean","title":"Success"},"content":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content"},"content_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Type"},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["accession_number","exhibit_type","success"],"title":"BulkExhibitItem"},"BulkExhibitRequest":{"properties":{"items":{"items":{"additionalProperties":{"type":"string"},"type":"object"},"type":"array","maxItems":50,"minItems":1,"title":"Items"}},"type":"object","required":["items"],"title":"BulkExhibitRequest","example":{"items":[{"accession_number":"0000320193-24-000006","exhibit_type":"EX-99.1"},{"accession_number":"0001045810-24-000010","exhibit_type":"EX-99.1"}]}},"BulkExhibitResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/BulkExhibitItem"},"type":"array","title":"Results"},"total_items":{"type":"integer","title":"Total Items"},"successful_count":{"type":"integer","title":"Successful Count"},"failed_count":{"type":"integer","title":"Failed Count"},"query_time_seconds":{"type":"number","title":"Query Time Seconds"}},"type":"object","required":["results","total_items","successful_count","failed_count","query_time_seconds"],"title":"BulkExhibitResponse"},"BulkFilingSearchItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"success":{"type":"boolean","title":"Success"},"filings":{"items":{"$ref":"#/components/schemas/FilingSummary"},"type":"array","title":"Filings","default":[]},"total_count":{"type":"integer","title":"Total Count","default":0},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["ticker","success"],"title":"BulkFilingSearchItem"},"BulkFilingSearchRequest":{"properties":{"tickers":{"items":{"type":"string"},"type":"array","maxItems":200,"minItems":1,"title":"Tickers"},"form_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Form Type"},"start_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Start Date"},"end_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"End Date"},"limit_per_ticker":{"type":"integer","maximum":100.0,"minimum":1.0,"title":"Limit Per Ticker","default":20}},"type":"object","required":["tickers"],"title":"BulkFilingSearchRequest","example":{"end_date":"2024-12-31","form_type":"8-K","limit_per_ticker":5,"start_date":"2024-01-01","tickers":["AAPL","MSFT","NVDA"]}},"BulkFilingSearchResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/BulkFilingSearchItem"},"type":"array","title":"Results"},"total_tickers":{"type":"integer","title":"Total Tickers"},"successful_count":{"type":"integer","title":"Successful Count"},"failed_count":{"type":"integer","title":"Failed Count"},"query_time_seconds":{"type":"number","title":"Query Time Seconds"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["results","total_tickers","successful_count","failed_count","query_time_seconds"],"title":"BulkFilingSearchResponse"},"BulkFinancialDataItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"success":{"type":"boolean","title":"Success"},"data":{"anyOf":[{"$ref":"#/components/schemas/FinancialDataResponse"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["ticker","success"],"title":"BulkFinancialDataItem"},"BulkFinancialDataRequest":{"properties":{"tickers":{"items":{"type":"string"},"type":"array","maxItems":500,"minItems":1,"title":"Tickers","description":"List of stock ticker symbols (max 500 for efficient bulk processing)"},"start_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Start Date","description":"Start date for data retrieval. Cannot be used with quarters or period."},"end_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"End Date","description":"End date for data retrieval. Cannot be used with quarters or period."},"quarters":{"anyOf":[{"items":{"type":"string"},"type":"array","maxItems":40,"minItems":1},{"type":"null"}],"title":"Quarters","description":"List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."},"period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period","description":"Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."},"period_type":{"$ref":"#/components/schemas/PeriodType","description":"Type of financial periods to retrieve","default":"all"},"include_metrics":{"type":"boolean","title":"Include Metrics","description":"Include calculated metrics in response","default":true},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Force refresh data from SEC","default":false}},"type":"object","required":["tickers"],"title":"BulkFinancialDataRequest"},"BulkFinancialDataResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/BulkFinancialDataItem"},"type":"array","title":"Results"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["results"],"title":"BulkFinancialDataResponse"},"BulkParseRequest":{"properties":{"tickers":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tickers"},"limit":{"type":"integer","maximum":1000.0,"minimum":1.0,"title":"Limit","default":100},"force_reparse":{"type":"boolean","title":"Force Reparse","description":"If True, also reparse filings with status succeeded or failed (resets to pending first)","default":false}},"type":"object","title":"BulkParseRequest","example":{"force_reparse":false,"limit":50,"tickers":["AVGO","AAPL"]}},"BulkParseResponse":{"properties":{"succeeded":{"type":"integer","title":"Succeeded"},"failed":{"type":"integer","title":"Failed"},"skipped":{"type":"integer","title":"Skipped"},"total":{"type":"integer","title":"Total"},"query_time_seconds":{"type":"number","title":"Query Time Seconds"}},"type":"object","required":["succeeded","failed","skipped","total","query_time_seconds"],"title":"BulkParseResponse"},"BulkPriceDataItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"success":{"type":"boolean","title":"Success"},"data":{"anyOf":[{"$ref":"#/components/schemas/PriceDataResponse"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["ticker","success"],"title":"BulkPriceDataItem"},"BulkPriceDataRequest":{"properties":{"tickers":{"items":{"type":"string"},"type":"array","maxItems":500,"minItems":1,"title":"Tickers","description":"List of stock ticker symbols (max 500 for efficient bulk processing)"},"start_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Start Date","description":"Start date for data retrieval. Cannot be used with quarters or period."},"end_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"End Date","description":"End date for data retrieval. Cannot be used with quarters or period."},"quarters":{"anyOf":[{"items":{"type":"string"},"type":"array","maxItems":40,"minItems":1},{"type":"null"}],"title":"Quarters","description":"List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."},"period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period","description":"Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."},"interval":{"type":"string","title":"Interval","description":"Data interval: 1d, 1w, 1m, 5d, 1h, etc.","default":"1d"},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Force refresh data from Yahoo Finance","default":false}},"type":"object","required":["tickers"],"title":"BulkPriceDataRequest"},"BulkPriceDataResponse":{"properties":{"results":{"items":{"$ref":"#/components/schemas/BulkPriceDataItem"},"type":"array","title":"Results"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["results"],"title":"BulkPriceDataResponse"},"CollectionStatusResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"source":{"type":"string","title":"Source"},"records_collected":{"type":"integer","title":"Records Collected"},"date_range":{"additionalProperties":true,"type":"object","title":"Date Range"},"status":{"type":"string","title":"Status"}},"type":"object","required":["ticker","source","records_collected","status"],"title":"CollectionStatusResponse","example":{"date_range":{"event_date":"2024-02-01"},"records_collected":22,"source":"wiki","status":"success","ticker":"AAPL"}},"CompanyInfo":{"properties":{"ticker":{"type":"string","title":"Ticker"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cik"},"exchange":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Exchange"},"sector":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sector"},"industry":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Industry"},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Country"},"market_cap":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Market Cap"},"business_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Business Description"}},"type":"object","required":["ticker"],"title":"CompanyInfo"},"CompanyMetadataResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cik"},"exchange":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Exchange"},"sector":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sector"},"industry":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Industry"},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Country"},"market_cap":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Market Cap"},"business_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Business Description"}},"type":"object","required":["ticker"],"title":"CompanyMetadataResponse"},"CoverageResponse":{"properties":{"source":{"type":"string","title":"Source"},"symbol":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Symbol"},"earliest":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Earliest"},"latest":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Latest"},"ingested_count":{"type":"integer","title":"Ingested Count"}},"type":"object","required":["source","ingested_count"],"title":"CoverageResponse"},"DataCatalogItem":{"properties":{"field_name":{"type":"string","title":"Field Name"},"description":{"type":"string","title":"Description"},"data_type":{"type":"string","title":"Data Type"},"unit":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Unit"},"calculation":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Calculation"},"source":{"type":"string","title":"Source"}},"type":"object","required":["field_name","description","data_type","source"],"title":"DataCatalogItem"},"DataCatalogResponse":{"properties":{"categories":{"additionalProperties":{"items":{"$ref":"#/components/schemas/DataCatalogItem"},"type":"array"},"type":"object","title":"Categories"},"last_updated":{"type":"string","format":"date-time","title":"Last Updated"}},"type":"object","required":["categories","last_updated"],"title":"DataCatalogResponse"},"DividendCalendarEntry":{"properties":{"ticker":{"type":"string","title":"Ticker"},"ex_dividend_date":{"type":"string","format":"date","title":"Ex Dividend Date"},"amount":{"type":"number","title":"Amount"},"declaration_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Declaration Date"},"record_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Record Date"},"payment_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Payment Date"},"currency":{"type":"string","title":"Currency","default":"USD"},"dividend_type":{"type":"string","title":"Dividend Type","default":"regular"},"frequency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Frequency"},"as_of_date":{"type":"string","format":"date","title":"As Of Date"},"source":{"type":"string","title":"Source"}},"type":"object","required":["ticker","ex_dividend_date","amount","as_of_date","source"],"title":"DividendCalendarEntry"},"DividendHistoryResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"dividends":{"items":{"$ref":"#/components/schemas/DividendCalendarEntry"},"type":"array","title":"Dividends"},"total_count":{"type":"integer","title":"Total Count"},"annual_yield_estimate":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Annual Yield Estimate"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","dividends","total_count"],"title":"DividendHistoryResponse","description":"Response for single-symbol dividend history."},"DividendIngestRequest":{"properties":{"symbols":{"items":{"type":"string"},"type":"array","maxItems":200,"minItems":1,"title":"Symbols"},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Re-ingest even if data exists","default":false}},"type":"object","required":["symbols"],"title":"DividendIngestRequest","description":"Request body for bulk backfill ingest."},"DividendIngestResponse":{"properties":{"symbols_processed":{"type":"integer","title":"Symbols Processed"},"total_records_upserted":{"type":"integer","title":"Total Records Upserted"},"failed_symbols":{"items":{"type":"string"},"type":"array","title":"Failed Symbols"},"status":{"type":"string","title":"Status"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbols_processed","total_records_upserted","status"],"title":"DividendIngestResponse","description":"Response for admin ingest endpoint."},"DividendUpcomingResponse":{"properties":{"dividends":{"items":{"$ref":"#/components/schemas/DividendCalendarEntry"},"type":"array","title":"Dividends"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["dividends","total_count"],"title":"DividendUpcomingResponse","description":"Response for PIT upcoming dividends query."},"ETFHoldingsOut":{"properties":{"success":{"type":"boolean","title":"Success"},"ticker":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ticker"},"as_of_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"As Of Date"},"cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cik"},"holdings_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Holdings Count"},"holdings":{"anyOf":[{"items":{},"type":"array"},{"type":"null"}],"title":"Holdings"},"availability":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Availability"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["success"],"title":"ETFHoldingsOut"},"EarningsCalendarEntry":{"properties":{"symbol":{"type":"string","title":"Symbol"},"earnings_date":{"type":"string","format":"date-time","title":"Earnings Date"},"earnings_time":{"type":"string","title":"Earnings Time"},"estimated_eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Estimated Eps"},"reported_eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Reported Eps"},"source":{"type":"string","title":"Source","default":"yfinance"},"fetched_at":{"type":"string","format":"date-time","title":"Fetched At"}},"type":"object","required":["symbol","earnings_date","earnings_time","fetched_at"],"title":"EarningsCalendarEntry"},"EarningsCalendarResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"upcoming_earnings":{"items":{"$ref":"#/components/schemas/EarningsCalendarEntry"},"type":"array","title":"Upcoming Earnings"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","upcoming_earnings"],"title":"EarningsCalendarResponse"},"EarningsSurpriseEntry":{"properties":{"fiscal_date_ending":{"type":"string","format":"date","title":"Fiscal Date Ending"},"reported_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Reported Date"},"reported_eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Reported Eps"},"estimated_eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Estimated Eps"},"surprise":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Surprise"},"surprise_percentage":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Surprise Percentage"},"beat":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Beat"}},"type":"object","required":["fiscal_date_ending"],"title":"EarningsSurpriseEntry"},"EarningsSurpriseResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"quarters":{"items":{"$ref":"#/components/schemas/EarningsSurpriseEntry"},"type":"array","title":"Quarters"},"streak":{"type":"integer","title":"Streak","default":0},"avg_surprise_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Avg Surprise Pct"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","quarters"],"title":"EarningsSurpriseResponse"},"EntityInfo":{"properties":{"ticker":{"type":"string","title":"Ticker"},"canonical_name":{"type":"string","title":"Canonical Name","description":"Normalized company name with legal suffixes stripped (e.g. 'Apple')"},"wiki_title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Wiki Title","description":"Matched Wikipedia article title; null if unresolved"},"gdelt_query":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gdelt Query","description":"GDELT DOC API query string (quoted OR phrases)"},"aliases":{"items":{"type":"string"},"type":"array","title":"Aliases","description":"Intermediate forms used during name normalization"},"resolver_confidence":{"type":"number","title":"Resolver Confidence","description":"Wikipedia match confidence [0, 1]","default":0.0},"is_manual_override":{"type":"boolean","title":"Is Manual Override","description":"If true, automated re-resolution is skipped","default":false}},"type":"object","required":["ticker","canonical_name"],"title":"EntityInfo","example":{"aliases":["Apple Inc."],"canonical_name":"Apple","gdelt_query":"\"Apple\" OR \"Apple Inc.\"","is_manual_override":false,"resolver_confidence":0.92,"ticker":"AAPL","wiki_title":"Apple Inc."}},"EntityResolveResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"entity":{"$ref":"#/components/schemas/EntityInfo"},"status":{"type":"string","title":"Status"},"message":{"type":"string","title":"Message"}},"type":"object","required":["ticker","entity","status","message"],"title":"EntityResolveResponse","example":{"entity":{"aliases":["Apple Inc."],"canonical_name":"Apple","gdelt_query":"\"Apple\" OR \"Apple Inc.\"","is_manual_override":false,"resolver_confidence":0.92,"ticker":"AAPL","wiki_title":"Apple Inc."},"message":"Entity resolved: wiki_title='Apple Inc.' confidence=0.92","status":"resolved","ticker":"AAPL"}},"ErrorLogListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ErrorLogResponse"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"page":{"type":"integer","title":"Page"},"page_size":{"type":"integer","title":"Page Size"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["items","total","page","page_size","total_pages"],"title":"ErrorLogListResponse","description":"Response schema for error log list"},"ErrorLogResponse":{"properties":{"request_id":{"type":"string","title":"Request Id"},"endpoint":{"type":"string","title":"Endpoint"},"method":{"type":"string","title":"Method"},"path":{"type":"string","title":"Path"},"query_params":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Query Params"},"request_body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Request Body"},"error_type":{"type":"string","title":"Error Type"},"error_message":{"type":"string","title":"Error Message"},"error_detail":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Error Detail"},"status_code":{"type":"integer","title":"Status Code"},"stack_trace":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stack Trace"},"user_agent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Agent"},"client_ip":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Ip"},"response_time_ms":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Response Time Ms"},"id":{"type":"integer","title":"Id"},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers"},"is_resolved":{"type":"boolean","title":"Is Resolved","default":false},"resolved_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resolved At"},"resolution_notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resolution Notes"},"created_at":{"type":"string","title":"Created At"}},"type":"object","required":["request_id","endpoint","method","path","error_type","error_message","status_code","id","created_at"],"title":"ErrorLogResponse","description":"Response schema for error log"},"ErrorLogStats":{"properties":{"total_errors":{"type":"integer","title":"Total Errors"},"resolved_errors":{"type":"integer","title":"Resolved Errors"},"unresolved_errors":{"type":"integer","title":"Unresolved Errors"},"resolution_rate":{"type":"number","title":"Resolution Rate"},"errors_by_type":{"additionalProperties":{"type":"integer"},"type":"object","title":"Errors By Type"},"errors_by_status_code":{"additionalProperties":{"type":"integer"},"type":"object","title":"Errors By Status Code"},"errors_by_endpoint":{"additionalProperties":{"type":"integer"},"type":"object","title":"Errors By Endpoint"},"average_response_time_ms":{"type":"number","title":"Average Response Time Ms"},"hourly_trend":{"additionalProperties":{"type":"integer"},"type":"object","title":"Hourly Trend"},"start_date":{"type":"string","title":"Start Date"},"end_date":{"type":"string","title":"End Date"}},"type":"object","required":["total_errors","resolved_errors","unresolved_errors","resolution_rate","errors_by_type","errors_by_status_code","errors_by_endpoint","average_response_time_ms","hourly_trend","start_date","end_date"],"title":"ErrorLogStats","description":"Statistics about error logs"},"ErrorLogUpdate":{"properties":{"is_resolved":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Resolved"},"resolution_notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resolution Notes"}},"type":"object","title":"ErrorLogUpdate","description":"Schema for updating error log"},"ErrorResponse":{"properties":{"error_type":{"$ref":"#/components/schemas/ErrorType"},"message":{"type":"string","title":"Message"},"detail":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Detail"},"timestamp":{"type":"string","format":"date-time","title":"Timestamp"}},"type":"object","required":["error_type","message"],"title":"ErrorResponse"},"ErrorType":{"type":"string","enum":["PARSING_ERROR","DATA_NOT_FOUND","INVALID_PERIOD","SEC_API_ERROR","DATABASE_ERROR","VALIDATION_ERROR","AUTHENTICATION_ERROR","RATE_LIMIT_ERROR"],"title":"ErrorType"},"EventAttentionResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"event_date":{"type":"string","format":"date","title":"Event Date"},"entity":{"$ref":"#/components/schemas/EntityInfo"},"wiki":{"$ref":"#/components/schemas/WikiFeatures"},"news":{"$ref":"#/components/schemas/NewsFeatures"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","event_date","entity","wiki","news"],"title":"EventAttentionResponse","example":{"entity":{"aliases":["Apple Inc."],"canonical_name":"Apple","gdelt_query":"\"Apple\" OR \"Apple Inc.\"","is_manual_override":false,"resolver_confidence":0.92,"ticker":"AAPL","wiki_title":"Apple Inc."},"event_date":"2024-02-01","metadata":{"resolver_confidence":0.92,"wiki_title":"Apple Inc."},"news":{"article_count_1d":18,"article_count_3d":52,"gdelt_status":"collected","unique_domains_3d":34,"us_article_count_3d":41},"ticker":"AAPL","wiki":{"baseline_10d":12400.0,"spike_10d":3.65,"views":45230,"zscore_20d":4.21}}},"ExhibitContentResponse":{"properties":{"accession_number":{"type":"string","title":"Accession Number"},"exhibit_type":{"type":"string","title":"Exhibit Type"},"content":{"type":"string","title":"Content"},"content_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Type"},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"}},"type":"object","required":["accession_number","exhibit_type","content"],"title":"ExhibitContentResponse","example":{"accession_number":"0000320193-24-000006","content":"Apple Reports First Quarter Results...\nCUPERTINO, California — February 1, 2024 — Apple Inc. today announced financial results for its fiscal 2024 first quarter...","content_type":"text/html","exhibit_type":"EX-99.1","filename":"ex991pressrelease.htm","url":"https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/ex991pressrelease.htm"}},"FilingDocumentInfo":{"properties":{"type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"filename":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filename"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"size":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Size"}},"type":"object","title":"FilingDocumentInfo","example":{"description":"Press Release","filename":"ex991pressrelease.htm","size":"42 KB","type":"EX-99.1","url":"https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/ex991pressrelease.htm"}},"FilingDocumentListResponse":{"properties":{"accession_number":{"type":"string","title":"Accession Number"},"documents":{"items":{"$ref":"#/components/schemas/FilingDocumentInfo"},"type":"array","title":"Documents"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["accession_number","documents"],"title":"FilingDocumentListResponse","example":{"accession_number":"0000320193-24-000006","documents":[{"description":"8-K","filename":"a8-k20240201.htm","size":"8 KB","type":"8-K","url":"https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/a8-k20240201.htm"},{"description":"Press Release","filename":"ex991pressrelease.htm","size":"42 KB","type":"EX-99.1","url":"https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/ex991pressrelease.htm"}],"metadata":{"total_documents":4}}},"FilingEventResponse":{"properties":{"id":{"type":"string","title":"Id"},"ticker":{"type":"string","title":"Ticker"},"accession_number":{"type":"string","title":"Accession Number"},"form_type":{"type":"string","title":"Form Type"},"filing_date":{"type":"string","title":"Filing Date"},"item_number":{"type":"string","title":"Item Number"},"event_type":{"type":"string","title":"Event Type"},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"content_source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Content Source"}},"type":"object","required":["id","ticker","accession_number","form_type","filing_date","item_number","event_type"],"title":"FilingEventResponse","example":{"accession_number":"0001193125-26-144028","content_source":"primary_doc","event_type":"other_material_event","filing_date":"2026-04-06","form_type":"8-K","id":"550e8400-e29b-41d4-a716-446655440000","item_number":"8.01","summary":"Broadcom Inc. and Google LLC have entered into a Long Term Agreement...","ticker":"AVGO","title":"Other Events"}},"FilingEventsSearchResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"events":{"items":{"$ref":"#/components/schemas/FilingEventResponse"},"type":"array","title":"Events"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","events","total_count"],"title":"FilingEventsSearchResponse"},"FilingSearchResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"filings":{"items":{"$ref":"#/components/schemas/FilingSummary"},"type":"array","title":"Filings"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","filings","total_count"],"title":"FilingSearchResponse","example":{"filings":[{"accepted_at":"2024-02-01T21:00:05+00:00","accession_number":"0000320193-24-000006","documents_count":4,"filing_date":"2024-02-01","filing_description":"Results of Operations and Financial Condition","form_type":"8-K","primary_document":"a8-k20240201.htm"}],"metadata":{"form_types":["8-K"],"limit":20,"offset":0},"ticker":"AAPL","total_count":42}},"FilingSummary":{"properties":{"accession_number":{"type":"string","title":"Accession Number"},"form_type":{"type":"string","title":"Form Type"},"filing_date":{"type":"string","title":"Filing Date"},"accepted_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accepted At"},"primary_document":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Primary Document"},"filing_description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filing Description"},"documents_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Documents Count"},"parsed_status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Parsed Status"},"items":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Items"}},"type":"object","required":["accession_number","form_type","filing_date"],"title":"FilingSummary","example":{"accepted_at":"2024-02-01T21:00:05+00:00","accession_number":"0000320193-24-000006","documents_count":4,"filing_date":"2024-02-01","filing_description":"Results of Operations and Financial Condition","form_type":"8-K","items":["2.02","9.01"],"parsed_status":"succeeded","primary_document":"a8-k20240201.htm"}},"FinancialDataPoint":{"properties":{"period_date":{"type":"string","format":"date-time","title":"Period Date"},"period_type":{"type":"string","title":"Period Type"},"filing_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Filing Type"},"revenue":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Revenue"},"gross_profit":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Gross Profit"},"operating_income":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Operating Income"},"net_income":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Net Income"},"eps":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Eps"},"total_assets":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Assets"},"total_equity":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Equity"},"total_debt":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Debt"},"cash":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cash"},"shares_outstanding":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Shares Outstanding"},"operating_cash_flow":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Operating Cash Flow"},"free_cash_flow":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Free Cash Flow"},"capex":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Capex"},"pe_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Pe Ratio"},"pb_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Pb Ratio"},"ps_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Ps Ratio"},"roe":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Roe"},"roa":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Roa"},"gross_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Gross Margin"},"operating_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Operating Margin"},"net_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Net Margin"},"debt_to_equity":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Debt To Equity"},"debt_to_assets":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Debt To Assets"},"ocf_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Ocf Margin"},"fcf_margin":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Fcf Margin"},"market_cap":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Market Cap"},"data_source":{"type":"string","title":"Data Source"},"is_estimated":{"type":"boolean","title":"Is Estimated"}},"type":"object","required":["period_date","period_type","data_source","is_estimated"],"title":"FinancialDataPoint"},"FinancialDataRequest":{"properties":{"ticker":{"type":"string","maxLength":10,"minLength":1,"title":"Ticker","description":"Stock ticker symbol"},"start_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Start Date","description":"Start date for data retrieval. Cannot be used with quarters or period."},"end_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"End Date","description":"End date for data retrieval. Cannot be used with quarters or period."},"quarters":{"anyOf":[{"items":{"type":"string"},"type":"array","maxItems":40,"minItems":1},{"type":"null"}],"title":"Quarters","description":"List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."},"period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period","description":"Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."},"period_type":{"$ref":"#/components/schemas/PeriodType","description":"Type of financial periods to retrieve","default":"all"},"include_metrics":{"type":"boolean","title":"Include Metrics","description":"Include calculated metrics in response","default":true},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Force refresh data from SEC","default":false}},"type":"object","required":["ticker"],"title":"FinancialDataRequest","description":"Request for financial data with flexible time period specification.\n\n**Three ways to specify time period (choose one):**\n1. **Date Range**: Use start_date and end_date \n2. **Quarters**: Use quarters list (e.g., ['2024Q1', '2024Q2'])\n3. **Period**: Use period string (e.g., '1d', '3m', '2y')\n\n**Important**: Cannot mix approaches in the same request."},"FinancialDataResponse":{"properties":{"company":{"$ref":"#/components/schemas/CompanyInfo"},"financial_data":{"items":{"$ref":"#/components/schemas/FinancialDataPoint"},"type":"array","title":"Financial Data"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["company","financial_data"],"title":"FinancialDataResponse"},"Form4AggregateResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"as_of":{"type":"string","format":"date","title":"As Of"},"window_days":{"type":"integer","title":"Window Days"},"buy_count":{"type":"integer","title":"Buy Count"},"buy_dollar_total":{"type":"number","title":"Buy Dollar Total"},"cluster_size":{"type":"integer","title":"Cluster Size"},"csuite_count":{"type":"integer","title":"Csuite Count"},"avg_pct_of_holding":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Avg Pct Of Holding"},"recency_days":{"type":"integer","title":"Recency Days"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","as_of","window_days","buy_count","buy_dollar_total","cluster_size","csuite_count","recency_days"],"title":"Form4AggregateResponse","description":"Aggregate Form 4 insider activity over a rolling window.\n\nAll fields are computed over open-market purchases only (transaction_code='P',\nshares > 0, non-derivative). Awards/grants (A-code) are excluded."},"Form4ByDateResponse":{"properties":{"filing_date":{"type":"string","format":"date","title":"Filing Date"},"buy_only":{"type":"boolean","title":"Buy Only"},"transactions":{"items":{"$ref":"#/components/schemas/Form4Entry"},"type":"array","title":"Transactions"},"total_count":{"type":"integer","title":"Total Count"}},"type":"object","required":["filing_date","buy_only","transactions","total_count"],"title":"Form4ByDateResponse"},"Form4Entry":{"properties":{"symbol":{"type":"string","title":"Symbol"},"filing_date":{"type":"string","format":"date","title":"Filing Date"},"transaction_date":{"type":"string","format":"date","title":"Transaction Date"},"owner_cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Cik"},"owner_name":{"type":"string","title":"Owner Name"},"owner_relationship":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Relationship"},"is_officer":{"type":"boolean","title":"Is Officer","default":false},"is_director":{"type":"boolean","title":"Is Director","default":false},"is_ten_percent_owner":{"type":"boolean","title":"Is Ten Percent Owner","default":false},"is_ceo":{"type":"boolean","title":"Is Ceo","default":false},"is_cfo":{"type":"boolean","title":"Is Cfo","default":false},"is_c_suite":{"type":"boolean","title":"Is C Suite","default":false},"transaction_code":{"type":"string","title":"Transaction Code"},"transaction_type":{"type":"string","title":"Transaction Type"},"shares":{"type":"number","title":"Shares"},"price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price"},"total_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Value"},"shares_owned_following":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Shares Owned Following"},"purchase_pct_of_holding":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Purchase Pct Of Holding"},"accession_number":{"type":"string","title":"Accession Number"}},"type":"object","required":["symbol","filing_date","transaction_date","owner_name","transaction_code","transaction_type","shares","accession_number"],"title":"Form4Entry"},"Form4Response":{"properties":{"symbol":{"type":"string","title":"Symbol"},"as_of":{"type":"string","format":"date","title":"As Of"},"window":{"additionalProperties":true,"type":"object","title":"Window"},"transactions":{"items":{"$ref":"#/components/schemas/Form4Entry"},"type":"array","title":"Transactions"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","as_of","transactions","total_count"],"title":"Form4Response"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthCheckResponse":{"properties":{"status":{"type":"string","title":"Status"},"version":{"type":"string","title":"Version"},"database":{"type":"string","title":"Database"},"cache":{"type":"string","title":"Cache"},"sec_data_available":{"type":"boolean","title":"Sec Data Available"},"timestamp":{"type":"string","format":"date-time","title":"Timestamp"}},"type":"object","required":["status","version","database","cache","sec_data_available","timestamp"],"title":"HealthCheckResponse"},"IngestResponse":{"properties":{"date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Date"},"date_range":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Date Range"},"records_ingested":{"type":"integer","title":"Records Ingested"},"status":{"type":"string","title":"Status"}},"type":"object","required":["records_ingested","status"],"title":"IngestResponse"},"InsiderSummaryPeriod":{"properties":{"period_label":{"type":"string","title":"Period Label"},"buy_count":{"type":"integer","title":"Buy Count","default":0},"sell_count":{"type":"integer","title":"Sell Count","default":0},"buy_shares":{"type":"number","title":"Buy Shares","default":0.0},"sell_shares":{"type":"number","title":"Sell Shares","default":0.0},"buy_value":{"type":"number","title":"Buy Value","default":0.0},"sell_value":{"type":"number","title":"Sell Value","default":0.0},"net_shares":{"type":"number","title":"Net Shares","default":0.0},"net_value":{"type":"number","title":"Net Value","default":0.0},"unique_buyers":{"type":"integer","title":"Unique Buyers","default":0},"unique_sellers":{"type":"integer","title":"Unique Sellers","default":0}},"type":"object","required":["period_label"],"title":"InsiderSummaryPeriod"},"InsiderSummaryResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"periods":{"items":{"$ref":"#/components/schemas/InsiderSummaryPeriod"},"type":"array","title":"Periods"},"notable_transactions":{"items":{"$ref":"#/components/schemas/InsiderTransactionEntry"},"type":"array","title":"Notable Transactions"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","periods","notable_transactions"],"title":"InsiderSummaryResponse"},"InsiderTransactionEntry":{"properties":{"filing_date":{"type":"string","format":"date","title":"Filing Date"},"transaction_date":{"type":"string","format":"date","title":"Transaction Date"},"owner_name":{"type":"string","title":"Owner Name"},"owner_cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Cik"},"is_officer":{"type":"boolean","title":"Is Officer","default":false},"is_director":{"type":"boolean","title":"Is Director","default":false},"is_ten_percent_owner":{"type":"boolean","title":"Is Ten Percent Owner","default":false},"officer_title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Officer Title"},"security_title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Security Title"},"transaction_code":{"type":"string","title":"Transaction Code"},"transaction_type":{"type":"string","title":"Transaction Type"},"shares":{"type":"number","title":"Shares"},"price_per_share":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Share"},"total_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Value"},"shares_owned_after":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Shares Owned After"},"is_derivative":{"type":"boolean","title":"Is Derivative","default":false}},"type":"object","required":["filing_date","transaction_date","owner_name","transaction_code","transaction_type","shares"],"title":"InsiderTransactionEntry"},"InsiderTransactionResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"transactions":{"items":{"$ref":"#/components/schemas/InsiderTransactionEntry"},"type":"array","title":"Transactions"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","transactions","total_count"],"title":"InsiderTransactionResponse"},"IntradayCandle":{"properties":{"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"open":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Open"},"high":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"High"},"low":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Low"},"close":{"type":"number","title":"Close"},"volume":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Volume"}},"type":"object","required":["timestamp","close"],"title":"IntradayCandle"},"IntradayResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"interval":{"type":"string","title":"Interval"},"period":{"type":"string","title":"Period"},"candles":{"items":{"$ref":"#/components/schemas/IntradayCandle"},"type":"array","title":"Candles"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","interval","period","candles"],"title":"IntradayResponse"},"JobLogEntry":{"properties":{"id":{"type":"string","title":"Id"},"job_type":{"type":"string","title":"Job Type"},"status":{"type":"string","title":"Status"},"started_at":{"type":"string","format":"date-time","title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"},"records_processed":{"type":"integer","title":"Records Processed","default":0},"error_message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Message"}},"type":"object","required":["id","job_type","status","started_at"],"title":"JobLogEntry"},"JobLogResponse":{"properties":{"logs":{"items":{"$ref":"#/components/schemas/JobLogEntry"},"type":"array","title":"Logs"},"total_count":{"type":"integer","title":"Total Count"}},"type":"object","required":["logs","total_count"],"title":"JobLogResponse"},"MigrationRequest":{"properties":{"source_url":{"type":"string","title":"Source Url","description":"Source API URL to migrate from"},"api_key":{"type":"string","title":"Api Key","description":"API key for authentication"},"tickers":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tickers","description":"Specific tickers to migrate, or all if not specified"},"start_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Start Date"},"end_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"End Date"}},"type":"object","required":["source_url","api_key"],"title":"MigrationRequest"},"MigrationResponse":{"properties":{"status":{"type":"string","title":"Status"},"total_records":{"type":"integer","title":"Total Records"},"migrated_records":{"type":"integer","title":"Migrated Records"},"failed_records":{"type":"integer","title":"Failed Records"},"errors":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Errors"},"duration_seconds":{"type":"number","title":"Duration Seconds"}},"type":"object","required":["status","total_records","migrated_records","failed_records","duration_seconds"],"title":"MigrationResponse"},"NewsFeatures":{"properties":{"article_count_1d":{"type":"integer","title":"Article Count 1D","description":"GDELT articles published on the event date","default":0},"article_count_3d":{"type":"integer","title":"Article Count 3D","description":"GDELT articles in the event_date ± 1 day window","default":0},"unique_domains_3d":{"type":"integer","title":"Unique Domains 3D","description":"Distinct publisher domains in the 3-day window","default":0},"us_article_count_3d":{"type":"integer","title":"Us Article Count 3D","description":"US-sourced articles in the 3-day window","default":0},"gdelt_status":{"type":"string","title":"Gdelt Status","description":"GDELT data availability for this event date. 'collected' — scheduler has run; counts are accurate (0 means genuinely no articles). 'not_collected' — scheduler has not run yet; POST /admin/collect/gdelt/{ticker}?event_date=... to populate. 'not_available' — event date is before GDELT V2 coverage start (2017-01-01).","default":"not_collected"}},"type":"object","title":"NewsFeatures","example":{"article_count_1d":18,"article_count_3d":52,"gdelt_status":"collected","unique_domains_3d":34,"us_article_count_3d":41}},"NewsOnlyResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"retrieved_at":{"type":"string","title":"Retrieved At"},"news":{"additionalProperties":true,"type":"object","title":"News"},"summary":{"additionalProperties":true,"type":"object","title":"Summary"}},"type":"object","required":["ticker","retrieved_at","news","summary"],"title":"NewsOnlyResponse"},"NewsSocialResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"retrieved_at":{"type":"string","title":"Retrieved At"},"news":{"additionalProperties":true,"type":"object","title":"News","description":"News articles and sources breakdown"},"social_media":{"additionalProperties":true,"type":"object","title":"Social Media","description":"Social media posts and platforms breakdown"},"summary":{"$ref":"#/components/schemas/NewsSocialSummarySchema"}},"type":"object","required":["ticker","retrieved_at","news","social_media","summary"],"title":"NewsSocialResponse","description":"Complete response for ticker news and social data"},"NewsSocialSummarySchema":{"properties":{"total_items":{"type":"integer","title":"Total Items"},"time_range_days":{"type":"integer","title":"Time Range Days"},"oldest_item":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Oldest Item"},"newest_item":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Newest Item"}},"type":"object","required":["total_items","time_range_days"],"title":"NewsSocialSummarySchema","description":"Summary of news and social data"},"PeriodType":{"type":"string","enum":["quarterly","annual","all"],"title":"PeriodType"},"PriceDataPoint":{"properties":{"date":{"type":"string","format":"date","title":"Date"},"open":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Open"},"high":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"High"},"low":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Low"},"close":{"type":"number","title":"Close"},"volume":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Volume"},"adjusted_close":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Adjusted Close"},"data_source":{"type":"string","title":"Data Source"}},"type":"object","required":["date","close","data_source"],"title":"PriceDataPoint"},"PriceDataRequest":{"properties":{"ticker":{"type":"string","maxLength":10,"minLength":1,"title":"Ticker","description":"Stock ticker symbol"},"start_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Start Date","description":"Start date for data retrieval. Cannot be used with quarters or period."},"end_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"End Date","description":"End date for data retrieval. Cannot be used with quarters or period."},"quarters":{"anyOf":[{"items":{"type":"string"},"type":"array","maxItems":40,"minItems":1},{"type":"null"}],"title":"Quarters","description":"List of quarters in format 'YYYYQN' (e.g., ['2020Q1', '2020Q2']). Cannot be used with start_date/end_date or period. If provided, dates are ignored."},"period":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Period","description":"Period string like '1d', '7d', '1m', '3m', '1y', '2y'. Cannot be used with start_date/end_date or quarters."},"interval":{"type":"string","title":"Interval","description":"Data interval: 1d, 1w, 1m, 5d, 1h, etc.","default":"1d"},"force_refresh":{"type":"boolean","title":"Force Refresh","description":"Force refresh data from Yahoo Finance","default":false}},"type":"object","required":["ticker"],"title":"PriceDataRequest","description":"Request for price data with flexible time period specification.\n\n**Three ways to specify time period (choose one):**\n1. **Date Range**: Use start_date and end_date\n2. **Quarters**: Use quarters list (e.g., ['2024Q1', '2024Q2'])\n3. **Period**: Use period string (e.g., '1d', '3m', '2y')\n\n**Important**: Cannot mix approaches in the same request."},"PriceDataResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"interval":{"type":"string","title":"Interval"},"data":{"items":{"$ref":"#/components/schemas/PriceDataPoint"},"type":"array","title":"Data"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","interval","data"],"title":"PriceDataResponse"},"QuoteResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"price":{"type":"number","title":"Price"},"regular_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Regular Price"},"pre_market_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Pre Market Price"},"post_market_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Post Market Price"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"},"exchange":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Exchange"},"market_state":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Market State"},"timestamp":{"type":"string","format":"date-time","title":"Timestamp"},"source":{"type":"string","title":"Source","default":"YAHOO_FINANCE"},"delayed":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Delayed","default":true}},"type":"object","required":["ticker","price","timestamp"],"title":"QuoteResponse"},"RefreshMapsOut":{"properties":{"cusip_rows":{"type":"integer","title":"Cusip Rows"},"etf_rows":{"type":"integer","title":"Etf Rows"}},"type":"object","required":["cusip_rows","etf_rows"],"title":"RefreshMapsOut"},"RegistryResponse":{"properties":{"tickers":{"items":{"$ref":"#/components/schemas/TickerRegistryItem"},"type":"array","title":"Tickers"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"page_size":{"type":"integer","title":"Page Size"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["tickers","total_count","page","page_size","total_pages"],"title":"RegistryResponse"},"RequestLogListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/RequestLogResponse"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"page":{"type":"integer","title":"Page"},"page_size":{"type":"integer","title":"Page Size"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["items","total","page","page_size","total_pages"],"title":"RequestLogListResponse","description":"Response schema for paginated request logs"},"RequestLogResponse":{"properties":{"id":{"type":"integer","title":"Id"},"request_id":{"type":"string","title":"Request Id"},"endpoint":{"type":"string","title":"Endpoint"},"method":{"type":"string","title":"Method"},"path":{"type":"string","title":"Path"},"query_params":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Query Params"},"request_body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Request Body"},"headers":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Headers"},"status_code":{"type":"integer","title":"Status Code"},"response_size":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Response Size"},"user_agent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Agent"},"client_ip":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Client Ip"},"response_time_ms":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Response Time Ms"},"created_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created At"}},"type":"object","required":["id","request_id","endpoint","method","path","status_code"],"title":"RequestLogResponse","description":"Response schema for individual request log"},"RequestLogStats":{"properties":{"total_requests":{"type":"integer","title":"Total Requests"},"success_requests":{"type":"integer","title":"Success Requests"},"client_error_requests":{"type":"integer","title":"Client Error Requests"},"server_error_requests":{"type":"integer","title":"Server Error Requests"},"success_rate":{"type":"number","title":"Success Rate"},"requests_by_method":{"additionalProperties":{"type":"integer"},"type":"object","title":"Requests By Method"},"requests_by_status_code":{"additionalProperties":{"type":"integer"},"type":"object","title":"Requests By Status Code"},"requests_by_endpoint":{"additionalProperties":{"type":"integer"},"type":"object","title":"Requests By Endpoint"},"average_response_time_ms":{"type":"number","title":"Average Response Time Ms"},"hourly_trend":{"additionalProperties":{"type":"integer"},"type":"object","title":"Hourly Trend"},"start_date":{"type":"string","title":"Start Date"},"end_date":{"type":"string","title":"End Date"}},"type":"object","required":["total_requests","success_requests","client_error_requests","server_error_requests","success_rate","requests_by_method","requests_by_status_code","requests_by_endpoint","average_response_time_ms","hourly_trend","start_date","end_date"],"title":"RequestLogStats","description":"Response schema for request log statistics"},"SessionAggregateBatchRequest":{"properties":{"session_date":{"type":"string","format":"date","title":"Session Date"},"window":{"type":"string","title":"Window"},"symbols":{"items":{"type":"string"},"type":"array","title":"Symbols"},"sources":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Sources"}},"type":"object","required":["session_date","window","symbols"],"title":"SessionAggregateBatchRequest"},"SessionAggregateBatchResponse":{"properties":{"items":{"additionalProperties":{"$ref":"#/components/schemas/SessionAggregateItem"},"type":"object","title":"Items"}},"type":"object","required":["items"],"title":"SessionAggregateBatchResponse"},"SessionAggregateItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"session_date":{"type":"string","title":"Session Date"},"window":{"type":"string","title":"Window"},"headline_count":{"type":"integer","title":"Headline Count","default":0},"primary_count":{"type":"integer","title":"Primary Count","default":0},"first_headline_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"First Headline At"},"last_headline_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Headline At"},"category_counts":{"additionalProperties":{"type":"integer"},"type":"object","title":"Category Counts"},"sentiment_mean":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Sentiment Mean"},"sentiment_recency_weighted":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Sentiment Recency Weighted"},"social":{"$ref":"#/components/schemas/SocialStatsItem"},"sources_present":{"items":{"type":"string"},"type":"array","title":"Sources Present"}},"type":"object","required":["ticker","session_date","window"],"title":"SessionAggregateItem"},"ShortRatioHistoryResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"history":{"items":{"$ref":"#/components/schemas/ShortRatioPoint"},"type":"array","title":"History"},"avg_short_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Avg Short Ratio"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","history"],"title":"ShortRatioHistoryResponse"},"ShortRatioPoint":{"properties":{"date":{"type":"string","format":"date","title":"Date"},"short_volume":{"type":"number","title":"Short Volume"},"short_exempt_volume":{"type":"number","title":"Short Exempt Volume"},"total_volume":{"type":"number","title":"Total Volume"},"short_ratio":{"type":"number","title":"Short Ratio"}},"type":"object","required":["date","short_volume","short_exempt_volume","total_volume","short_ratio"],"title":"ShortRatioPoint"},"ShortVolumeEntry":{"properties":{"date":{"type":"string","format":"date","title":"Date"},"symbol":{"type":"string","title":"Symbol"},"short_volume":{"type":"number","title":"Short Volume"},"short_exempt_volume":{"type":"number","title":"Short Exempt Volume","default":0.0},"total_volume":{"type":"number","title":"Total Volume"},"market":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Market"},"short_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Short Ratio"}},"type":"object","required":["date","symbol","short_volume","total_volume"],"title":"ShortVolumeEntry"},"ShortVolumeResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"entries":{"items":{"$ref":"#/components/schemas/ShortVolumeEntry"},"type":"array","title":"Entries"},"total_count":{"type":"integer","title":"Total Count"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["symbol","entries","total_count"],"title":"ShortVolumeResponse"},"SnapshotBuildRequest":{"properties":{"tickers":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tickers","description":"Specific tickers to build. Omit for all registry tickers."},"start_date":{"type":"string","title":"Start Date","description":"Start date YYYY-MM-DD (e.g. 2015-01-01)"},"end_date":{"type":"string","title":"End Date","description":"End date YYYY-MM-DD (e.g. 2025-12-01)"},"force_rebuild":{"type":"boolean","title":"Force Rebuild","description":"Delete existing snapshots for these tickers before rebuilding","default":false}},"type":"object","required":["start_date","end_date"],"title":"SnapshotBuildRequest"},"SocialOnlyResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"retrieved_at":{"type":"string","title":"Retrieved At"},"social_media":{"additionalProperties":true,"type":"object","title":"Social Media"},"summary":{"additionalProperties":true,"type":"object","title":"Summary"}},"type":"object","required":["ticker","retrieved_at","social_media","summary"],"title":"SocialOnlyResponse"},"SocialStatsItem":{"properties":{"message_count":{"type":"integer","title":"Message Count","default":0},"bull_count":{"type":"integer","title":"Bull Count","default":0},"bear_count":{"type":"integer","title":"Bear Count","default":0},"bull_bear_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Bull Bear Ratio"}},"type":"object","title":"SocialStatsItem"},"TickerRegistryItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"sector":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sector"},"industry":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Industry"},"exchange":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Exchange"},"cik":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cik"},"is_active":{"type":"boolean","title":"Is Active","default":true}},"type":"object","required":["ticker"],"title":"TickerRegistryItem"},"TodayOHLCResponse":{"properties":{"ticker":{"type":"string","title":"Ticker"},"date":{"type":"string","format":"date","title":"Date"},"open":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Open"},"high":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"High"},"low":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Low"},"close":{"type":"number","title":"Close"},"volume":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Volume"},"source":{"type":"string","title":"Source","default":"YAHOO_FINANCE"},"method":{"type":"string","title":"Method","default":"daily"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"}},"type":"object","required":["ticker","date","close"],"title":"TodayOHLCResponse"},"UniverseScreenResponse":{"properties":{"stocks":{"items":{"$ref":"#/components/schemas/UniverseSnapshotItem"},"type":"array","title":"Stocks"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"page_size":{"type":"integer","title":"Page Size"},"total_pages":{"type":"integer","title":"Total Pages"},"snapshot_date":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Snapshot Date"},"filters_applied":{"additionalProperties":true,"type":"object","title":"Filters Applied","default":{}},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata","default":{}}},"type":"object","required":["stocks","total_count","page","page_size","total_pages"],"title":"UniverseScreenResponse"},"UniverseSnapshotItem":{"properties":{"ticker":{"type":"string","title":"Ticker"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"market_cap":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Market Cap"},"close_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Close Price"},"shares_outstanding":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Shares Outstanding"},"sector":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Sector"},"industry":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Industry"},"exchange":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Exchange"},"snapshot_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Snapshot Date"}},"type":"object","required":["ticker"],"title":"UniverseSnapshotItem"},"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"},"WikiFeatures":{"properties":{"views":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Views","description":"Wikipedia pageviews on the event date"},"baseline_10d":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Baseline 10D","description":"Median pageviews over the prior 10 days"},"spike_10d":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Spike 10D","description":"views / baseline_10d; >1 means above-average attention"},"zscore_20d":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Zscore 20D","description":"Z-score vs prior 20-day mean/stdev; null if stdev=0"}},"type":"object","title":"WikiFeatures","example":{"baseline_10d":12400.0,"spike_10d":3.65,"views":45230,"zscore_20d":4.21}},"app__api__v1__endpoints__news_v2__HeadlineItem":{"properties":{"source":{"type":"string","title":"Source"},"source_id":{"type":"string","title":"Source Id"},"ticker":{"type":"string","title":"Ticker"},"tickers_all":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Tickers All"},"published_at":{"type":"string","title":"Published At"},"headline":{"type":"string","title":"Headline"},"summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Summary"},"url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Url"},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"vendor_categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Vendor Categories"},"categories":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Categories"},"raw_sentiment":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Raw Sentiment"},"is_primary":{"type":"boolean","title":"Is Primary"},"ingested_at":{"type":"string","title":"Ingested At"}},"type":"object","required":["source","source_id","ticker","published_at","headline","is_primary","ingested_at"],"title":"HeadlineItem"},"app__api__v1__endpoints__news_v2__HeadlinesResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/app__api__v1__endpoints__news_v2__HeadlineItem"},"type":"array","title":"Items"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"type":"object","required":["items"],"title":"HeadlinesResponse"},"app__api__v1__endpoints__overlay__HeadlineItem":{"properties":{"title":{"type":"string","title":"Title"},"publisher":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Publisher"},"published_at":{"type":"string","format":"date-time","title":"Published At"},"article_guid":{"type":"string","title":"Article Guid"}},"type":"object","required":["title","published_at","article_guid"],"title":"HeadlineItem"},"app__api__v1__endpoints__overlay__HeadlinesResponse":{"properties":{"symbol":{"type":"string","title":"Symbol"},"headlines":{"items":{"$ref":"#/components/schemas/app__api__v1__endpoints__overlay__HeadlineItem"},"type":"array","title":"Headlines"},"headline_count_6h":{"type":"integer","title":"Headline Count 6H"},"headline_count_24h":{"type":"integer","title":"Headline Count 24H"},"publisher_breadth_24h":{"type":"integer","title":"Publisher Breadth 24H"}},"type":"object","required":["symbol","headlines","headline_count_6h","headline_count_24h","publisher_breadth_24h"],"title":"HeadlinesResponse"}}},"tags":[{"name":"health","description":"Health check endpoints"},{"name":"financial","description":"Financial data retrieval endpoints"},{"name":"price","description":"Price data endpoints (OHLCV)"},{"name":"news","description":"News and social media endpoints"},{"name":"metadata","description":"Data catalog and metadata endpoints"},{"name":"filings","description":"SEC filings search, document listing, and exhibit extraction (8-K, 6-K, 20-F, 40-F)"},{"name":"etf","description":"ETF holdings endpoints"},{"name":"alpaca","description":"Alpaca Market Data endpoints (OHLCV bars, connection status)"},{"name":"finra","description":"FINRA RegSHO short sale volume data (ingest, query, ratio history)"},{"name":"admin","description":"Administrative endpoints (migration, etc.)"},{"name":"overlay","description":"Overlay headlines — Yahoo RSS headline collector"},{"name":"overlay-admin","description":"Overlay job log"},{"name":"screener","description":"Stock screener — condition-based filtering by market cap, volume, price, P/E, sector, exchange"},{"name":"stocks","description":"Stock market data — most active, 52-week gainers, trending, and index constituents (S&P 500 / Nasdaq 100)"},{"name":"attention","description":"Attention signals — Wikipedia pageview spikes and GDELT news article counts for event-centric backtesting"},{"name":"attention-admin","description":"Attention administrative endpoints — entity resolution, Wikipedia and GDELT data collection"},{"name":"database","description":"Database inspection — record counts, date ranges, raw data browsing, and ETF snapshot history"},{"name":"fred","description":"FRED (Federal Reserve Economic Data) — macroeconomic series via FRED API proxy"},{"name":"ownership","description":"SEC 13D/13G activist ownership events — activist filings, active positions (PIT-safe)"},{"name":"error-logs","description":"Error log management — browse and clear server-side error records"},{"name":"request-logs","description":"Request log management — browse API request history and latency records"}]} \ No newline at end of file diff --git a/requirements-api.txt b/requirements-api.txt index bad0073..cf75df4 100644 --- a/requirements-api.txt +++ b/requirements-api.txt @@ -38,6 +38,9 @@ feedparser>=6.0.0 # Yahoo RSS feed parsing apscheduler>=3.10.0 # Batch scheduling pytrends>=4.9.0 # Google Trends (experimental, disabled by default) +# News v2 (Alpaca News + StockTwits + Finnhub) +pandas_market_calendars>=4.3.0 # NYSE holiday/short-day handling for session windows + # Development & Testing pytest>=7.4.0 pytest-asyncio>=0.21.0 diff --git a/scripts/news_backfill.py b/scripts/news_backfill.py new file mode 100644 index 0000000..3329b72 --- /dev/null +++ b/scripts/news_backfill.py @@ -0,0 +1,163 @@ +""" +News v2 historical backfill — Finnhub (and future Alpaca catch-up). + +Usage (inside the API container): + python scripts/news_backfill.py \\ + --source finnhub \\ + --tickers AAPL,MSFT,NVDA \\ + --start 2025-04-25 \\ + --end 2026-04-25 \\ + --chunk monthly + + python scripts/news_backfill.py --source finnhub \\ + --tickers-file data/universe/v49_active.txt \\ + --start 2025-04-25 --end 2026-04-25 + +Estimated runtime (Finnhub free, 60 calls/min, monthly chunks): + 100 tickers × 12 months ≈ 1,200 calls ≈ 20 minutes. + +Idempotent: re-runs hit ON CONFLICT DO NOTHING on +(source, source_id, ticker), so overlapping windows are safe. +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import sys +from calendar import monthrange +from datetime import date, datetime, timedelta +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("news_backfill") + + +def _parse_date(s: str) -> date: + return datetime.strptime(s, "%Y-%m-%d").date() + + +def _load_tickers(args) -> list[str]: + tickers: list[str] = [] + if args.tickers: + tickers.extend(t.strip().upper() for t in args.tickers.split(",") if t.strip()) + if args.tickers_file: + path = Path(args.tickers_file) + if not path.exists(): + raise SystemExit(f"--tickers-file not found: {path}") + for raw in path.read_text().splitlines(): + t = raw.strip().upper() + if t and not t.startswith("#"): + tickers.append(t) + if not tickers: + raise SystemExit("Provide --tickers and/or --tickers-file") + # Dedup, preserve order + seen: set[str] = set() + out: list[str] = [] + for t in tickers: + if t not in seen: + seen.add(t) + out.append(t) + return out + + +def _monthly_chunks(start: date, end: date) -> list[tuple[date, date]]: + """Split [start, end] into per-month [chunk_start, chunk_end] inclusive bounds.""" + chunks: list[tuple[date, date]] = [] + cur = date(start.year, start.month, 1) + if cur < start: + cur = start + while cur <= end: + last_day_of_month = monthrange(cur.year, cur.month)[1] + chunk_end = min(end, date(cur.year, cur.month, last_day_of_month)) + chunks.append((cur, chunk_end)) + # Advance to first of next month + if cur.month == 12: + cur = date(cur.year + 1, 1, 1) + else: + cur = date(cur.year, cur.month + 1, 1) + return chunks + + +def _daily_chunks(start: date, end: date) -> list[tuple[date, date]]: + return [(start + timedelta(days=i), start + timedelta(days=i)) for i in range((end - start).days + 1)] + + +async def _backfill_finnhub(tickers: list[str], start: date, end: date, chunk: str) -> None: + from app.services.news.finnhub_client import FinnhubClient + from app.services.news.headline_ingest_service import ( + finnhub_articles_to_rows, + insert_headline_rows, + ) + + client = FinnhubClient() + if not client.is_configured(): + raise SystemExit("FINNHUB_API_KEY not set in environment") + + if chunk == "monthly": + ranges = _monthly_chunks(start, end) + elif chunk == "daily": + ranges = _daily_chunks(start, end) + else: + raise SystemExit(f"Unknown --chunk: {chunk}") + + total_calls = len(tickers) * len(ranges) + logger.info( + f"Finnhub backfill: {len(tickers)} tickers × {len(ranges)} {chunk} chunks " + f"= {total_calls} calls (~{total_calls / 60:.1f} min @ 60 cpm)" + ) + + grand_inserted = 0 + grand_calls = 0 + try: + for ticker in tickers: + ticker_inserted = 0 + for (chunk_start, chunk_end) in ranges: + try: + payload = await client.fetch_company_news(ticker, chunk_start, chunk_end) + except Exception as e: + logger.error(f"Finnhub fetch failed {ticker} {chunk_start}..{chunk_end}: {e}") + continue + grand_calls += 1 + if not payload: + continue + rows = finnhub_articles_to_rows(ticker, payload) + if not rows: + continue + inserted = await insert_headline_rows(rows) + ticker_inserted += inserted + grand_inserted += inserted + logger.info(f" {ticker}: +{ticker_inserted} new (cumulative {grand_inserted}, {grand_calls}/{total_calls} calls)") + finally: + await client.close() + + logger.info(f"Finnhub backfill done — {grand_inserted} new headlines inserted in {grand_calls} calls") + + +def main() -> None: + parser = argparse.ArgumentParser(description="News v2 historical backfill") + parser.add_argument("--source", required=True, choices=["finnhub"], help="Vendor source") + parser.add_argument("--tickers", help="Comma-separated ticker list") + parser.add_argument("--tickers-file", help="File with one ticker per line (# comments OK)") + parser.add_argument("--start", required=True, type=_parse_date, help="YYYY-MM-DD inclusive") + parser.add_argument("--end", required=True, type=_parse_date, help="YYYY-MM-DD inclusive") + parser.add_argument("--chunk", default="monthly", choices=["monthly", "daily"], help="Per-call window size") + + args = parser.parse_args() + + if args.start > args.end: + raise SystemExit("--start must be <= --end") + + tickers = _load_tickers(args) + logger.info(f"Loaded {len(tickers)} tickers") + + if args.source == "finnhub": + asyncio.run(_backfill_finnhub(tickers, args.start, args.end, args.chunk)) + + +if __name__ == "__main__": + main() diff --git a/scripts/seed_company_aliases.py b/scripts/seed_company_aliases.py new file mode 100644 index 0000000..7c30637 --- /dev/null +++ b/scripts/seed_company_aliases.py @@ -0,0 +1,111 @@ +""" +Seed the company_aliases table with canonical names + common short forms for +the TOP_50 watchlist. Idempotent — re-running is safe. + +Usage: + docker exec stock_oracle_api python -m scripts.seed_company_aliases +""" + +import asyncio +import logging +import sys + +from sqlalchemy import select + +from app.core.database import AsyncSessionLocal +from app.models.overlay_registry import CompanyAlias + +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger(__name__) + + +# (symbol, [(alias, alias_type, confidence), ...]) +# Confidence ≥ 0.7 is required for the EntityResolver to apply Stage 3 +# (alias-based) matches. +_SEEDS = { + "AAPL": [("Apple", "short", 0.95), ("Apple Inc", "canonical", 1.0)], + "MSFT": [("Microsoft", "short", 0.95), ("Microsoft Corporation", "canonical", 1.0)], + "NVDA": [("Nvidia", "short", 0.95), ("NVIDIA", "alias", 0.95), ("NVIDIA Corporation", "canonical", 1.0)], + "AMZN": [("Amazon", "short", 0.9), ("Amazon.com", "canonical", 1.0)], + "GOOGL": [("Google", "short", 0.85), ("Alphabet", "alias", 0.9), ("Alphabet Inc", "canonical", 1.0)], + "META": [("Meta", "short", 0.8), ("Meta Platforms", "canonical", 1.0), ("Facebook", "alias", 0.8)], + "TSLA": [("Tesla", "short", 0.95), ("Tesla Inc", "canonical", 1.0), ("Tesla Motors", "alias", 0.9)], + "BRK.B": [("Berkshire Hathaway", "canonical", 1.0), ("Berkshire", "short", 0.9)], + "JPM": [("JPMorgan", "short", 0.9), ("JPMorgan Chase", "canonical", 1.0), ("JP Morgan", "alias", 0.9)], + "JNJ": [("Johnson & Johnson", "canonical", 1.0), ("J&J", "short", 0.9)], + "V": [("Visa", "short", 0.9), ("Visa Inc", "canonical", 1.0)], + "UNH": [("UnitedHealth", "short", 0.9), ("UnitedHealth Group", "canonical", 1.0)], + "XOM": [("Exxon", "short", 0.9), ("ExxonMobil", "alias", 0.95), ("Exxon Mobil", "canonical", 1.0)], + "PG": [("Procter & Gamble", "canonical", 1.0), ("P&G", "short", 0.9)], + "MA": [("Mastercard", "short", 0.95), ("Mastercard Inc", "canonical", 1.0)], + "HD": [("Home Depot", "short", 0.9), ("The Home Depot", "canonical", 1.0)], + "CVX": [("Chevron", "short", 0.95), ("Chevron Corporation", "canonical", 1.0)], + "LLY": [("Eli Lilly", "short", 0.95), ("Eli Lilly and Company", "canonical", 1.0), ("Lilly", "alias", 0.75)], + "ABBV": [("AbbVie", "short", 0.95), ("AbbVie Inc", "canonical", 1.0)], + "BAC": [("Bank of America", "canonical", 1.0), ("BofA", "short", 0.9)], + "KO": [("Coca-Cola", "short", 0.9), ("The Coca-Cola Company", "canonical", 1.0), ("Coca Cola", "alias", 0.85)], + "PEP": [("Pepsi", "short", 0.85), ("PepsiCo", "canonical", 1.0)], + "AVGO": [("Broadcom", "short", 0.95), ("Broadcom Inc", "canonical", 1.0)], + "COST": [("Costco", "short", 0.95), ("Costco Wholesale", "canonical", 1.0)], + "WMT": [("Walmart", "short", 0.95), ("Wal-Mart", "alias", 0.9)], + "MRK": [("Merck", "short", 0.85), ("Merck & Co", "canonical", 1.0)], + "TMO": [("Thermo Fisher", "short", 0.9), ("Thermo Fisher Scientific", "canonical", 1.0)], + "DIS": [("Disney", "short", 0.9), ("Walt Disney", "alias", 0.9), ("The Walt Disney Company", "canonical", 1.0)], + "ACN": [("Accenture", "short", 0.95), ("Accenture plc", "canonical", 1.0)], + "ABT": [("Abbott", "short", 0.85), ("Abbott Laboratories", "canonical", 1.0)], + "VZ": [("Verizon", "short", 0.95), ("Verizon Communications", "canonical", 1.0)], + "ADBE": [("Adobe", "short", 0.9), ("Adobe Inc", "canonical", 1.0)], + "CRM": [("Salesforce", "short", 0.95), ("Salesforce.com", "canonical", 1.0)], + "NFLX": [("Netflix", "short", 0.95), ("Netflix Inc", "canonical", 1.0)], + "CMCSA": [("Comcast", "short", 0.95), ("Comcast Corporation", "canonical", 1.0)], + "TXN": [("Texas Instruments", "short", 0.95), ("Texas Instruments Incorporated", "canonical", 1.0)], + "CSCO": [("Cisco", "short", 0.9), ("Cisco Systems", "canonical", 1.0)], + "NKE": [("Nike", "short", 0.9), ("Nike Inc", "canonical", 1.0)], + "NEE": [("NextEra", "short", 0.9), ("NextEra Energy", "canonical", 1.0)], + "AMD": [("AMD", "short", 0.85), ("Advanced Micro Devices", "canonical", 1.0)], + "DHR": [("Danaher", "short", 0.95), ("Danaher Corporation", "canonical", 1.0)], + "BMY": [("Bristol-Myers Squibb", "canonical", 1.0), ("Bristol Myers Squibb", "alias", 0.95), ("Bristol-Myers", "short", 0.85)], + "QCOM": [("Qualcomm", "short", 0.95), ("Qualcomm Incorporated", "canonical", 1.0)], + "T": [("AT&T", "short", 0.95), ("AT&T Inc", "canonical", 1.0)], + "LOW": [("Lowe's", "short", 0.9), ("Lowe's Companies", "canonical", 1.0)], + "PM": [("Philip Morris", "short", 0.95), ("Philip Morris International", "canonical", 1.0)], + "HON": [("Honeywell", "short", 0.95), ("Honeywell International", "canonical", 1.0)], + "ORCL": [("Oracle", "short", 0.85), ("Oracle Corporation", "canonical", 1.0)], + "RTX": [("RTX", "short", 0.85), ("Raytheon", "alias", 0.85), ("Raytheon Technologies", "canonical", 1.0)], + "UPS": [("UPS", "short", 0.9), ("United Parcel Service", "canonical", 1.0)], +} + + +async def seed(): + inserted = 0 + skipped = 0 + async with AsyncSessionLocal() as db: + for symbol, aliases in _SEEDS.items(): + for value, alias_type, confidence in aliases: + existing = await db.execute( + select(CompanyAlias.id).where( + CompanyAlias.symbol == symbol, + CompanyAlias.alias_value == value, + ) + ) + if existing.first(): + skipped += 1 + continue + db.add( + CompanyAlias( + symbol=symbol, + alias_type=alias_type, + alias_value=value, + confidence=confidence, + active=True, + ) + ) + inserted += 1 + await db.commit() + logger.info(f"company_aliases seed: inserted={inserted}, skipped={skipped}") + return inserted + + +if __name__ == "__main__": + n = asyncio.run(seed()) + sys.exit(0 if n >= 0 else 1) diff --git a/stock_oracle_client.py b/stock_oracle_client.py index 3e2337a..8390dbd 100644 --- a/stock_oracle_client.py +++ b/stock_oracle_client.py @@ -499,7 +499,104 @@ class StockOracleClient: } return self._make_request('GET', f'/api/v1/news/{ticker}/social-only', params=params) - + + # ============= News v2 — multi-source structured ingest ============= + + def get_news_headlines( + self, + symbols: Optional[Union[str, List[str]]] = None, + start: Optional[str] = None, + end: Optional[str] = None, + sources: Optional[Union[str, List[str]]] = None, + limit: int = 100, + cursor: Optional[str] = None, + ) -> Dict: + """ + Fetch raw multi-source news headlines (Alpaca News, StockTwits, Finnhub, GDELT). + + Args: + symbols: Ticker(s) — string "AAPL" or list ["AAPL", "MSFT"]. Max 50. + start: UTC ISO datetime lower bound (inclusive) + end: UTC ISO datetime upper bound (exclusive) + sources: Optional filter, subset of {alpaca_benzinga, stocktwits, finnhub, gdelt} + limit: Max rows (1-500, default 100) + cursor: published_at_lt cursor (ISO datetime) for pagination + """ + params: Dict[str, Any] = {"limit": limit} + if symbols is not None: + params["symbols"] = symbols if isinstance(symbols, str) else ",".join(symbols) + if start is not None: + params["start"] = start + if end is not None: + params["end"] = end + if sources is not None: + params["sources"] = sources if isinstance(sources, str) else ",".join(sources) + if cursor is not None: + params["cursor"] = cursor + return self._make_request("GET", "/api/v1/news/v2/headlines", params=params) + + def get_news_session_aggregate( + self, + symbol: str, + session_date: str, + window: str = "premarket", + sources: Optional[Union[str, List[str]]] = None, + force_refresh: bool = False, + ) -> Dict: + """ + Session-aggregated news for one ticker. + + Args: + symbol: Ticker + session_date: ET session date (YYYY-MM-DD) + window: One of "premarket", "intraday", "post", "full_session" + sources: Optional source filter + force_refresh: Bypass Redis cache + """ + params: Dict[str, Any] = { + "symbol": symbol, + "session_date": session_date, + "window": window, + "force_refresh": str(force_refresh).lower(), + } + if sources is not None: + params["sources"] = sources if isinstance(sources, str) else ",".join(sources) + return self._make_request("GET", "/api/v1/news/v2/session_aggregate", params=params) + + def get_news_session_aggregate_batch( + self, + session_date: str, + window: str, + symbols: List[str], + sources: Optional[List[str]] = None, + ) -> Dict: + """ + Batch session-aggregated news. Up to 200 tickers per call. + + fithia2's V49 backtest hot path — call once per session, cache locally. + """ + body: Dict[str, Any] = { + "session_date": session_date, + "window": window, + "symbols": symbols, + } + if sources is not None: + body["sources"] = sources + return self._make_request("POST", "/api/v1/news/v2/session_aggregate/batch", json=body) + + def get_news_coverage(self, source: str, symbol: Optional[str] = None) -> Dict: + """ + Per-source ingest depth probe — call before backtest window selection. + + Args: + source: One of {alpaca_benzinga, stocktwits, finnhub, gdelt} + symbol: Optional ticker filter + """ + params: Dict[str, Any] = {"source": source} + if symbol is not None: + params["symbol"] = symbol + return self._make_request("GET", "/api/v1/news/v2/coverage", params=params) + # ============= Metadata & Catalog ============= def get_data_catalog(self) -> Dict: diff --git a/tests/test_news_v2_category_normalizer.py b/tests/test_news_v2_category_normalizer.py new file mode 100644 index 0000000..cec7880 --- /dev/null +++ b/tests/test_news_v2_category_normalizer.py @@ -0,0 +1,70 @@ +""" +Unit tests for app.services.news.category_normalizer. + +Covers vendor-map fallback, headline regex overrides, and the "drop general +when specific exists" finalize rule. +""" +from app.services.news.category_normalizer import ( + UNIFIED_CATEGORIES, + normalize_alpaca, + normalize_finnhub, + normalize_stocktwits, +) + + +def test_alpaca_upgrade_via_vendor_map(): + cats = normalize_alpaca(["Upgrades"], "Goldman raises Apple to Buy") + assert "analyst_rating_upgrade" in cats + + +def test_alpaca_downgrade_overrides_via_headline(): + # Vendor said "Analyst Color" (general), but headline mentions downgrade + cats = normalize_alpaca(["Analyst Color"], "Morgan Stanley downgrades NVDA to Hold") + assert "analyst_rating_downgrade" in cats + assert "general" not in cats # specific present → general dropped + + +def test_alpaca_fda_approval_vs_rejection_split(): + approval = normalize_alpaca(["FDA"], "FDA approves new oncology drug") + rejection = normalize_alpaca(["FDA"], "FDA rejects PDUFA application; CRL issued") + assert "fda_approval" in approval + assert "fda_rejection" in rejection + + +def test_finnhub_earnings_passthrough(): + cats = normalize_finnhub("earnings", "Apple beats Q1 estimates") + assert "earnings_release" in cats + + +def test_finnhub_unknown_category_falls_back_to_general(): + cats = normalize_finnhub("nonexistent", "") + assert cats == ["general"] + + +def test_stocktwits_default_general_with_no_keywords(): + assert normalize_stocktwits("Just bought some AAPL today") == ["general"] + + +def test_stocktwits_buyback_keyword_picks_up_category(): + cats = normalize_stocktwits("Apple announces $90B share repurchase program") + assert "buyback" in cats + + +def test_unified_categories_contains_all_mapped_targets(): + """Every vendor map target value must be a known unified category.""" + from app.services.news.category_normalizer import ( + ALPACA_BENZINGA_MAP, + FINNHUB_MAP, + ) + targets = set(ALPACA_BENZINGA_MAP.values()) | set(FINNHUB_MAP.values()) + assert targets <= UNIFIED_CATEGORIES, f"Stray targets: {targets - UNIFIED_CATEGORIES}" + + +def test_no_categories_yields_general_singleton(): + cats = normalize_alpaca(None, "") + assert cats == ["general"] + + +def test_management_change_pattern(): + cats = normalize_alpaca(["News"], "Acme Corp CEO Jane Smith steps down") + assert "management_change" in cats diff --git a/tests/test_news_v2_ingest_transforms.py b/tests/test_news_v2_ingest_transforms.py new file mode 100644 index 0000000..46b2f82 --- /dev/null +++ b/tests/test_news_v2_ingest_transforms.py @@ -0,0 +1,134 @@ +""" +Unit tests for vendor → row dict transforms in headline_ingest_service. + +These exercise the data-shape contract without hitting the database. +""" +from datetime import datetime, timezone + +from app.services.news.alpaca_news_client import AlpacaNewsArticle +from app.services.news.headline_ingest_service import ( + alpaca_articles_to_rows, + finnhub_articles_to_rows, + stocktwits_messages_to_rows, +) + + +def _alpaca_article(symbols, headline="Test", source="benzinga"): + return AlpacaNewsArticle( + id=12345, + headline=headline, + summary="summary", + url="https://example.com/n/12345", + author="Author", + created_at=datetime(2026, 4, 25, 13, 30, tzinfo=timezone.utc), + updated_at=datetime(2026, 4, 25, 13, 30, tzinfo=timezone.utc), + source=source, + symbols=symbols, + images=[], + content=None, + ) + + +def test_alpaca_emits_one_row_per_ticker_with_primary_flag(): + art = _alpaca_article(["AAPL", "MSFT", "NVDA"], headline="Tech rally continues") + rows = alpaca_articles_to_rows([art]) + assert len(rows) == 3 + tickers = [r["ticker"] for r in rows] + assert tickers == ["AAPL", "MSFT", "NVDA"] + primary_flags = [r["is_primary"] for r in rows] + assert primary_flags == [True, False, False] + # tickers_all preserves the full set on every row + for r in rows: + assert r["tickers_all"] == ["AAPL", "MSFT", "NVDA"] + assert r["source"] == "alpaca_benzinga" + assert r["source_id"] == "12345" + + +def test_alpaca_skips_articles_with_no_symbols(): + art = _alpaca_article([]) + assert alpaca_articles_to_rows([art]) == [] + + +def test_alpaca_published_at_is_tz_aware_utc(): + rows = alpaca_articles_to_rows([_alpaca_article(["AAPL"])]) + assert rows[0]["published_at"].tzinfo is not None + assert rows[0]["ingested_at"].tzinfo is not None + + +def test_alpaca_normalizes_categories_with_headline_override(): + art = _alpaca_article(["AAPL"], headline="Goldman downgrades AAPL to Sell") + rows = alpaca_articles_to_rows([art]) + assert "analyst_rating_downgrade" in rows[0]["categories"] + + +def test_finnhub_payload_to_rows(): + payload = [{ + "id": 999, + "datetime": 1745596800, # 2025-04-25 16:00 UTC + "headline": "Apple posts strong earnings", + "summary": "Beat on EPS and revenue", + "url": "https://finn/news/999", + "category": "earnings", + }] + rows = finnhub_articles_to_rows("AAPL", payload) + assert len(rows) == 1 + r = rows[0] + assert r["source"] == "finnhub" + assert r["source_id"] == "999" + assert r["ticker"] == "AAPL" + assert r["is_primary"] is True + assert "earnings_release" in r["categories"] + assert r["published_at"].tzinfo is not None + + +def test_finnhub_skips_rows_missing_id_or_timestamp(): + payload = [ + {"id": 1, "datetime": None, "headline": "x"}, # missing ts + {"datetime": 100, "headline": "x"}, # missing id + ] + assert finnhub_articles_to_rows("AAPL", payload) == [] + + +def test_stocktwits_bullish_score_and_dedup_key(): + msg = { + "id": 555, + "created_at": "2026-04-25T13:00:00Z", + "body": "AAPL to the moon", + "entities": {"sentiment": {"basic": "Bullish"}}, + "symbols": [{"symbol": "AAPL"}, {"symbol": "MSFT"}], + } + rows = stocktwits_messages_to_rows("AAPL", [msg]) + assert len(rows) == 1 + r = rows[0] + assert r["source"] == "stocktwits" + assert r["source_id"] == "555" + assert r["raw_sentiment"] == 1.0 + assert "AAPL" in r["tickers_all"] and "MSFT" in r["tickers_all"] + assert r["published_at"].tzinfo is not None + + +def test_stocktwits_bearish_score_negative(): + msg = { + "id": 1, + "created_at": "2026-04-25T13:00:00Z", + "body": "shorting this dog", + "entities": {"sentiment": {"basic": "Bearish"}}, + } + rows = stocktwits_messages_to_rows("AAPL", [msg]) + assert rows[0]["raw_sentiment"] == -1.0 + + +def test_stocktwits_neutral_returns_none_sentiment(): + msg = { + "id": 2, + "created_at": "2026-04-25T13:00:00Z", + "body": "watching this stock today", + "entities": {}, + } + rows = stocktwits_messages_to_rows("AAPL", [msg]) + assert rows[0]["raw_sentiment"] is None + + +def test_stocktwits_skips_empty_body(): + msg = {"id": 3, "created_at": "2026-04-25T13:00:00Z", "body": " "} + assert stocktwits_messages_to_rows("AAPL", [msg]) == [] diff --git a/tests/test_news_v2_session_window.py b/tests/test_news_v2_session_window.py new file mode 100644 index 0000000..16af179 --- /dev/null +++ b/tests/test_news_v2_session_window.py @@ -0,0 +1,95 @@ +""" +Unit tests for app.services.news.session_window. + +Critical invariant: `post` must end at the next trading day's premarket +start (04:00 ET) — NOT next session's open (09:30 ET) — to avoid 4-hour +overlap with the next session's `premarket` window. +""" +from datetime import date, datetime, time +from zoneinfo import ZoneInfo + +import pytest + +from app.services.news.session_window import session_window + +ET = ZoneInfo("America/New_York") +UTC = ZoneInfo("UTC") + + +def _et(d: date, t: time) -> datetime: + return datetime.combine(d, t, tzinfo=ET).astimezone(UTC) + + +def test_premarket_bounds_basic_weekday(): + # Wednesday 2026-04-22 (Tue 2026-04-21 prev day, both weekdays) + target = date(2026, 4, 22) + start, end = session_window(target, "premarket") + # Premarket starts at the previous session's *close*, not 04:00 + expected_start = _et(date(2026, 4, 21), time(16, 0)) + expected_end = _et(target, time(9, 30)) + assert start == expected_start + assert end == expected_end + + +def test_intraday_bounds_basic_weekday(): + target = date(2026, 4, 22) + start, end = session_window(target, "intraday") + assert start == _et(target, time(9, 30)) + assert end == _et(target, time(16, 0)) + + +def test_post_ends_at_next_premarket_start_not_next_open(): + """post must NOT extend to next session's 09:30 — that would overlap T+1 premarket.""" + target = date(2026, 4, 22) # Wed; next trading day Thu 2026-04-23 + _, end = session_window(target, "post") + expected_end = _et(date(2026, 4, 23), time(4, 0)) + assert end == expected_end, f"post should end at next premarket start (04:00 ET), got {end.astimezone(ET)}" + + +def test_post_and_next_premarket_are_disjoint(): + target = date(2026, 4, 22) + _, post_end = session_window(target, "post") + next_pm_start, _ = session_window(date(2026, 4, 23), "premarket") + # premarket starts at *previous close* (16:00), so they don't share boundary; + # the contract is post_end <= 04:00 ET while next premarket fully overlaps that + # zone. We assert the no-double-count guarantee: a 04:30 ET headline lands in + # premarket (Thu) only, not in post (Wed). + sample_dt = _et(date(2026, 4, 23), time(4, 30)) + assert sample_dt >= post_end, "04:30 ET headline should be past post-window end" + assert sample_dt > next_pm_start # premarket window includes 04:30 + + +def test_full_session_spans_prev_close_to_next_premarket_start(): + target = date(2026, 4, 22) + start, end = session_window(target, "full_session") + assert start == _et(date(2026, 4, 21), time(16, 0)) + assert end == _et(date(2026, 4, 23), time(4, 0)) + + +def test_premarket_after_weekend_uses_friday_close(): + """Monday's premarket starts at the previous *Friday*'s close, not Sunday.""" + target = date(2026, 4, 27) # Monday + start, _ = session_window(target, "premarket") + expected_start = _et(date(2026, 4, 24), time(16, 0)) # Friday + assert start == expected_start + + +def test_friday_post_extends_to_monday_premarket_start(): + """Friday's `post` window must extend across the weekend to Monday 04:00 ET.""" + target = date(2026, 4, 24) # Friday + _, end = session_window(target, "post") + expected_end = _et(date(2026, 4, 27), time(4, 0)) # Monday 04:00 + assert end == expected_end + + +def test_unknown_window_raises(): + with pytest.raises(ValueError): + session_window(date(2026, 4, 22), "lunch") # type: ignore[arg-type] + + +def test_returned_datetimes_are_utc_aware(): + start, end = session_window(date(2026, 4, 22), "premarket") + assert start.tzinfo is not None + assert end.tzinfo is not None + assert start.utcoffset().total_seconds() == 0 # type: ignore[union-attr] + assert end.utcoffset().total_seconds() == 0 # type: ignore[union-attr]