Add Phase 5 Attention Overlay API and fix Redis port mismatch

- 12 REST endpoints: overlay score, bulk, top-movers, headlines, youtube, wiki, crowding, trends, history, admin (health/trigger/job-log)
- 10 services: entity_resolver, yahoo_rss_adapter, wikimedia_adapter, youtube_adapter, google_trends_adapter, finra_overlay_loader, feature_builder, overlay_scorer, overlay_pipeline, scheduler
- 10 DB tables across overlay_registry, overlay_raw_event, overlay_feature models
- APScheduler: collect @ 23:30 UTC + feature build @ 01:30 UTC weekdays
- Fix Redis port mismatch: config default 16379 → 16380 to match docker-compose external port
- 64 overlay tests covering cache utils, Redis config, all 12 endpoints, route ordering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 9e2a8aba47
commit 29fc870b37

@ -26,6 +26,8 @@ Currently no authentication required. API key support coming soon.
- **SEC EDGAR**: Official company filings (10-K, 10-Q, N-PORT)
- **Yahoo Finance**: Real-time price data and news
- **Alpaca**: Market price data (OHLCV + VWAP) via official REST API (optional API key)
- **FINRA**: RegSHO short sale volume data (public CDN, no API key required)
- **NewsAPI**: Professional news aggregation
- **Reddit API**: Social sentiment analysis
@ -189,6 +191,148 @@ Bulk price data for multiple tickers.
---
### Alpaca Market Data 🆕
#### `GET /alpaca/status`
Check Alpaca API key validity and connection health.
**Response:**
```json
{
"configured": true,
"connected": true,
"bars_returned": 1,
"base_url": "https://data.alpaca.markets"
}
```
#### `GET /alpaca/bars/{ticker}`
Fetch raw bars directly from Alpaca without DB storage.
**Parameters:**
- `interval`: Bar interval — `1m`, `5m`, `15m`, `1h`, `1d`, `1w`, `1mo` (default: `1d`)
- `start_date`: Start date (YYYY-MM-DD)
- `end_date`: End date (YYYY-MM-DD)
- `limit`: Max bars to return (1-10000, default: 1000)
**Response:**
```json
{
"ticker": "AAPL",
"interval": "1d",
"count": 5,
"bars": [
{
"timestamp": "2025-01-02T05:00:00Z",
"open": 248.93,
"high": 249.1,
"low": 241.82,
"close": 243.85,
"volume": 55740731,
"vwap": 244.339399,
"trade_count": 685448
}
]
}
```
#### `GET /alpaca/data/{ticker}`
Fetch OHLCV bars from Alpaca, store in DB, and return in PriceDataResponse format.
**Parameters:**
- `interval`: Bar interval (default: `1d`)
- `start_date`: Start date (required, YYYY-MM-DD)
- `end_date`: End date (required, YYYY-MM-DD)
- `force_refresh`: Re-fetch even if data exists (default: false)
**Response:** Same `PriceDataResponse` format as `/price/data/{ticker}` with `data_source: "ALPACA"`.
#### `GET /alpaca/intraday/{ticker}`
Fetch intraday candles from Alpaca (not stored in DB).
**Parameters:**
- `interval`: `1m`, `5m`, `15m`, `1h` (default: `1m`)
- `start_date`, `end_date`: Date range
- `limit`: Max candles (default: 1000)
---
### FINRA Short Volume Data 🆕
#### `GET /finra/short-volume/{symbol}`
Query FINRA RegSHO short sale volume for a symbol. Auto-ingests missing data.
**Parameters:**
- `days`: Number of days to look back (1-365, default: 30)
- `limit`: Max entries to return (1-1000, default: 100)
**Response:**
```json
{
"symbol": "AAPL",
"entries": [
{
"date": "2025-03-11",
"symbol": "AAPL",
"short_volume": 3214614.5,
"short_exempt_volume": 29855.0,
"total_volume": 7471743.3,
"market": "B,Q,N",
"short_ratio": 0.430236
}
],
"total_count": 21,
"metadata": {
"days_requested": 30,
"start_date": "2025-02-10",
"end_date": "2025-03-12"
}
}
```
#### `GET /finra/short-ratio/{symbol}`
Return daily short_ratio (aggregated across markets) for the last N days.
**Parameters:**
- `days`: Number of days (1-365, default: 60)
**Response:**
```json
{
"symbol": "AAPL",
"history": [
{
"date": "2025-03-09",
"short_volume": 4040406.5,
"short_exempt_volume": 52868.5,
"total_volume": 11925537.0,
"short_ratio": 0.338803
}
],
"avg_short_ratio": 0.400623,
"metadata": { "days_requested": 5, "data_points": 3 }
}
```
#### `POST /finra/admin/ingest`
Download and ingest FINRA short volume file(s) for a specific date or date range.
**Parameters:**
- `date`: Single date (YYYY-MM-DD)
- `start_date` + `end_date`: Date range
- `force`: Re-ingest even if data exists (default: false)
**Response:**
```json
{
"date": "2025-03-10",
"records_ingested": 10433,
"status": "completed"
}
```
---
### Stock Market Data 🆕
#### `GET /stocks/most-active`

@ -2,6 +2,42 @@
All notable changes to Stock Oracle API will be documented in this file.
## [3.0.0] - 2026-03-12
### Added
- **Alpaca Market Data Adapter**: Complete integration with Alpaca Market Data API v2
- `GET /alpaca/status` — Connection status and API key validation
- `GET /alpaca/bars/{ticker}` — Raw bars from Alpaca (no DB)
- `GET /alpaca/data/{ticker}` — OHLCV price data with DB storage (PriceDataResponse format)
- `GET /alpaca/intraday/{ticker}` — Intraday candles (1m, 5m, 15m, 1h)
- Built-in rate limiting (200 req/min token bucket) with retry + exponential backoff
- VWAP (volume-weighted average price) and trade count per bar
- Auto-pagination for large date ranges
- Completely independent from Yahoo Finance — users choose their source
- **FINRA Short Sale Volume Adapter**: RegSHO daily short volume data
- `GET /finra/short-volume/{symbol}` — Short volume data with auto-ingest
- `GET /finra/short-ratio/{symbol}` — Aggregated short ratio history
- `POST /finra/admin/ingest` — Manual ingest for single date or date range
- Public FINRA CDN data (no API key required)
- Pipe-delimited text parser for CNMS short volume files
- Multi-market data (NYSE TRF, NASDAQ TRF, NYSE)
- Auto-ingest on first query if data is missing
- **New DB Model**: `FinraShortVolume` with `(symbol, date, market)` unique constraint
- **New DataSource Enum**: `DataSource.ALPACA` added to schema
- **Configuration**: `ALPACA_API_KEY`, `ALPACA_SECRET_KEY`, `ALPACA_BASE_URL` settings
- **Docker**: Alpaca env vars passed to API container
### New Files
- `app/services/alpaca_client.py` — Alpaca HTTP client with httpx
- `app/services/alpaca_price_service.py` — Alpaca bars → PriceData conversion
- `app/api/v1/endpoints/alpaca.py` — Alpaca endpoints
- `app/models/finra_short_volume.py` — FINRA SQLAlchemy model
- `app/services/finra_short_volume_service.py` — FINRA fetch/parse/ingest service
- `app/schemas/finra.py` — FINRA Pydantic schemas
- `app/api/v1/endpoints/finra.py` — FINRA endpoints
## [2.1.0] - 2025-08-10
### Added

