fix(overlay): 다중 버그 수정 및 OpenAPI 문서 개선

## Overlay 버그 수정

- **collect_all 동시성 오류**: asyncio.gather로 공유 DB 세션에 동시 접근 → SQLAlchemy 오류
  발생. 어댑터를 순차 실행으로 변경
- **feedparser/apscheduler/pytrends 미설치**: Docker 이미지 재빌드로 패키지 영구 포함
- **중복 job log 항목**: _log_job이 매번 새 행 삽입 → running+completed 중복 생성.
  기존 running 행을 업데이트하도록 수정
- **admin/health 잘못된 job_type**: yahoo_rss/wikimedia 등 존재하지 않는 타입 조회.
  실제 로깅되는 collect_all/feature_build만 조회하도록 수정
- **source_presence 항상 false**: z-score가 계산 불가능하면(2일 미만 데이터) source가
  false로 표시됨. 실제 데이터 존재 여부(headline_count_24h > 0 등)로 판단하도록 수정
- **top-movers 심볼 중복**: 파이프라인 실행 횟수만큼 같은 심볼 반복 출력.
  심볼별 최신 레코드만 조회하는 서브쿼리로 수정
- **YouTube None 곱셈 오류**: view_count * channel_weight에서 None이면 TypeError.
  (or 0) / (or 0.5) 가드 추가

## Trends 기능 수정

- **ThemeTopicMap 자동 시딩**: 파이프라인 최초 실행 시 TOP_50_SYMBOLS에 대한
  기본 topic 매핑 자동 생성
- **GOOGLE_TRENDS_ENABLED=true**: docker-compose.yml에 환경변수 추가
- **theme_heat_z 항상 null**: feature_builder에 build_trends_features() 메서드
  누락 → OverlayTrendObservation 데이터가 점수에 반영 안 됨. 메서드 추가 및 연결
- **POST /admin/seed-topics**: ThemeTopicMap 수동 시딩용 admin 엔드포인트 추가

## OpenAPI 문서 개선

- 모든 엔드포인트에 summary/description 추가 (filings, news, database, etf, stocks,
  screener, fred, attention, overlay)
- Pydantic 스키마에 json_schema_extra example 추가 (attention, filing)
- 누락된 태그 6개 추가 (attention, attention-admin, database, fred, error-logs,
  request-logs)
- 루트(/) 랜딩 페이지를 Swagger UI로 리다이렉트로 교체

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent afd133e027
commit 4a0aff9ad6

