feat: day_gainers 5분봉 수집 + News v2 ingest + overlay 정리

주요 변경사항:

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 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent 27990ea908
commit 1d087b54a4

@ -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`: 페이지당 결과 수 (1250, 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 — 3060초 후 재시도
---
#### `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` — 1500 (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.

@ -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

@ -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")

@ -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

@ -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")

@ -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"])

@ -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(),
)

@ -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)},
)

@ -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",

@ -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"

@ -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",

@ -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",

@ -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",
]

@ -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"),
)

@ -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"),
)

@ -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)

@ -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"),
)

@ -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)
)

@ -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)

@ -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)

@ -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:3016: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:3016: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")

@ -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):

@ -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")

@ -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)

@ -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

@ -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

@ -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")

@ -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

@ -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

@ -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()

@ -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}")

@ -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)

@ -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,
}

@ -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,
}

@ -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

@ -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

@ -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,
}

@ -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)

@ -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

@ -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 <source> 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

@ -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

@ -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 3060 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,

@ -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:

@ -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:

@ -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 {}

@ -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:

@ -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=<your-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
---
## 백필 우선순위 권장 사항
| 우선순위 | 대상 | 이유 | 예상 소요 시간 |

@ -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

File diff suppressed because one or more lines are too long

@ -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

@ -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()

@ -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)

@ -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:

@ -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

@ -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]) == []

@ -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]
Loading…
Cancel
Save