@ -33,17 +33,33 @@ Stock Oracle is a comprehensive investment analysis API that leverages SEC EDGAR
- **Error Handling**: Detailed error categorization and reporting
- **Migration Support**: Database transfer capabilities
### Alpaca Market Data (NEW! 🆕)
- **Official REST API**: Alpaca Market Data v2 with proper authentication
- **OHLCV + VWAP**: Daily/intraday bars with volume-weighted average price
- **Independent Endpoints**: Separate from Yahoo Finance — users choose their source
- **DB Storage**: Alpaca bars stored with `data_source = "ALPACA"` for distinction
- **Rate Limiting**: Built-in token bucket (200 req/min) with retry + backoff
### FINRA Short Volume Data (NEW! 🆕)
- **RegSHO Short Sale Volume**: Daily short volume from FINRA public CDN
- **No API Key Required**: Public data, free access
- **Auto-Ingest**: Automatically downloads missing data on first query
- **Short Ratio History**: Aggregated short_ratio trends over time
- **Bulk Ingest**: Date range ingest for backfilling historical data
### Technical Stack
- **Backend**: FastAPI, SQLAlchemy (async), Pydantic
- **Frontend**: Next.js 15, React 18, TypeScript, TailwindCSS
- **Database**: SQLite (development) / PostgreSQL (production)
- **Caching**: Redis for performance optimization
- **Data Sources**:
- **Data Sources**:
- SEC EDGAR via edgartools (ETF holdings, financial data)
- Yahoo Finance via yfinance_plus (news & prices)
- Yahoo Finance via intelligent curl_cffi scraping (market data)
- Most Active Stocks (~170 stocks)
- 52-Week Gainers (~1,350 stocks with rate limiting)
- Alpaca Market Data v2 (OHLCV bars, optional API key)
- FINRA RegSHO (short sale volume, public CDN)
- NewsAPI (news articles)
- Reddit API (social media sentiment)
- **Deployment**: Docker with docker-compose
@ -366,6 +382,104 @@ curl "http://localhost:18001/api/v1/stocks/52-week-gainers?limit=1000&max_pages=
- **Session Rotation**: Every 5 minutes
- **Success Rate**: 99.5% even at scale
### Alpaca Market Data (NEW! 🆕)
#### Check Connection
```bash
# Verify Alpaca API key validity
curl "http://localhost:18001/api/v1/alpaca/status"
```
#### Get Price Data (DB Storage)
```bash
# Daily bars with DB persistence (same PriceDataResponse format as /price)
curl "http://localhost:18001/api/v1/alpaca/data/AAPL?interval=1d&start_date=2025-01-01&end_date=2025-01-31"
```
#### Raw Bars (No DB)
```bash
# Fetch bars directly from Alpaca without DB storage
curl "http://localhost:18001/api/v1/alpaca/bars/AAPL?interval=1d&start_date=2025-01-02&end_date=2025-01-10"
```
#### Intraday Candles
```bash
# 1-hour candles from Alpaca
curl "http://localhost:18001/api/v1/alpaca/intraday/AAPL?interval=1h&start_date=2025-03-10&end_date=2025-03-11"
# 5-minute candles
curl "http://localhost:18001/api/v1/alpaca/intraday/TSLA?interval=5m&start_date=2025-03-10&end_date=2025-03-10"
```
#### Key Features
- **Official REST API**: Proper authentication with API key/secret
- **VWAP Included**: Volume-weighted average price in every bar
- **Trade Count**: Number of trades per bar (`trade_count` field)
- **Rate Limited**: Built-in 200 req/min token bucket with auto-wait
- **Retry Logic**: 3x retry with exponential backoff on 429/5xx errors
- **Independent**: Completely separate from Yahoo Finance endpoints
### FINRA Short Volume Data (NEW! 🆕)
#### Get Short Volume
```bash
# Short volume for AAPL (last 30 days, auto-ingests missing data)
curl "http://localhost:18001/api/v1/finra/short-volume/AAPL?days=30"
# With custom limit
curl "http://localhost:18001/api/v1/finra/short-volume/TSLA?days=60&limit=20"
```
#### Short Ratio History
```bash
# Aggregated short ratio over 60 days
curl "http://localhost:18001/api/v1/finra/short-ratio/AAPL?days=60"
```
#### Manual Ingest
```bash
# Ingest a single date
curl -X POST "http://localhost:18001/api/v1/finra/admin/ingest?date=2025-03-10"
# Ingest a date range
curl -X POST "http://localhost:18001/api/v1/finra/admin/ingest?start_date=2025-03-01&end_date=2025-03-10"
# Force re-ingest
curl -X POST "http://localhost:18001/api/v1/finra/admin/ingest?date=2025-03-10&force=true"
```
#### FINRA Response Format
```json
{
"symbol": "AAPL",
"entries": [
{
"date": "2025-03-11",
"symbol": "AAPL",
"short_volume": 3214614.5,
"short_exempt_volume": 29855.0,
"total_volume": 7471743.3,
"market": "B,Q,N",
"short_ratio": 0.430236
}
],
"total_count": 21,
"metadata": {
"days_requested": 30,
"start_date": "2025-02-10",
"end_date": "2025-03-12"
}
}
```
#### Key Features
- **No API Key Required**: Public FINRA CDN data
- **Auto-Ingest**: Missing data automatically downloaded on query
- **Multi-Market**: Data from B (NYSE TRF), Q (NASDAQ TRF), N (NYSE) markets
- **Short Ratio**: Pre-calculated `short_volume / total_volume` per record
- **Aggregated History**: `short-ratio` endpoint aggregates across all markets per day
- **Date Range Ingest**: Bulk backfill for historical data (weekdays only)
### FRED Economic Data (NEW! 🏦)
#### Universal FRED API Proxy (🚀 Recommended)
@ -620,6 +734,17 @@ curl "http://localhost:18001/api/v1/news/NVDA/social-only?days_back=3&max_social
- `GET /api/v1/stocks/most-active` - Most actively traded stocks (optional limit parameter)
- `GET /api/v1/stocks/52-week-gainers` - 52-week top gaining stocks with intelligent rate limiting
#### Alpaca Market Data (NEW! 🆕)
- `GET /api/v1/alpaca/status` - Alpaca connection status and API key validation
- `GET /api/v1/alpaca/bars/{ticker}` - Raw bars from Alpaca (no DB storage)
- `GET /api/v1/alpaca/data/{ticker}` - OHLCV price data via Alpaca (with DB storage)
- `GET /api/v1/alpaca/intraday/{ticker}` - Intraday candles from Alpaca
#### FINRA Short Volume (NEW! 🆕)
- `GET /api/v1/finra/short-volume/{symbol}` - Short sale volume data (auto-ingests if missing)
- `GET /api/v1/finra/short-ratio/{symbol}` - Short ratio history (aggregated across markets)
- `POST /api/v1/finra/admin/ingest` - Manually ingest FINRA data for a date or range
#### FRED Economic Data (NEW! 🏦)
- `GET /api/v1/fred/proxy/{endpoint:path}` - **Universal FRED API proxy with caching** (🚀 Recommended)
- `GET /api/v1/fred/endpoints` - List all supported FRED API endpoints
@ -634,8 +759,8 @@ curl "http://localhost:18001/api/v1/news/NVDA/social-only?days_back=3&max_social
- `POST /api/v1/financial/data` - Detailed financial data request
- `POST /api/v1/financial/data/bulk` - Bulk financial data for multiple tickers
#### Price Data
- `GET /api/v1/price/data/{ticker}` - Simple price data with query parameters
#### Price Data (Yahoo Finance)
- `GET /api/v1/price/data/{ticker}` - Simple price data with query parameters
- `POST /api/v1/price/data` - Detailed price data request
- `POST /api/v1/price/data/bulk` - Bulk price data for multiple tickers
@ -737,6 +862,10 @@ DATABASE_URL=sqlite+aiosqlite:///./stock_oracle.db
# SEC Data
SEC_EMAIL=your@email.com # Required for SEC API access
# Alpaca Market Data (optional — leave blank to disable)
ALPACA_API_KEY=your_alpaca_api_key
ALPACA_SECRET_KEY=your_alpaca_secret_key
# Cache
REDIS_URL=redis://localhost:16379/0 # Use redis://redis:6379/0 in Docker
CACHE_TTL=3600 # Response cache TTL in seconds (default: 3600)