@ -228,11 +228,14 @@ async def admin_collect_gdelt(
"/entity/{ticker}",
response_model=EntityResolveResponse,
summary="Get entity mapping for a ticker",
description=(
"Returns the stored entity mapping for a ticker: canonical name, Wikipedia title, "
"GDELT query string, and resolver confidence score.\n\n"
"Returns 404 if no mapping exists — run `POST /admin/resolve/{ticker}` first."
),
description="""
Returns the stored entity mapping for a ticker: canonical name, Wikipedia title,
GDELT query string, and resolver confidence score.
Returns **404** if no mapping exists run `POST /admin/resolve/{ticker}` first.
**Example**: `GET /attention/entity/AAPL`
""",
)
async def get_entity(
ticker: str,
@ -265,18 +268,34 @@ async def get_entity(
"/event/{ticker}",
response_model=EventAttentionResponse,
summary="Get attention features for a ticker on an event date",
description=(
"Returns Wikipedia pageview spike/z-score and GDELT news article counts "
"for the given ticker centered on `event_date`.\n\n"
"**Wikipedia data** is collected on-demand if not in DB. "
"**GDELT data** is never collected on-demand — it must be pre-populated via "
"`POST /admin/collect/gdelt/{ticker}` (scheduler). "
"Check `news.gdelt_status` in the response to understand data availability:\n\n"
"- `collected` — GDELT was collected; counts are accurate (0 means genuinely no articles)\n"
"- `not_collected` — scheduler has not run for this date yet\n"
"- `not_available` — event date is before GDELT V2 coverage (2017-01-01)\n\n"
"If no entity mapping exists, resolution runs automatically before collection."
),
description="""
Returns Wikipedia pageview spike/z-score and GDELT news volume for a ticker
centered on a specific event date. Designed for event-driven backtesting.
**Wikipedia signals** (collected on-demand):
- `wiki.views` raw pageview count on `event_date`
- `wiki.spike_10d` views / 10-day median baseline; >1 = above-average interest
- `wiki.zscore_20d` standard-deviation units above 20-day mean
**GDELT news signals** (pre-populated by scheduler only):
- `news.article_count_1d` articles published on `event_date`
- `news.article_count_3d` articles in `event_date ± 1 day` window
- `news.unique_domains_3d` distinct publisher domains in that window
- `news.gdelt_status` data availability flag:
- `collected` scheduler ran; counts are accurate (0 = genuinely no articles)
- `not_collected` scheduler has not run yet; use `POST /admin/collect/gdelt/{ticker}`
- `not_available` event date is before GDELT V2 coverage (2017-01-01)
**Auto-resolution**: if no entity mapping exists, resolution runs automatically first.
**Examples**:
- `GET /attention/event/AAPL?event_date=2024-02-01` Q1 earnings day attention
- `GET /attention/event/NVDA?event_date=2024-05-22` post-earnings spike
""",
responses={
404: {"description": "Ticker not found or entity resolution failed"},
500: {"description": "Feature materialization or collection error"},
},
)
async def get_event_attention(
ticker: str,

@ -17,7 +17,18 @@ from app.schemas.financial import DataSource
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/stats")
@router.get(
"/stats",
summary="Database record counts and date ranges",
description="""
Returns aggregate statistics across all core tables:
- `companies` total companies, how many have financial/price data
- `financial_data` total records, real vs estimated, date range, breakdown by source
- `price_data` total records, date range, list of tickers
- `calculated_metrics` total records and date range
""",
)
async def get_database_stats(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""
데이터베이스 통계 정보를 반환합니다.
@ -156,7 +167,7 @@ async def get_database_stats(db: AsyncSession = Depends(get_db)) -> Dict[str, An
detail=f"Failed to fetch database statistics: {str(e)}"
)
@router.get("/health")
@router.get("/health", summary="Database connection health check")
async def get_database_health(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""
데이터베이스 연결 상태를 확인합니다.
@ -178,7 +189,7 @@ async def get_database_health(db: AsyncSession = Depends(get_db)) -> Dict[str, A
detail=f"Database connection failed: {str(e)}"
)
@router.get("/tables")
@router.get("/tables", summary="Table row counts for all core tables")
async def get_table_info(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""
데이터베이스 테이블 정보를 반환합니다.
@ -213,7 +224,7 @@ async def get_table_info(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
detail=f"Failed to fetch table info: {str(e)}"
)
@router.post("/cleanup/duplicates")
@router.post("/cleanup/duplicates", summary="Remove duplicate financial and metrics records")
async def cleanup_duplicate_records(db: AsyncSession = Depends(get_db)) -> Dict[str, Any]:
"""
Remove duplicate financial and metrics records, keeping the most recent real data.
@ -263,7 +274,7 @@ async def cleanup_duplicate_records(db: AsyncSession = Depends(get_db)) -> Dict[
detail=f"Failed to cleanup duplicates: {str(e)}"
)
@router.get("/tickers")
@router.get("/tickers", summary="List tickers available in the database")
async def get_available_tickers(db: AsyncSession = Depends(get_db)) -> Dict[str, List[str]]:
"""
사용 가능한 종목 목록을 반환합니다.
@ -306,7 +317,18 @@ async def get_available_tickers(db: AsyncSession = Depends(get_db)) -> Dict[str,
# ETF persisted data browsing
# ==========================
@router.get("/etf/snapshots")
@router.get(
"/etf/snapshots",
summary="List persisted ETF holdings snapshots",
description="""
Browse ETF holdings snapshots stored in the database. Each snapshot represents
the portfolio as reported in a SEC 13-F filing.
Filter by `ticker`, `start_date`, `end_date`. Results are ordered by snapshot date (newest first).
**Example**: `GET /database/etf/snapshots?ticker=SPY&limit=10`
""",
)
async def list_etf_snapshots(
ticker: str | None = None,
start_date: str | None = None,
@ -366,7 +388,7 @@ async def list_etf_snapshots(
raise HTTPException(status_code=500, detail="Failed to list ETF snapshots")
@router.get("/etf/snapshot/{snapshot_id}")
@router.get("/etf/snapshot/{snapshot_id}", summary="Get ETF snapshot with full holdings list")
async def get_etf_snapshot(snapshot_id: str, db: AsyncSession = Depends(get_db)):
try:
from uuid import UUID
@ -411,7 +433,18 @@ async def get_etf_snapshot(snapshot_id: str, db: AsyncSession = Depends(get_db))
# Financial records browsing list
# ==============================
@router.get("/financial/records")
@router.get(
"/financial/records",
summary="Browse raw financial data records",
description="""
List raw financial data rows from the `financial_data` table.
Supports filtering by `ticker`, `period_type` (`quarterly`/`annual`),
`start_date`, and `end_date`. Results ordered by `period_date` descending.
**Example**: `GET /database/financial/records?ticker=AAPL&period_type=quarterly&limit=8`
""",
)
async def list_financial_records(
ticker: str | None = None,
period_type: str | None = None,

@ -33,7 +33,22 @@ class ETFHoldingsOut(BaseModel):
error: Optional[str] = None
@router.get("/holdings/{ticker}", response_model=ETFHoldingsOut)
@router.get(
"/holdings/{ticker}",
response_model=ETFHoldingsOut,
summary="Get ETF portfolio holdings",
description=(
"Fetch the constituent holdings of an ETF (e.g., SPY, QQQ, IWM). "
"Data is sourced from SEC 13-F filings and cached for 1 hour.\n\n"
"Use `top_n` to limit to the N largest positions, or `top_percentage` to return "
"the minimal set of holdings that covers X% of the portfolio (e.g., `top_percentage=0.8` "
"for the holdings making up 80% of the ETF).\n\n"
"**Examples**:\n"
"- `GET /etf/holdings/SPY` — all holdings\n"
"- `GET /etf/holdings/QQQ?top_n=10` — top 10 positions\n"
"- `GET /etf/holdings/IWM?top_percentage=0.5` — holdings covering 50% of portfolio"
),
)
@with_cache(namespace="etf:holdings", ttl=3600, key_params=["ticker", "as_of_date", "top_n", "top_percentage"])
async def get_etf_holdings(
ticker: str,
@ -90,7 +105,16 @@ class RefreshMapsOut(BaseModel):
etf_rows: int = Field(...)
@router.post("/admin/refresh-maps", response_model=RefreshMapsOut)
@router.post(
"/admin/refresh-maps",
response_model=RefreshMapsOut,
summary="Refresh ETF CIK and CUSIP mapping tables",
description=(
"Re-fetches and upserts the ETF→CIK and CUSIP→ticker mapping tables from SEC data. "
"Run this when new ETFs need to be supported. Returns the number of rows updated."
),
tags=["etf"],
)
async def refresh_etf_maps(db: AsyncSession = Depends(get_db)):
refreshed = await etf_loader_service.refresh_all(db)
return RefreshMapsOut(**refreshed)

@ -31,7 +31,17 @@ router = APIRouter()
logger = logging.getLogger("app.api.v1.filings")
@router.get("/search/{ticker}", response_model=FilingSearchResponse)
@router.get(
"/search/{ticker}",
response_model=FilingSearchResponse,
summary="Search SEC filings for a ticker",
description=(
"Search SEC filings for the given ticker. Supported form types: **8-K, 6-K, 20-F, 40-F**.\n\n"
"Auto-indexes filings from EDGAR on first request (or when `force_refresh=true`). "
"Results are cached for 1 hour.\n\n"
"**Example**: `GET /filings/search/AAPL?form_type=8-K&limit=10`"
),
)
@with_cache(namespace="filings:search", ttl=3600, key_params=["ticker", "form_type", "start_date", "end_date", "limit", "offset"])
async def search_filings(
ticker: str,
@ -129,7 +139,17 @@ async def search_filings(
)
@router.get("/documents/{accession_number}", response_model=FilingDocumentListResponse)
@router.get(
"/documents/{accession_number}",
response_model=FilingDocumentListResponse,
summary="List documents in a SEC filing",
description=(
"List all documents attached to a SEC filing by accession number.\n\n"
"Returns filename, document type, size, and SEC URL for each document. "
"Cached for 24 hours.\n\n"
"**Example**: `GET /filings/documents/0001193125-24-123456`"
),
)
@with_cache(namespace="filings:documents", ttl=86400, key_params=["accession_number"])
async def get_filing_documents(
accession_number: str,
@ -158,7 +178,18 @@ async def get_filing_documents(
)
@router.get("/exhibit/{accession_number}", response_model=ExhibitContentResponse)
@router.get(
"/exhibit/{accession_number}",
response_model=ExhibitContentResponse,
summary="Extract exhibit content from a filing",
description=(
"Extract the text content of a specific exhibit (e.g., press release **EX-99.1**) "
"from a SEC filing.\n\n"
"Returns the full text content along with content type, filename, and SEC URL. "
"404 responses are negative-cached for 1 hour. Cached for 24 hours.\n\n"
"**Example**: `GET /filings/exhibit/0001193125-24-123456?exhibit_type=EX-99.1`"
),
)
@with_cache(namespace="filings:exhibit", ttl=86400, key_params=["accession_number", "exhibit_type"])
async def get_exhibit_content(
accession_number: str,
@ -200,7 +231,21 @@ async def get_exhibit_content(
)
@router.post("/search/bulk", response_model=BulkFilingSearchResponse)
@router.post(
"/search/bulk",
response_model=BulkFilingSearchResponse,
summary="Bulk search SEC filings for multiple tickers",
description=(
"Search SEC filings for up to many tickers in a single request. "
"Auto-indexes from EDGAR for any ticker not yet in the database.\n\n"
"**Timeout**: 600 seconds. Each ticker is processed concurrently.\n\n"
"**Example body**:\n"
"```json\n"
'{"tickers": ["AAPL", "MSFT", "NVDA"], "form_type": "8-K", '
'"start_date": "2024-01-01", "limit_per_ticker": 5}\n'
"```"
),
)
@with_cache(namespace="filings:search:bulk", ttl=3600, key_params=["request"])
async def search_filings_bulk(
request: BulkFilingSearchRequest,
@ -288,7 +333,19 @@ async def search_filings_bulk(
)
@router.post("/exhibit/bulk", response_model=BulkExhibitResponse)
@router.post(
"/exhibit/bulk",
response_model=BulkExhibitResponse,
summary="Bulk fetch exhibit content",
description=(
"Fetch exhibit content for multiple accession numbers in one request. "
"Up to 4 concurrent fetches; max 300 second timeout.\n\n"
"**Example body**:\n"
"```json\n"
'{"items": [{"accession_number": "0001193125-24-123456", "exhibit_type": "EX-99.1"}]}\n'
"```"
),
)
async def get_exhibit_bulk(
request: BulkExhibitRequest,
db: AsyncSession = Depends(get_db),

@ -20,7 +20,7 @@ logger = logging.getLogger("app.api.v1.fred")
# Use /proxy/{endpoint} instead for all FRED API access
@router.get("/stats/usage")
@router.get("/stats/usage", summary="FRED API usage statistics and cache performance")
async def get_fred_usage_stats(
days: int = Query(7, ge=1, le=30, description="Number of days to include in stats"),
use_proxy_stats: bool = Query(True, description="Use enhanced proxy service statistics"),
@ -124,7 +124,7 @@ async def get_fred_usage_stats(
# Removed: /search endpoint - use /proxy/series/search instead
@router.get("/proxy/{endpoint:path}")
@router.get("/proxy/{endpoint:path}", summary="Universal FRED API proxy")
async def fred_proxy_endpoint(
endpoint: str,
db: AsyncSession = Depends(get_db),
@ -305,7 +305,7 @@ async def fred_proxy_endpoint(
)
@router.get("/endpoints")
@router.get("/endpoints", summary="List supported FRED API endpoints")
async def get_supported_fred_endpoints():
"""
Get list of supported FRED API endpoints

@ -75,7 +75,24 @@ class NewsSocialResponse(BaseModel):
summary: NewsSocialSummarySchema
@router.get("/{ticker}", response_model=NewsSocialResponse)
@router.get(
"/{ticker}",
response_model=NewsSocialResponse,
summary="Get news and social media for a ticker",
description="""
Fetch recent news articles and social media posts for a ticker from multiple sources.
**News sources**: Yahoo Finance, NewsAPI
**Social sources**: Reddit (r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis, r/ValueInvesting)
Both sources are fetched in parallel. Results are deduplicated and ranked by relevance.
Cached for **10 minutes**.
**Examples**:
- `GET /news/AAPL` last 7 days, up to 20 articles + 15 posts
- `GET /news/TSLA?days_back=14&max_articles=50&include_social=false` news-only, 2 weeks
""",
)
@with_cache(namespace="news:full", ttl=600, key_params=["ticker", "days_back", "max_articles", "max_social_posts", "include_social"])
async def get_ticker_news_and_social(
ticker: str,
@ -148,7 +165,19 @@ async def get_ticker_news_and_social(
)
@router.get("/{ticker}/news-only", response_model=NewsOnlyResponse)
@router.get(
"/{ticker}/news-only",
response_model=NewsOnlyResponse,
summary="Get news articles for a ticker (no social media)",
description="""
Faster endpoint that returns only news articles, skipping social media API calls.
**Sources**: Yahoo Finance, NewsAPI
Cached for **10 minutes**.
**Example**: `GET /news/NVDA/news-only?days_back=3&max_articles=30`
""",
)
@with_cache(namespace="news:news-only", ttl=600, key_params=["ticker", "days_back", "max_articles"])
async def get_ticker_news_only(
ticker: str,
@ -217,7 +246,20 @@ async def get_ticker_news_only(
)
@router.get("/{ticker}/social-only", response_model=SocialOnlyResponse)
@router.get(
"/{ticker}/social-only",
response_model=SocialOnlyResponse,
summary="Get social media posts for a ticker",
description="""
Returns only Reddit posts for a ticker, skipping news API calls.
**Subreddits**: r/stocks, r/investing, r/wallstreetbets, r/SecurityAnalysis,
r/StockMarket, r/ValueInvesting, r/financialindependence
Cached for **10 minutes**.
**Example**: `GET /news/GME/social-only?days_back=3&max_social_posts=30`
""",
)
@with_cache(namespace="news:social-only", ttl=600, key_params=["ticker", "days_back", "max_social_posts"])
async def get_ticker_social_only(
ticker: str,

@ -14,7 +14,7 @@ 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 sqlalchemy import select, desc, and_, func
from app.core.config import settings
from app.core.database import get_db
@ -226,9 +226,26 @@ async def get_top_movers(
raise HTTPException(status_code=503, detail="Overlay feature is disabled")
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
# Get the latest record per symbol, then rank by score
latest_per_symbol = (
select(
OverlayFeatureRecord.symbol,
func.max(OverlayFeatureRecord.as_of_ts).label("max_ts"),
)
.where(OverlayFeatureRecord.as_of_ts >= cutoff)
.group_by(OverlayFeatureRecord.symbol)
.subquery()
)
result = await db.execute(
select(OverlayFeatureRecord)
.where(OverlayFeatureRecord.as_of_ts >= cutoff)
.join(
latest_per_symbol,
and_(
OverlayFeatureRecord.symbol == latest_per_symbol.c.symbol,
OverlayFeatureRecord.as_of_ts == latest_per_symbol.c.max_ts,
),
)
.order_by(desc(OverlayFeatureRecord.overlay_score))
.limit(limit)
)
@ -269,9 +286,9 @@ async def admin_health(db: AsyncSession = Depends(get_db)):
)
last_job = result.scalars().first()
# Per-source status
# Per-job-type status (collect_all runs all sources; feature_build computes scores)
sources = []
for source_name in ["yahoo_rss", "wikimedia", "youtube", "google_trends", "collect_all", "feature_build"]:
for source_name in ["collect_all", "feature_build"]:
result_s = await db.execute(
select(OverlayJobLog)
.where(OverlayJobLog.job_type == source_name)
@ -312,11 +329,22 @@ async def trigger_pipeline():
asyncio.create_task(_run())
return TriggerPipelineResponse(
status="triggered",
message="Overlay pipeline started in background",
message="Overlay pipeline started in background (seeds topic maps, collects data, builds features)",
job_ids=[],
)
@router.post(
"/admin/seed-topics",
summary="Seed ThemeTopicMap with default topic mappings",
tags=["overlay-admin"],
)
async def seed_topics(db: AsyncSession = Depends(get_db)):
"""Create default ThemeTopicMap entries for all TOP_50_SYMBOLS (safe to re-run; skips existing)."""
inserted = await _pipeline.seed_topic_maps(db)
return {"status": "ok", "inserted": inserted, "message": f"Seeded {inserted} new topic mappings"}
@router.get(
"/admin/job-log",
response_model=JobLogResponse,
@ -470,7 +498,7 @@ async def get_youtube(
)
for e in sym_events
]
weighted_views = sum(e.view_count * e.channel_weight for e in events_24h)
weighted_views = sum((e.view_count or 0) * (e.channel_weight or 0.5) for e in events_24h)
return YouTubeResponse(
symbol=symbol,

@ -16,7 +16,7 @@ router = APIRouter()
logger = logging.getLogger("app.api.v1.screener")
@router.get("/stocks")
@router.get("/stocks", summary="Screen stocks by financial criteria")
@with_cache(
namespace="screener:stocks",
ttl=300,
@ -128,7 +128,7 @@ async def screen_stocks(
)
@router.get("/fields")
@router.get("/fields", summary="Available screener filter options and valid values")
async def get_screener_fields():
"""
Return metadata about available screener filter options.

@ -85,7 +85,7 @@ async def get_index_constituents(
)
@router.get("/most-active")
@router.get("/most-active", summary="Most actively traded stocks by volume")
@with_cache(namespace="stocks:most-active", ttl=3600, key_params=["limit"])
async def get_most_active_stocks(
response: Response,
@ -184,7 +184,7 @@ async def get_most_active_stocks(
)
@router.get("/52-week-gainers")
@router.get("/52-week-gainers", summary="Top 52-week gaining stocks")
async def get_52week_gainers(
limit: Optional[int] = Query(None, ge=1, le=1000, description="Maximum number of stocks to return (1-1000). If not specified, returns first 600 stocks (3 pages) for performance."),
max_pages: Optional[int] = Query(3, ge=1, le=10, description="Maximum pages to fetch (1-10). Each page has ~200 stocks. Higher values may cause rate limiting.")
@ -280,7 +280,7 @@ async def get_52week_gainers(
)
@router.get("/trending")
@router.get("/trending", summary="Trending stocks combining most active and 52-week gainers")
async def get_trending_stocks(
n: Optional[int] = Query(500, ge=1, description="Total number of trending stocks to return after combining most active + gainers (default: 500)"),
most_active_limit: Optional[int] = Query(None, ge=1, description="Number of most active stocks to include. If not specified, returns all available stocks (~170)."),

@ -4,9 +4,9 @@ Main FastAPI application
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.responses import RedirectResponse
from app.core.config import settings
from app.api.v1.api import api_router
@ -62,120 +62,10 @@ app.add_middleware(
# Include API router
app.include_router(api_router, prefix=settings.API_PREFIX)
# Root documentation endpoint — auto-generated from OpenAPI schema
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
async def root_documentation(request: Request):
try:
schema = request.app.openapi()
paths = schema.get("paths", {})
api_prefix = settings.API_PREFIX
# Collect all tags defined in schema (preserves order)
tag_order = [t["name"] for t in schema.get("tags", [])]
# Group routes by first tag
from collections import defaultdict
tag_routes: dict = defaultdict(list)
untagged: list = []
for path, methods in paths.items():
for method, op in methods.items():
if method.upper() not in ("GET", "POST", "PUT", "DELETE", "PATCH"):
continue
tags = op.get("tags", [])
entry = {
"method": method.upper(),
"path": path,
"summary": op.get("summary", ""),
"deprecated": op.get("deprecated", False),
}
if tags:
tag_routes[tags[0]].append(entry)
else:
untagged.append(entry)
# Build tag sections — respect schema tag order, then alphabetical remainder
all_tags = tag_order + sorted(t for t in tag_routes if t not in tag_order)
if untagged:
all_tags.append("other")
tag_routes["other"] = untagged
method_colors = {
"GET": "#61affe",
"POST": "#49cc90",
"PUT": "#fca130",
"DELETE": "#f93e3e",
"PATCH": "#50e3c2",
}
sections_html = ""
for tag in all_tags:
routes = tag_routes.get(tag, [])
if not routes:
continue
rows = ""
for r in routes:
color = method_colors.get(r["method"], "#999")
deprecated = " style='opacity:0.5;text-decoration:line-through'" if r["deprecated"] else ""
display_path = r["path"].removeprefix(api_prefix)
rows += (
f"<tr{deprecated}>"
f"<td><span class='method' style='background:{color}'>{r['method']}</span></td>"
f"<td><code>{display_path}</code></td>"
f"<td>{r['summary']}</td>"
f"</tr>"
)
sections_html += f"<h3>{tag}</h3><table>{rows}</table>"
title = schema.get("info", {}).get("title", "API")
version = schema.get("info", {}).get("version", "")
total = sum(len(v) for v in tag_routes.values())
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6; max-width: 1100px; margin: 0 auto; padding: 24px; color: #333; }}
h1 {{ color: #1a1a2e; border-bottom: 3px solid #007acc; padding-bottom: 10px; }}
h3 {{ color: #2c3e50; margin-top: 2em; text-transform: capitalize; border-left: 4px solid #007acc; padding-left: 10px; }}
.nav {{ margin: 20px 0; display: flex; gap: 10px; flex-wrap: wrap; }}
.nav a {{ padding: 8px 18px; background: #007acc; color: white; text-decoration: none;
border-radius: 6px; font-size: 0.9em; }}
.nav a:hover {{ background: #005fa3; }}
.meta {{ color: #666; font-size: 0.9em; margin-bottom: 1.5em; }}
table {{ width: 100%; border-collapse: collapse; margin-bottom: 1em; font-size: 0.9em; }}
tr:hover {{ background: #f8f9fa; }}
td {{ padding: 7px 10px; border-bottom: 1px solid #eee; vertical-align: top; }}
td:first-child {{ width: 72px; }}
td:nth-child(2) {{ width: 38%; }}
code {{ background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.85em; word-break: break-all; }}
.method {{ display: inline-block; color: white; font-weight: bold; font-size: 0.75em;
padding: 3px 8px; border-radius: 4px; letter-spacing: 0.5px; }}
footer {{ margin-top: 3em; padding-top: 1.5em; border-top: 1px solid #eee;
text-align: center; color: #888; font-size: 0.85em; }}
</style>
</head>
<body>
<h1>🔮 {title}</h1>
<div class="nav">
<a href="{api_prefix}/docs">📋 Swagger UI</a>
<a href="{api_prefix}/redoc">📖 ReDoc</a>
<a href="{api_prefix}/health">💚 Health</a>
<a href="{api_prefix}/openapi.json"> OpenAPI JSON</a>
</div>
<p class="meta">v{version} &nbsp;·&nbsp; Base URL: <code>{api_prefix}</code> &nbsp;·&nbsp; {total} endpoints</p>
{sections_html}
<footer>Auto-generated from OpenAPI schema · <a href="{api_prefix}/docs">Full interactive docs</a></footer>
</body>
</html>"""
return HTMLResponse(content=html)
except Exception:
return RedirectResponse(url=f"{settings.API_PREFIX}/docs")
# Root — redirect to Swagger UI
@app.get("/", include_in_schema=False)
async def root_redirect():
return RedirectResponse(url=f"{settings.API_PREFIX}/docs")
# Additional metadata for OpenAPI
app.openapi_tags = [
@ -234,6 +124,30 @@ app.openapi_tags = [
{
"name": "stocks",
"description": "Stock market data — most active, 52-week gainers, trending, and index constituents (S&P 500 / Nasdaq 100)"
},
{
"name": "attention",
"description": "Attention signals — Wikipedia pageview spikes and GDELT news article counts for event-centric backtesting"
},
{
"name": "attention-admin",
"description": "Attention administrative endpoints — entity resolution, Wikipedia and GDELT data collection"
},
{
"name": "database",
"description": "Database inspection — record counts, date ranges, raw data browsing, and ETF snapshot history"
},
{
"name": "fred",
"description": "FRED (Federal Reserve Economic Data) — macroeconomic series via FRED API proxy"
},
{
"name": "error-logs",
"description": "Error log management — browse and clear server-side error records"
},
{
"name": "request-logs",
"description": "Request log management — browse API request history and latency records"
}
]

@ -4,10 +4,22 @@ Pydantic v2 schemas for the Attention subsystem API.
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class EntityInfo(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"ticker": "AAPL",
"canonical_name": "Apple",
"wiki_title": "Apple Inc.",
"gdelt_query": '"Apple" OR "Apple Inc."',
"aliases": ["Apple Inc."],
"resolver_confidence": 0.92,
"is_manual_override": False,
}
})
ticker: str
canonical_name: str = Field(description="Normalized company name with legal suffixes stripped (e.g. 'Apple')")
wiki_title: Optional[str] = Field(default=None, description="Matched Wikipedia article title; null if unresolved")
@ -18,6 +30,15 @@ class EntityInfo(BaseModel):
class WikiFeatures(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"views": 45230,
"baseline_10d": 12400.0,
"spike_10d": 3.65,
"zscore_20d": 4.21,
}
})
views: Optional[int] = Field(default=None, description="Wikipedia pageviews on the event date")
baseline_10d: Optional[float] = Field(default=None, description="Median pageviews over the prior 10 days")
spike_10d: Optional[float] = Field(default=None, description="views / baseline_10d; >1 means above-average attention")
@ -25,6 +46,16 @@ class WikiFeatures(BaseModel):
class NewsFeatures(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"article_count_1d": 18,
"article_count_3d": 52,
"unique_domains_3d": 34,
"us_article_count_3d": 41,
"gdelt_status": "collected",
}
})
article_count_1d: int = Field(default=0, description="GDELT articles published on the event date")
article_count_3d: int = Field(default=0, description="GDELT articles in the event_date ± 1 day window")
unique_domains_3d: int = Field(default=0, description="Distinct publisher domains in the 3-day window")
@ -41,6 +72,39 @@ class NewsFeatures(BaseModel):
class EventAttentionResponse(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"ticker": "AAPL",
"event_date": "2024-02-01",
"entity": {
"ticker": "AAPL",
"canonical_name": "Apple",
"wiki_title": "Apple Inc.",
"gdelt_query": '"Apple" OR "Apple Inc."',
"aliases": ["Apple Inc."],
"resolver_confidence": 0.92,
"is_manual_override": False,
},
"wiki": {
"views": 45230,
"baseline_10d": 12400.0,
"spike_10d": 3.65,
"zscore_20d": 4.21,
},
"news": {
"article_count_1d": 18,
"article_count_3d": 52,
"unique_domains_3d": 34,
"us_article_count_3d": 41,
"gdelt_status": "collected",
},
"metadata": {
"wiki_title": "Apple Inc.",
"resolver_confidence": 0.92,
},
}
})
ticker: str
event_date: date
entity: EntityInfo
@ -50,6 +114,23 @@ class EventAttentionResponse(BaseModel):
class EntityResolveResponse(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"ticker": "AAPL",
"entity": {
"ticker": "AAPL",
"canonical_name": "Apple",
"wiki_title": "Apple Inc.",
"gdelt_query": '"Apple" OR "Apple Inc."',
"aliases": ["Apple Inc."],
"resolver_confidence": 0.92,
"is_manual_override": False,
},
"status": "resolved",
"message": "Entity resolved: wiki_title='Apple Inc.' confidence=0.92",
}
})
ticker: str
entity: EntityInfo
status: str # "resolved", "already_exists", "failed", "manual_override_skipped"
@ -57,6 +138,16 @@ class EntityResolveResponse(BaseModel):
class CollectionStatusResponse(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"ticker": "AAPL",
"source": "wiki",
"records_collected": 22,
"date_range": {"event_date": "2024-02-01"},
"status": "success",
}
})
ticker: str
source: str # "wiki" or "gdelt"
records_collected: int

@ -4,10 +4,20 @@ Pydantic schemas for SEC filing endpoints.
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class FilingDocumentInfo(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"type": "EX-99.1",
"description": "Press Release",
"filename": "ex991pressrelease.htm",
"url": "https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/ex991pressrelease.htm",
"size": "42 KB",
}
})
type: Optional[str] = None
description: Optional[str] = None
filename: Optional[str] = None
@ -16,6 +26,18 @@ class FilingDocumentInfo(BaseModel):
class FilingSummary(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"accession_number": "0000320193-24-000006",
"form_type": "8-K",
"filing_date": "2024-02-01",
"accepted_at": "2024-02-01T21:00:05+00:00",
"primary_document": "a8-k20240201.htm",
"filing_description": "Results of Operations and Financial Condition",
"documents_count": 4,
}
})
accession_number: str
form_type: str
filing_date: str
@ -26,6 +48,25 @@ class FilingSummary(BaseModel):
class FilingSearchResponse(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"ticker": "AAPL",
"filings": [
{
"accession_number": "0000320193-24-000006",
"form_type": "8-K",
"filing_date": "2024-02-01",
"accepted_at": "2024-02-01T21:00:05+00:00",
"primary_document": "a8-k20240201.htm",
"filing_description": "Results of Operations and Financial Condition",
"documents_count": 4,
}
],
"total_count": 42,
"metadata": {"limit": 20, "offset": 0, "form_types": ["8-K"]},
}
})
ticker: str
filings: List[FilingSummary]
total_count: int
@ -33,12 +74,46 @@ class FilingSearchResponse(BaseModel):
class FilingDocumentListResponse(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"accession_number": "0000320193-24-000006",
"documents": [
{
"type": "8-K",
"description": "8-K",
"filename": "a8-k20240201.htm",
"url": "https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/a8-k20240201.htm",
"size": "8 KB",
},
{
"type": "EX-99.1",
"description": "Press Release",
"filename": "ex991pressrelease.htm",
"url": "https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/ex991pressrelease.htm",
"size": "42 KB",
},
],
"metadata": {"total_documents": 4},
}
})
accession_number: str
documents: List[FilingDocumentInfo]
metadata: dict = Field(default_factory=dict)
class ExhibitContentResponse(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"accession_number": "0000320193-24-000006",
"exhibit_type": "EX-99.1",
"content": "Apple Reports First Quarter Results...\nCUPERTINO, California — February 1, 2024 — Apple Inc. today announced financial results for its fiscal 2024 first quarter...",
"content_type": "text/html",
"filename": "ex991pressrelease.htm",
"url": "https://www.sec.gov/Archives/edgar/data/320193/000032019324000006/ex991pressrelease.htm",
}
})
accession_number: str
exhibit_type: str
content: str
@ -50,6 +125,16 @@ class ExhibitContentResponse(BaseModel):
# ── Bulk filing search ──────────────────────────────────────────────────────
class BulkFilingSearchRequest(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"tickers": ["AAPL", "MSFT", "NVDA"],
"form_type": "8-K",
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"limit_per_ticker": 5,
}
})
tickers: List[str] = Field(..., min_length=1, max_length=200)
form_type: Optional[str] = None
start_date: Optional[str] = None
@ -77,6 +162,15 @@ class BulkFilingSearchResponse(BaseModel):
# ── Bulk exhibit ────────────────────────────────────────────────────────────
class BulkExhibitRequest(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"items": [
{"accession_number": "0000320193-24-000006", "exhibit_type": "EX-99.1"},
{"accession_number": "0001045810-24-000010", "exhibit_type": "EX-99.1"},
]
}
})
items: List[Dict[str, str]] = Field(..., min_length=1, max_length=50)
# Each item: {"accession_number": "...", "exhibit_type": "EX-99.1"}

@ -14,7 +14,9 @@ from app.models.overlay_raw_event import (
OverlayHeadlineEvent,
OverlayVideoEvent,
OverlayWikiPageview,
OverlayTrendObservation,
)
from app.models.overlay_registry import ThemeTopicMap
from app.services.overlay.finra_overlay_loader import FinraOverlayLoader
from app.core.overlay_config import ZSCORE_WINDOW_DAYS, WINSOR_LOWER, WINSOR_UPPER
@ -139,13 +141,13 @@ class FeatureBuilder:
sym_rows_24h = [r for r in sym_rows if r.published_at >= cutoff_24h]
mentions_24h = len(sym_rows_24h)
weighted_views_24h = sum(r.view_count * r.channel_weight for r in sym_rows_24h)
weighted_views_24h = sum((r.view_count or 0) * (r.channel_weight or 0.5) for r in sym_rows_24h)
# Daily weighted views for z-score
daily_weighted: Dict[str, float] = {}
for row in sym_rows:
key = _day_key(row.published_at)
daily_weighted[key] = daily_weighted.get(key, 0.0) + row.view_count * row.channel_weight
daily_weighted[key] = daily_weighted.get(key, 0.0) + (row.view_count or 0) * (row.channel_weight or 0.5)
hist_values = list(daily_weighted.values())
youtube_influence_z = compute_zscore(weighted_views_24h, hist_values) if hist_values else None
@ -202,6 +204,53 @@ class FeatureBuilder:
"wiki_attention_z": wiki_attention_z,
}
# ------------------------------------------------------------------
# Google Trends features
# ------------------------------------------------------------------
async def build_trends_features(
self, db: AsyncSession, symbol: str, as_of: datetime
) -> Dict:
window_cutoff = as_of - timedelta(days=ZSCORE_WINDOW_DAYS)
cutoff_1d = as_of - timedelta(days=1)
# Find topic IDs mapped to this symbol
topics_result = await db.execute(
select(ThemeTopicMap).where(ThemeTopicMap.active == True)
)
topics = topics_result.scalars().all()
topic_ids = [t.topic_id for t in topics if symbol in (t.mapped_symbols or [])]
if not topic_ids:
return {"theme_heat_z": None}
result = await db.execute(
select(
OverlayTrendObservation.interest_value,
OverlayTrendObservation.observed_at,
).where(
and_(
OverlayTrendObservation.topic_id.in_(topic_ids),
OverlayTrendObservation.observed_at >= window_cutoff,
)
).order_by(OverlayTrendObservation.observed_at)
)
rows = result.fetchall()
if not rows:
return {"theme_heat_z": None}
recent = [r for r in rows if r.observed_at >= cutoff_1d]
current_value = float(sum(r.interest_value for r in recent) / len(recent)) if recent else None
if current_value is None:
return {"theme_heat_z": None}
hist_values = [float(r.interest_value) for r in rows]
theme_heat_z = compute_zscore(current_value, hist_values) if len(hist_values) >= 2 else None
return {"theme_heat_z": theme_heat_z}
# ------------------------------------------------------------------
# FINRA crowding features
# ------------------------------------------------------------------
@ -223,14 +272,14 @@ class FeatureBuilder:
headline = await self.build_headline_features(db, symbol, as_of)
youtube = await self.build_youtube_features(db, symbol, as_of)
wiki = await self.build_wiki_features(db, symbol, as_of)
trends = await self.build_trends_features(db, symbol, as_of)
crowding = await self.build_crowding_features(db, symbol)
return {
**headline,
**youtube,
**wiki,
**trends,
**crowding,
"as_of_ts": as_of,
# theme_heat_z comes from Google Trends; left None here (no Trends data yet)
"theme_heat_z": None,
}

@ -13,6 +13,7 @@ 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.models.overlay_registry import ThemeTopicMap
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
@ -47,6 +48,30 @@ class OverlayPipeline:
records: int = 0,
error: Optional[str] = None,
) -> None:
if status != "running":
# Try to update existing "running" entry rather than creating a duplicate
existing_result = await db.execute(
select(OverlayJobLog)
.where(
OverlayJobLog.job_type == job_type,
OverlayJobLog.started_at == started_at,
OverlayJobLog.status == "running",
)
.limit(1)
)
existing = existing_result.scalars().first()
if existing:
existing.status = status
existing.completed_at = datetime.now(timezone.utc)
existing.records_processed = records
existing.error_message = error
try:
await db.commit()
except Exception as e:
logger.warning(f"Failed to update job log: {e}")
await db.rollback()
return
log = OverlayJobLog(
job_type=job_type,
status=status,
@ -67,26 +92,36 @@ class OverlayPipeline:
# ------------------------------------------------------------------
async def collect_all(self, db: AsyncSession) -> Dict:
"""Run all data collectors concurrently (source failures are isolated)."""
"""Run all data collectors sequentially (source failures are isolated).
NOTE: Sequential (not concurrent) because SQLAlchemy async sessions do not
allow concurrent operations on the same connection.
"""
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,
counts: Dict[str, int] = {
"yahoo_rss": 0,
"wikimedia": 0,
"youtube": 0,
"google_trends": 0,
}
errors: List[str] = []
adapters = [
("yahoo_rss", self.rss.collect(db, symbols=TOP_50_SYMBOLS)),
("wikimedia", self.wiki.collect(db)),
("youtube", self.youtube.collect(db)),
("google_trends", self.trends.collect(db)),
]
for name, coro in adapters:
try:
result = await coro
counts[name] = result if isinstance(result, int) else 0
except Exception as e:
logger.error(f"Collect error for {name}: {e}")
errors.append(f"{name}: {e}")
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"
@ -212,12 +247,45 @@ class OverlayPipeline:
# Return potentially-stale record rather than None
return record
# ------------------------------------------------------------------
# Topic map seeding
# ------------------------------------------------------------------
async def seed_topic_maps(self, db: AsyncSession) -> int:
"""Seed ThemeTopicMap with default entries for TOP_50_SYMBOLS if empty.
Each symbol gets a topic with label "{TICKER} stock" used as Google Trends keyword.
Safe to call repeatedly skips symbols that already have a mapping.
Returns number of new entries created.
"""
inserted = 0
for symbol in TOP_50_SYMBOLS:
existing = await db.execute(
select(ThemeTopicMap).where(ThemeTopicMap.topic_id == symbol)
)
if existing.scalars().first():
continue
entry = ThemeTopicMap(
topic_id=symbol,
topic_label=f"{symbol} stock",
mapped_symbols=[symbol],
active=True,
)
db.add(entry)
inserted += 1
if inserted:
await db.commit()
logger.info(f"Seeded {inserted} ThemeTopicMap entries")
return inserted
# ------------------------------------------------------------------
# Full pipeline
# ------------------------------------------------------------------
async def run_full_pipeline(self, db: AsyncSession) -> Dict:
"""Run collect → build feature for all TOP_50 symbols."""
"""Run seed → collect → build feature for all TOP_50 symbols."""
await self.seed_topic_maps(db)
collect_counts = await self.collect_all(db)
built = await self.build_features_batch(db)
return {

@ -50,8 +50,15 @@ class OverlayScorer:
"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()}
# Source presence: based on actual raw data, not z-score availability.
# Z-scores require 2+ days of history; a source is "present" if it has any data at all.
source_presence_mask = {
"yahoo": (features.get("headline_count_24h") or 0) > 0,
"youtube": (features.get("youtube_mentions_24h") or 0) > 0,
"wikimedia": features.get("wiki_views_1d") is not None,
"google_trends": normalized.get("google_trends") is not None,
"finra": features.get("short_volume_ratio") is not None,
}
# Weighted average across present sources
total_weight = 0.0

@ -50,6 +50,7 @@ services:
- SEC_EMAIL=example@example.com
- ALPACA_API_KEY=${ALPACA_API_KEY:-}
- ALPACA_SECRET_KEY=${ALPACA_SECRET_KEY:-}
- GOOGLE_TRENDS_ENABLED=true
ports:
- "18001:18000" # External:Internal port mapping
depends_on:

Loading…
Cancel
Save