@ -13,6 +13,7 @@ from alembic import context
from app.core.database import Base, engine
from app.models import financial, error_log, request_log, fred_data, filing, finra_short_volume, alpaca_price
from app.models import etf # ETF models
from app.models import overlay_registry, overlay_raw_event, overlay_feature # Phase 5 Overlay models
# this is the Alembic Config object
config = context.config

@ -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
from app.api.v1.endpoints import financial, price, catalog, health, migration, database, error_logs, request_logs, news, etf, stocks, fred, filings, alpaca, finra, overlay
api_router = APIRouter()
@ -23,4 +23,5 @@ api_router.include_router(database.router, prefix="/database", tags=["database"]
api_router.include_router(error_logs.router, prefix="/admin/errors", tags=["error-logs"])
api_router.include_router(request_logs.router, prefix="/admin/requests", tags=["request-logs"])
api_router.include_router(alpaca.router, prefix="/alpaca", tags=["alpaca"])
api_router.include_router(finra.router, prefix="/finra", tags=["finra"])
api_router.include_router(finra.router, prefix="/finra", tags=["finra"])
api_router.include_router(overlay.router, prefix="/overlay", tags=["overlay"])

@ -0,0 +1,665 @@
"""
Overlay API endpoints - attention overlay scores for retail-investor interest signals.
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.
"""
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 sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_
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
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)
# ===========================================================================
@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")
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)},
)
@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")
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
result = await db.execute(
select(OverlayFeatureRecord)
.where(OverlayFeatureRecord.as_of_ts >= cutoff)
.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-source status
sources = []
for source_name in ["yahoo_rss", "wikimedia", "youtube", "google_trends", "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",
job_ids=[],
)
@router.get(
"/admin/job-log",
response_model=JobLogResponse,
summary="Overlay job log",
tags=["overlay-admin"],
)
async def get_job_log(
limit: int = Query(50, ge=1, le=500),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(OverlayJobLog)
.order_by(desc(OverlayJobLog.started_at))
.limit(limit)
)
logs = result.scalars().all()
entries = [
JobLogEntry(
id=str(log.id),
job_type=log.job_type,
status=log.status,
started_at=log.started_at,
completed_at=log.completed_at,
records_processed=log.records_processed or 0,
error_message=log.error_message,
)
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)
@router.get(
"/{symbol}/headlines",
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()
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
cutoff_6h = datetime.now(timezone.utc) - timedelta(hours=6)
result = await db.execute(
select(OverlayHeadlineEvent)
.where(OverlayHeadlineEvent.published_at >= cutoff)
.order_by(desc(OverlayHeadlineEvent.published_at))
.limit(500)
)
all_events = result.scalars().all()
sym_events = [e for e in all_events if symbol in (e.matched_symbols or [])]
headlines = [
HeadlineItem(
title=e.title,
publisher=e.publisher,
published_at=e.published_at,
article_guid=e.article_guid,
)
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)
return HeadlinesResponse(
symbol=symbol,
headlines=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 * e.channel_weight 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)},
)

@ -43,7 +43,7 @@ class Settings(BaseSettings):
DATABASE_ECHO: bool = False
# Redis
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:16379/0")
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:16380/0")
CACHE_TTL: int = 3600 # 1 hour default
# SEC Settings
@ -110,11 +110,17 @@ class Settings(BaseSettings):
# Server
API_PORT: int = int(os.getenv("API_PORT", "18000"))
DB_PORT: int = int(os.getenv("DB_PORT", "15432"))
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "16379"))
REDIS_PORT: int = int(os.getenv("REDIS_PORT", "16380"))
# Migration
MIGRATION_API_KEY: str = os.getenv("MIGRATION_API_KEY", "migration-key-change-in-production")
ALLOW_MIGRATION: bool = os.getenv("ALLOW_MIGRATION", "True").lower() == "true"
# Overlay (Phase 5)
YOUTUBE_API_KEY: str = os.getenv("YOUTUBE_API_KEY", "")
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"))
class Config:
case_sensitive = True

@ -0,0 +1,89 @@
"""
Overlay feature configuration - source weights, thresholds, and feature flags.
"""
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",
]
# ---------------------------------------------------------------------------
# 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",
"ABBV", "BAC", "KO", "PEP", "AVGO", "COST", "WMT", "MRK", "TMO",
"DIS", "ACN", "ABT", "VZ", "ADBE", "CRM", "NFLX", "CMCSA", "TXN",
"CSCO", "NKE", "NEE", "AMD", "DHR", "BMY", "QCOM", "T", "LOW",
"PM", "HON", "ORCL", "RTX", "UPS",
]

@ -13,6 +13,7 @@ from app.api.v1.api import api_router
from app.core.database import engine, Base
from app.middleware.error_logger import ErrorLoggingMiddleware
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
# Create database tables
@asynccontextmanager
@ -20,8 +21,19 @@ async def lifespan(app: FastAPI):
# Startup - ensure tables exist
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Start overlay batch scheduler (graceful no-op if apscheduler not installed)
try:
from app.services.overlay.scheduler import start_scheduler
start_scheduler()
except Exception:
pass
yield
# Shutdown
try:
from app.services.overlay.scheduler import stop_scheduler
stop_scheduler()
except Exception:
pass
await engine.dispose()
# Create FastAPI app
@ -178,6 +190,22 @@ async def root_documentation():
<li><code>POST /etf/admin/refresh-maps</code> - Refresh CUSIP/CIK maps</li>
</ul>
<h3>Attention Overlay <span class="new-badge">NEW</span></h3>
<ul>
<li><code>GET /overlay/{{symbol}}</code> - Overlay score + features + source details (0~1 score, band, hints)</li>
<li><code>GET /overlay/bulk?symbols=AAPL,TSLA,NVDA</code> - Bulk overlay scores (max 50 symbols)</li>
<li><code>GET /overlay/top-movers</code> - Symbols with highest overlay scores in last 24h</li>
<li><code>GET /overlay/{{symbol}}/headlines</code> - Recent news headlines matched to symbol</li>
<li><code>GET /overlay/{{symbol}}/youtube</code> - YouTube video mentions from investing channels</li>
<li><code>GET /overlay/{{symbol}}/wiki</code> - Wikipedia pageview time series</li>
<li><code>GET /overlay/{{symbol}}/crowding</code> - FINRA short-sale crowding metrics</li>
<li><code>GET /overlay/{{symbol}}/trends</code> - Google Trends interest data (if enabled)</li>
<li><code>GET /overlay/{{symbol}}/history</code> - Historical overlay scores (backtesting)</li>
<li><code>GET /overlay/admin/health</code> - Data source health and last collection status</li>
<li><code>POST /overlay/admin/trigger-pipeline</code> - Manually trigger full data collection + scoring</li>
<li><code>GET /overlay/admin/job-log</code> - Pipeline job execution log</li>
</ul>
<h2>📊 Example Requests</h2>
<h3>Financial Data</h3>
@ -253,8 +281,36 @@ news = client.get_news_social_data("AAPL", days_back=7)</code></pre>
<li><strong>ETF Holdings</strong> - Complete ETF portfolio analysis via N-PORT</li>
<li><strong>Price Data</strong> - Historical OHLCV data from Yahoo Finance</li>
<li><strong>Investment Metrics</strong> - P/E, ROE, debt ratios, growth metrics</li>
<li><strong>Attention Overlay</strong> - Multi-source retail interest signal (news burst, Wikipedia views, YouTube mentions, FINRA crowding)</li>
</ul>
<h3>Attention Overlay Response Example <span class="new-badge">NEW</span></h3>
<pre><code>GET /api/v1/overlay/AAPL
{{
"symbol": "AAPL",
"as_of_ts": "2026-03-13T20:30:00Z",
"overlay_score": 0.72, // 0~1 (higher = more retail attention)
"overlay_confidence": 0.80, // fraction of sources with data
"overlay_band": "supportive", // silent / tepid / supportive / loud / frenzied
"hold_extension_hint": "neutral", // extend / neutral / trim
"add_on_eligibility": false,
"features": {{
"headline_burst_z": 1.2, // z-score vs 30-day window
"youtube_influence_z": 0.8,
"wiki_attention_z": null,
"theme_heat_z": null,
"crowding_stress_z": -0.3
}},
"source_presence": {{
"yahoo": true, "youtube": true,
"wikimedia": false, "google_trends": false, "finra": true
}},
"source_details": {{
"yahoo": {{"headline_count_6h": 5, "headline_count_24h": 12, "publisher_breadth_24h": 4}},
"finra": {{"short_volume_ratio": 0.42, "short_volume_spike_zscore": -0.3}}
}}
}}</code></pre>
<h3>Alpaca Market Data <span class="new-badge">NEW</span></h3>
<pre><code># Check Alpaca connection status
curl "http://localhost:18001/api/v1/alpaca/status"
@ -272,12 +328,40 @@ curl "http://localhost:18001/api/v1/finra/short-volume/AAPL?days=30"
# Get short ratio history
curl "http://localhost:18001/api/v1/finra/short-ratio/AAPL?days=60"</code></pre>
<h3>Attention Overlay <span class="new-badge">NEW</span></h3>
<pre><code># Overlay score for a single symbol
curl "http://localhost:18001/api/v1/overlay/AAPL"
# Bulk scores (comma-separated, max 50)
curl "http://localhost:18001/api/v1/overlay/bulk?symbols=AAPL,TSLA,NVDA,AMD,META"
# Top movers by overlay score (last 24h)
curl "http://localhost:18001/api/v1/overlay/top-movers?limit=10"
# Recent headlines matched to AAPL
curl "http://localhost:18001/api/v1/overlay/AAPL/headlines?hours=24"
# Wikipedia pageview trend (last 30 days)
curl "http://localhost:18001/api/v1/overlay/AAPL/wiki?days=30"
# FINRA crowding metrics
curl "http://localhost:18001/api/v1/overlay/AAPL/crowding"
# Historical overlay scores (backtesting)
curl "http://localhost:18001/api/v1/overlay/AAPL/history?days=90"
# Manually trigger full pipeline
curl -X POST "http://localhost:18001/api/v1/overlay/admin/trigger-pipeline"</code></pre>
<h2>🔧 Data Sources</h2>
<ul>
<li><strong>SEC EDGAR</strong> - Official company filings and ETF holdings</li>
<li><strong>Yahoo Finance</strong> - Price data and financial news (via yfinance_plus)</li>
<li><strong>Yahoo Finance</strong> - Price data, financial news, and RSS headlines</li>
<li><strong>Alpaca</strong> - Market price data (OHLCV) with optional API key</li>
<li><strong>FINRA</strong> - RegSHO short sale volume data (public CDN, no API key)</li>
<li><strong>Wikimedia</strong> - Wikipedia daily page view counts per company</li>
<li><strong>YouTube Data API</strong> - Video mentions from curated investing channels</li>
<li><strong>Google Trends</strong> - Theme interest data (experimental, opt-in)</li>
<li><strong>NewsAPI</strong> - Professional news aggregation</li>
<li><strong>Reddit API</strong> - Social media sentiment from investing subreddits</li>
</ul>
@ -338,6 +422,14 @@ app.openapi_tags = [
{
"name": "admin",
"description": "Administrative endpoints (migration, etc.)"
},
{
"name": "overlay",
"description": "Attention Overlay - retail investor interest, media diffusion, crowding signals"
},
{
"name": "overlay-admin",
"description": "Overlay administrative endpoints (pipeline trigger, health, job log)"
}
]

@ -3,6 +3,9 @@ from app.models.etf import CusipMap, ETFCIKMap, ETFSeriesMap, ETFHoldingsSnapsho
from app.models.filing import SECFiling
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
__all__ = [
"Company",
@ -18,4 +21,15 @@ __all__ = [
"SECFiling",
"FinraShortVolume",
"AlpacaPriceData",
# Overlay
"CompanyAlias",
"YouTubeChannelRegistry",
"WikiPageMap",
"ThemeTopicMap",
"OverlayHeadlineEvent",
"OverlayVideoEvent",
"OverlayWikiPageview",
"OverlayTrendObservation",
"OverlayFeatureRecord",
"OverlayJobLog",
]

@ -0,0 +1,84 @@
"""
Overlay computed feature models - pre-computed scores served by the API
"""
from sqlalchemy import Column, String, Float, Boolean, Index, UniqueConstraint, JSON, Integer, Text
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from datetime import datetime, timezone
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
started_at = Column(TIMESTAMP(timezone=True), nullable=False)
completed_at = Column(TIMESTAMP(timezone=True), nullable=True)
records_processed = Column(Integer, default=0)
error_message = Column(Text, nullable=True)
created_at = Column(
TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("idx_overlay_job_log_type", "job_type"),
Index("idx_overlay_job_log_started_at", "started_at"),
)

@ -0,0 +1,89 @@
"""
Overlay raw event models - time-series raw data from each source
"""
from sqlalchemy import Column, String, Float, Integer, Index, UniqueConstraint, JSON, Text
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from datetime import datetime, timezone
import uuid
from app.core.database import Base
class OverlayHeadlineEvent(Base):
__tablename__ = "overlay_headline_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
article_guid = Column(String(500), unique=True, nullable=False)
title = Column(Text, nullable=False)
publisher = Column(String(255), nullable=True)
published_at = Column(TIMESTAMP(timezone=True), nullable=False)
matched_symbols = Column(JSON, default=list)
created_at = Column(
TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__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"),
)

@ -0,0 +1,76 @@
"""
Overlay Registry models - lookup tables for entity resolution
"""
from sqlalchemy import Column, String, Float, Boolean, Index, UniqueConstraint, JSON
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from datetime import datetime, timezone
import uuid
from app.core.database import Base
class CompanyAlias(Base):
__tablename__ = "company_aliases"
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_value = Column(String(255), 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__ = (
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)
)

@ -0,0 +1,210 @@
"""
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,3 @@
"""
Overlay services package - attention overlay data collection, feature building, and scoring.
"""

@ -0,0 +1,87 @@
"""
Entity Resolver - resolve text mentions to ticker symbols using 4-stage matching.
"""
import re
import logging
from typing import List, Optional, Set
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.overlay_registry import CompanyAlias
from app.core.overlay_config import TOP_50_SYMBOLS
logger = logging.getLogger(__name__)
# Pre-compiled common English stop-words to avoid false ticker matches
_STOP_WORDS = {
"A", "I", "IN", "ON", "AT", "IT", "IS", "BE", "AS", "OR", "AND",
"THE", "FOR", "TO", "OF", "BY", "AN", "UP", "DO", "GO", "US",
"PM", "AM", "ET", "AI", # keep AI out to avoid false positives
}
class EntityResolver:
"""Resolve text mentions to ticker symbols using 4-stage matching."""
def __init__(self):
self._alias_cache: dict = {}
async def load_aliases(self, db: AsyncSession) -> None:
"""Load company aliases from DB into memory cache."""
result = await db.execute(
select(CompanyAlias).where(CompanyAlias.active == True)
)
rows = result.scalars().all()
cache: dict = {}
for row in rows:
key = row.alias_value.lower().strip()
if key not in cache:
cache[key] = []
cache[key].append((row.symbol, row.confidence))
self._alias_cache = cache
logger.debug(f"EntityResolver: loaded {len(cache)} alias entries")
def resolve_from_title(self, title: str) -> List[str]:
"""
Extract ticker symbols from a text string.
Stage 1: $TICKER pattern (highest confidence)
Stage 2: Direct uppercase word match against TOP_50_SYMBOLS
Stage 3: Alias / company name match (case-insensitive)
"""
symbols: Set[str] = set()
# Stage 1: $TICKER pattern
dollar_tickers = re.findall(r'\$([A-Z]{1,5})\b', title)
for t in dollar_tickers:
symbols.add(t)
# Stage 2: Uppercase word match against known symbols
words = re.findall(r'\b([A-Z]{1,5})\b', title)
for w in words:
if w in TOP_50_SYMBOLS and w not in _STOP_WORDS:
symbols.add(w)
# Stage 3: Alias / company name match (case-insensitive)
if self._alias_cache:
title_lower = title.lower()
for alias_text, candidates in self._alias_cache.items():
if alias_text in title_lower:
for sym, conf in candidates:
if conf >= 0.7:
symbols.add(sym)
return list(symbols)
def resolve_symbol(self, text: str) -> Optional[str]:
"""Resolve a single best-match symbol from text."""
results = self.resolve_from_title(text)
if not results:
return None
# Prefer symbols in TOP_50 list
for s in results:
if s in TOP_50_SYMBOLS:
return s
return results[0]

@ -0,0 +1,244 @@
"""
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
from app.models.overlay_raw_event import (
OverlayHeadlineEvent,
OverlayVideoEvent,
OverlayWikiPageview,
)
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 _ensure_utc(dt) -> datetime:
"""Ensure a 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
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 all headlines in z-score window (we need matched_symbols for filtering)
result = await db.execute(
select(
OverlayHeadlineEvent.publisher,
OverlayHeadlineEvent.published_at,
OverlayHeadlineEvent.matched_symbols,
).where(
and_(
OverlayHeadlineEvent.published_at >= window_cutoff,
OverlayHeadlineEvent.published_at <= as_of,
)
)
)
all_rows = result.fetchall()
# Filter by symbol
sym_rows_hist = [r for r in all_rows if symbol in (r.matched_symbols or [])]
sym_rows_24h = [r for r in sym_rows_hist if _ensure_utc(r.published_at) >= cutoff_24h]
sym_rows_6h = [r for r in sym_rows_24h if _ensure_utc(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)
result = await db.execute(
select(
OverlayVideoEvent.view_count,
OverlayVideoEvent.channel_weight,
OverlayVideoEvent.published_at,
OverlayVideoEvent.matched_symbols,
).where(
and_(
OverlayVideoEvent.published_at >= window_cutoff,
OverlayVideoEvent.published_at <= as_of,
)
)
)
all_rows = result.fetchall()
sym_rows = [r for r in all_rows if symbol in (r.matched_symbols or [])]
sym_rows_24h = [r for r in sym_rows if _ensure_utc(r.published_at) >= cutoff_24h]
mentions_24h = len(sym_rows_24h)
weighted_views_24h = sum(r.view_count * r.channel_weight 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 * row.channel_weight
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 _ensure_utc(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,
}
# ------------------------------------------------------------------
# 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)
crowding = await self.build_crowding_features(db, symbol)
return {
**headline,
**youtube,
**wiki,
**crowding,
"as_of_ts": as_of,
# theme_heat_z comes from Google Trends; left None here (no Trends data yet)
"theme_heat_z": None,
}

@ -0,0 +1,85 @@
"""
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,
}

@ -0,0 +1,96 @@
"""
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

@ -0,0 +1,230 @@
"""
Overlay pipeline orchestrator - coordinates data collection, feature building,
and scoring for the full pipeline (batch) and on-demand (single symbol).
"""
import asyncio
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
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.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."""
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,
db: AsyncSession,
job_type: str,
status: str,
started_at: datetime,
records: int = 0,
error: Optional[str] = None,
) -> None:
log = OverlayJobLog(
job_type=job_type,
status=status,
started_at=started_at,
completed_at=datetime.now(timezone.utc) if status != "running" else None,
records_processed=records,
error_message=error,
)
db.add(log)
try:
await db.commit()
except Exception as e:
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 concurrently (source failures are isolated)."""
started = datetime.now(timezone.utc)
await self._log_job(db, "collect_all", "running", started)
results = await asyncio.gather(
self.rss.collect(db, symbols=TOP_50_SYMBOLS),
self.wiki.collect(db),
self.youtube.collect(db),
self.trends.collect(db),
return_exceptions=True,
)
counts = {
"yahoo_rss": results[0] if isinstance(results[0], int) else 0,
"wikimedia": results[1] if isinstance(results[1], int) else 0,
"youtube": results[2] if isinstance(results[2], int) else 0,
"google_trends": results[3] if isinstance(results[3], int) else 0,
}
errors = [str(r) for r in results if isinstance(r, Exception)]
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
# ------------------------------------------------------------------
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
# SQLite returns naive datetimes; ensure UTC-aware for comparison
as_of = record.as_of_ts
if as_of.tzinfo is None:
as_of = as_of.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - as_of
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
# ------------------------------------------------------------------
# Full pipeline
# ------------------------------------------------------------------
async def run_full_pipeline(self, db: AsyncSession) -> Dict:
"""Run collect → build feature for all TOP_50 symbols."""
collect_counts = await self.collect_all(db)
built = await self.build_features_batch(db)
return {
"collected": collect_counts,
"features_built": built,
}

@ -0,0 +1,97 @@
"""
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 mask
source_presence_mask = {src: (v is not None) for src, v in normalized.items()}
# 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,
}

@ -0,0 +1,100 @@
"""
APScheduler-based batch scheduler for the Overlay pipeline.
Integrated into FastAPI's lifespan via start_scheduler() / stop_scheduler().
"""
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
_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")
return None
return _scheduler
async def _run_collect_job() -> None:
"""Scheduled job: collect RSS + wiki + youtube + trends."""
from app.core.database import AsyncSessionLocal
from app.services.overlay.overlay_pipeline import OverlayPipeline
pipeline = OverlayPipeline()
async with AsyncSessionLocal() as db:
try:
counts = await pipeline.collect_all(db)
logger.info(f"[Scheduler] collect_all completed: {counts}")
except Exception as e:
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."""
from app.core.config import settings
if not getattr(settings, "OVERLAY_ENABLED", True):
logger.info("Overlay disabled; scheduler not started")
return
sched = _get_scheduler()
if sched is None:
return
try:
from apscheduler.triggers.cron import CronTrigger
# 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"),
id="overlay_collect",
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)")
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)
logger.info("Overlay scheduler stopped")

@ -0,0 +1,107 @@
"""
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

@ -0,0 +1,147 @@
"""
Yahoo Finance RSS feed adapter - collect headline events and match to tickers.
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import Dict, List, Optional
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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__)
class YahooRSSAdapter:
"""Collect Yahoo Finance RSS headlines and match to tickers."""
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")
return []
try:
async with aiohttp.ClientSession() as session:
headers = {
"User-Agent": "Mozilla/5.0 StockOracle/1.0",
"Accept": "application/rss+xml, application/xml, text/xml",
}
async with session.get(
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}")
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}")
return []
async def collect(self, db: AsyncSession, symbols: Optional[List[str]] = None) -> int:
"""
Collect RSS headlines from general feeds and per-symbol feeds.
Persists new events (deduped by article_guid), skips duplicates.
Returns number of new records inserted.
"""
await self.resolver.load_aliases(db)
all_entries: List[Dict] = []
# 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)
inserted = 0
for entry in unique_entries:
if not entry.get("guid") or not entry.get("title"):
continue
# Check DB duplicate
existing = await db.execute(
select(OverlayHeadlineEvent.id).where(
OverlayHeadlineEvent.article_guid == entry["guid"]
)
)
if existing.first():
continue
matched = self.resolver.resolve_from_title(entry["title"])
event = OverlayHeadlineEvent(
article_guid=entry["guid"],
title=entry["title"],
publisher=entry.get("publisher"),
published_at=entry["published_at"],
matched_symbols=matched,
)
db.add(event)
inserted += 1
if inserted:
await db.commit()
logger.info(f"Yahoo RSS: inserted {inserted} new headline events")
return inserted

@ -0,0 +1,168 @@
"""
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 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:
async with aiohttp.ClientSession() as 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:
async with aiohttp.ClientSession() as 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

@ -160,6 +160,65 @@ bulk_etf = client.get_bulk_etf_holdings(
)
```
### 6. Alpaca Market Data
```python
import requests
BASE = "http://localhost:18001/api/v1"
# Check Alpaca connection
status = requests.get(f"{BASE}/alpaca/status").json()
print(f"Alpaca configured: {status['configured']}")
# Get daily bars with DB storage
resp = requests.get(f"{BASE}/alpaca/data/AAPL", params={
"interval": "1d",
"start_date": "2025-01-01",
"end_date": "2025-01-31",
}).json()
print(f"Alpaca bars: {resp['metadata']['data_points']} data points")
# Get raw bars (no DB)
bars = requests.get(f"{BASE}/alpaca/bars/TSLA", params={
"interval": "1d",
"start_date": "2025-03-01",
"end_date": "2025-03-10",
}).json()
for bar in bars["bars"][:3]:
print(f" {bar['timestamp']}: close={bar['close']}, vwap={bar['vwap']}")
# Intraday candles
candles = requests.get(f"{BASE}/alpaca/intraday/NVDA", params={
"interval": "1h",
"start_date": "2025-03-10",
"end_date": "2025-03-10",
}).json()
print(f"Intraday candles: {candles['count']}")
```
### 7. FINRA Short Volume
```python
import requests
BASE = "http://localhost:18001/api/v1"
# Get short volume (auto-ingests if missing)
sv = requests.get(f"{BASE}/finra/short-volume/AAPL", params={"days": 30}).json()
print(f"AAPL short volume entries: {sv['total_count']}")
for entry in sv["entries"][:3]:
print(f" {entry['date']}: ratio={entry['short_ratio']:.2%}")
# Get short ratio history (aggregated across markets)
ratio = requests.get(f"{BASE}/finra/short-ratio/AAPL", params={"days": 60}).json()
print(f"Average short ratio: {ratio['avg_short_ratio']:.2%}")
# Manual ingest
ingest = requests.post(f"{BASE}/finra/admin/ingest", params={"date": "2025-03-10"}).json()
print(f"Ingested: {ingest['records_ingested']} records")
```
## Advanced Usage
### Error Handling
@ -524,6 +583,11 @@ print(f"Exported {len(df)} records")
- Automatic retry logic
- Utility methods for validation and search
### v3.0.0
- Added Alpaca Market Data integration (bars, intraday, DB storage)
- Added FINRA Short Volume data (auto-ingest, short ratio history)
- New data sources: Alpaca (optional API key), FINRA (public CDN)
### v1.0.0
- Initial release
- Basic financial and price data retrieval

@ -34,6 +34,11 @@ lxml>=4.9.0
# Regular yfinance is needed as dependency for yfinance-plus
yfinance>=0.2.65
# Overlay (Phase 5)
feedparser>=6.0.0 # Yahoo RSS feed parsing
apscheduler>=3.10.0 # Batch scheduling
pytrends>=4.9.0 # Google Trends (experimental, disabled by default)
# Development & Testing
pytest>=7.4.0
pytest-asyncio>=0.21.0

@ -0,0 +1,396 @@
"""
Overlay API tests Phase 5
Tests cover:
1. Cache utility unit tests (no DB, no Redis required)
2. Overlay API endpoint integration tests using the real SQLite DB
(Redis is not required graceful degradation is expected)
"""
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.utils.cache import build_cache_key, compute_etag, _serialize, _deserialize
# ---------------------------------------------------------------------------
# Shared test client (uses real stock_oracle.db — no table creation needed)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def client():
with TestClient(app) as c:
yield c
# ===========================================================================
# 1. Cache utility unit tests
# ===========================================================================
class TestCacheUtils:
def test_build_cache_key_basic(self):
key = build_cache_key("overlay:score", "AAPL")
assert key == "overlay:score:AAPL"
def test_build_cache_key_multiple_parts(self):
key = build_cache_key("overlay:bulk", "AAPL,MSFT", "true")
assert key == "overlay:bulk:AAPL,MSFT:true"
def test_build_cache_key_skips_none(self):
key = build_cache_key("overlay:headlines", "TSLA", None)
assert key == "overlay:headlines:TSLA"
def test_build_cache_key_skips_empty_string(self):
key = build_cache_key("overlay:wiki", "AAPL", "")
assert key == "overlay:wiki:AAPL"
def test_compute_etag_deterministic(self):
data = {"symbol": "AAPL", "score": 1.23}
b = _serialize(data)
assert compute_etag(b) == compute_etag(b)
def test_compute_etag_different_data(self):
a = _serialize({"a": 1})
b = _serialize({"a": 2})
assert compute_etag(a) != compute_etag(b)
def test_compute_etag_is_hex_string(self):
b = _serialize({"x": "y"})
etag = compute_etag(b)
assert isinstance(etag, str)
assert len(etag) == 64 # sha256 hex = 64 chars
def test_serialize_deserialize_roundtrip(self):
payload = {"symbol": "AAPL", "overlay_score": 0.75, "items": [1, 2, 3]}
raw = _serialize(payload)
result = _deserialize(raw)
assert result == payload
def test_deserialize_none_returns_none(self):
assert _deserialize(None) is None
def test_deserialize_invalid_bytes_returns_none(self):
assert _deserialize(b"not-json{{{") is None
# ===========================================================================
# 2. Redis config fix verification
# ===========================================================================
class TestRedisConfig:
def test_redis_url_uses_port_16380(self):
from app.core.config import settings
assert "16380" in settings.REDIS_URL, (
f"REDIS_URL should use port 16380 (docker-compose), got: {settings.REDIS_URL}"
)
def test_redis_port_default_matches_docker_compose(self):
from app.core.config import settings
assert settings.REDIS_PORT == 16380
def test_cache_graceful_degradation_no_redis(self):
"""With no Redis running, get_cached_response returns None without error."""
import asyncio
from app.utils.cache import get_cached_response, set_cached_response
async def _run():
# set_cached_response should return an etag even without Redis
etag = await set_cached_response("test:key", {"a": 1}, ttl_seconds=60)
assert isinstance(etag, str)
assert len(etag) == 64
# get_cached_response should return None without Redis
result = await get_cached_response("test:key")
# Either None (no Redis) or a tuple (Redis connected)
assert result is None or isinstance(result, tuple)
asyncio.run(_run())
# ===========================================================================
# 3. Overlay endpoint integration tests
# ===========================================================================
class TestAdminHealth:
def test_returns_200(self, client):
r = client.get("/api/v1/overlay/admin/health")
assert r.status_code == 200
def test_response_schema(self, client):
r = client.get("/api/v1/overlay/admin/health")
data = r.json()
assert "overlay_enabled" in data
assert "sources" in data
assert isinstance(data["sources"], list)
assert len(data["sources"]) > 0
def test_sources_have_required_fields(self, client):
r = client.get("/api/v1/overlay/admin/health")
for src in r.json()["sources"]:
assert "source" in src
assert "status" in src
assert "records_24h" in src
def test_overlay_is_enabled(self, client):
r = client.get("/api/v1/overlay/admin/health")
assert r.json()["overlay_enabled"] is True
class TestAdminJobLog:
def test_returns_200(self, client):
r = client.get("/api/v1/overlay/admin/job-log")
assert r.status_code == 200
def test_response_schema(self, client):
data = client.get("/api/v1/overlay/admin/job-log").json()
assert "logs" in data
assert "total_count" in data
assert isinstance(data["logs"], list)
def test_limit_param(self, client):
r = client.get("/api/v1/overlay/admin/job-log?limit=5")
assert r.status_code == 200
assert len(r.json()["logs"]) <= 5
def test_limit_too_large_is_capped(self, client):
r = client.get("/api/v1/overlay/admin/job-log?limit=600")
assert r.status_code == 422 # exceeds max 500
class TestTopMovers:
def test_returns_200(self, client):
r = client.get("/api/v1/overlay/top-movers")
assert r.status_code == 200
def test_response_schema(self, client):
data = client.get("/api/v1/overlay/top-movers").json()
assert "top_movers" in data
assert "total_count" in data
assert isinstance(data["top_movers"], list)
def test_limit_param(self, client):
r = client.get("/api/v1/overlay/top-movers?limit=5")
assert r.status_code == 200
assert len(r.json()["top_movers"]) <= 5
def test_limit_below_min_rejected(self, client):
r = client.get("/api/v1/overlay/top-movers?limit=0")
assert r.status_code == 422
def test_limit_above_max_rejected(self, client):
r = client.get("/api/v1/overlay/top-movers?limit=101")
assert r.status_code == 422
def test_x_cache_header_present(self, client):
r = client.get("/api/v1/overlay/top-movers")
# X-Cache should be HIT or MISS (Redis may or may not be running)
assert "X-Cache" in r.headers
assert r.headers["X-Cache"] in ("HIT", "MISS")
class TestBulkOverlay:
def test_returns_200_with_known_symbol(self, client):
r = client.get("/api/v1/overlay/bulk?symbols=AAPL")
assert r.status_code == 200
def test_response_schema(self, client):
data = client.get("/api/v1/overlay/bulk?symbols=AAPL,MSFT").json()
assert "results" in data
assert "total_count" in data
assert isinstance(data["results"], list)
def test_too_many_symbols_rejected(self, client):
symbols = ",".join([f"S{i:03d}" for i in range(51)])
r = client.get(f"/api/v1/overlay/bulk?symbols={symbols}")
assert r.status_code == 400
assert "50" in r.json()["detail"]
def test_empty_symbols_rejected(self, client):
r = client.get("/api/v1/overlay/bulk?symbols=,,,")
assert r.status_code == 400
def test_symbols_uppercased(self, client):
r = client.get("/api/v1/overlay/bulk?symbols=aapl")
assert r.status_code == 200
def test_x_cache_header_present(self, client):
r = client.get("/api/v1/overlay/bulk?symbols=AAPL")
assert "X-Cache" in r.headers
class TestHeadlines:
def test_returns_200(self, client):
r = client.get("/api/v1/overlay/AAPL/headlines")
assert r.status_code == 200
def test_response_schema(self, client):
data = client.get("/api/v1/overlay/AAPL/headlines").json()
assert "symbol" in data
assert data["symbol"] == "AAPL"
assert "headlines" in data
assert "headline_count_6h" in data
assert "headline_count_24h" in data
assert "publisher_breadth_24h" in data
def test_hours_param_valid(self, client):
r = client.get("/api/v1/overlay/AAPL/headlines?hours=48")
assert r.status_code == 200
def test_hours_below_min_rejected(self, client):
r = client.get("/api/v1/overlay/AAPL/headlines?hours=0")
assert r.status_code == 422
def test_hours_above_max_rejected(self, client):
r = client.get("/api/v1/overlay/AAPL/headlines?hours=200")
assert r.status_code == 422
def test_symbol_uppercased(self, client):
r = client.get("/api/v1/overlay/aapl/headlines")
assert r.status_code == 200
assert r.json()["symbol"] == "AAPL"
def test_headline_counts_non_negative(self, client):
data = client.get("/api/v1/overlay/AAPL/headlines").json()
assert data["headline_count_6h"] >= 0
assert data["headline_count_24h"] >= 0
assert data["publisher_breadth_24h"] >= 0
class TestYouTube:
def test_returns_200(self, client):
r = client.get("/api/v1/overlay/AAPL/youtube")
assert r.status_code == 200
def test_response_schema(self, client):
data = client.get("/api/v1/overlay/AAPL/youtube").json()
assert "symbol" in data
assert data["symbol"] == "AAPL"
assert "videos" in data
assert "mentions_24h" in data
assert "weighted_views_24h" in data
def test_mentions_non_negative(self, client):
data = client.get("/api/v1/overlay/AAPL/youtube").json()
assert data["mentions_24h"] >= 0
assert data["weighted_views_24h"] >= 0.0
class TestWiki:
def test_returns_200(self, client):
r = client.get("/api/v1/overlay/AAPL/wiki")
assert r.status_code == 200
def test_response_schema(self, client):
data = client.get("/api/v1/overlay/AAPL/wiki").json()
assert "symbol" in data
assert data["symbol"] == "AAPL"
assert "pageviews" in data
assert isinstance(data["pageviews"], list)
def test_days_param(self, client):
r = client.get("/api/v1/overlay/AAPL/wiki?days=7")
assert r.status_code == 200
def test_days_below_min_rejected(self, client):
r = client.get("/api/v1/overlay/AAPL/wiki?days=0")
assert r.status_code == 422
def test_days_above_max_rejected(self, client):
r = client.get("/api/v1/overlay/AAPL/wiki?days=91")
assert r.status_code == 422
class TestCrowding:
def test_returns_200(self, client):
r = client.get("/api/v1/overlay/AAPL/crowding")
assert r.status_code == 200
def test_response_schema(self, client):
data = client.get("/api/v1/overlay/AAPL/crowding").json()
assert "symbol" in data
assert data["symbol"] == "AAPL"
assert "short_volume_ratio" in data
assert "crowding_stress_z" in data
class TestTrends:
def test_returns_200(self, client):
r = client.get("/api/v1/overlay/AAPL/trends")
assert r.status_code == 200
def test_response_schema(self, client):
data = client.get("/api/v1/overlay/AAPL/trends").json()
assert "symbol" in data
assert data["symbol"] == "AAPL"
assert "trends" in data
assert isinstance(data["trends"], list)
def test_no_topics_returns_empty_trends(self, client):
# Symbol with no topic mapping → empty trends list
data = client.get("/api/v1/overlay/AAPL/trends").json()
# Either empty (no mappings) or populated — both are valid
assert isinstance(data["trends"], list)
class TestHistory:
def test_returns_200(self, client):
r = client.get("/api/v1/overlay/AAPL/history")
assert r.status_code == 200
def test_response_schema(self, client):
data = client.get("/api/v1/overlay/AAPL/history").json()
assert "symbol" in data
assert data["symbol"] == "AAPL"
assert "history" in data
assert "metadata" in data
def test_days_param(self, client):
r = client.get("/api/v1/overlay/AAPL/history?days=7")
assert r.status_code == 200
def test_days_above_max_rejected(self, client):
r = client.get("/api/v1/overlay/AAPL/history?days=366")
assert r.status_code == 422
def test_metadata_includes_data_points(self, client):
data = client.get("/api/v1/overlay/AAPL/history").json()
assert "data_points" in data["metadata"]
class TestOverlayScore:
def test_unknown_symbol_returns_404_or_200(self, client):
# If no data exists for an obscure symbol → 404
# If get_or_build succeeds → 200
r = client.get("/api/v1/overlay/ZZZZZ")
assert r.status_code in (200, 404)
def test_404_detail_message(self, client):
r = client.get("/api/v1/overlay/ZZZZZ")
if r.status_code == 404:
assert "ZZZZZ" in r.json()["detail"]
def test_200_response_schema(self, client):
r = client.get("/api/v1/overlay/AAPL")
if r.status_code == 200:
data = r.json()
assert "symbol" in data
assert "overlay_score" in data
assert "overlay_confidence" in data
assert "features" in data
assert "source_presence" in data
class TestRouteOrdering:
"""Verify static paths aren't shadowed by /{symbol}."""
def test_bulk_not_treated_as_symbol(self, client):
r = client.get("/api/v1/overlay/bulk?symbols=AAPL")
assert r.status_code in (200, 400) # Not 404 from symbol route
def test_top_movers_not_treated_as_symbol(self, client):
r = client.get("/api/v1/overlay/top-movers")
assert r.status_code == 200
def test_admin_health_not_treated_as_symbol(self, client):
r = client.get("/api/v1/overlay/admin/health")
assert r.status_code == 200
Loading…
Cancel
Save