You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
526 lines
21 KiB
Python
526 lines
21 KiB
Python
"""
|
|
Attention API endpoints — event-centric attention data for backtesting.
|
|
|
|
Static routes are registered before parameterized routes to avoid path shadowing.
|
|
|
|
Main endpoint:
|
|
GET /api/v1/attention/event/{ticker}?event_date=YYYY-MM-DD
|
|
|
|
Admin endpoints:
|
|
GET /api/v1/attention/entity/{ticker}
|
|
POST /api/v1/attention/admin/resolve/{ticker}
|
|
POST /api/v1/attention/admin/collect/wiki/{ticker}?event_date=...
|
|
POST /api/v1/attention/admin/collect/gdelt/{ticker}?event_date=...
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db
|
|
from app.utils.cache import build_cache_key, get_cached_response, set_cached_response, with_cache
|
|
from app.models.attention import AttentionFeaturesDaily, CompanyEntityMap, GdeltArticleRaw
|
|
from app.services.attention.gdelt_collector import GDELT_EARLIEST_DATE
|
|
from app.schemas.attention import (
|
|
CollectionStatusResponse,
|
|
EntityInfo,
|
|
EntityOverrideResponse,
|
|
EntityResolveResponse,
|
|
EventAttentionResponse,
|
|
NewsFeatures,
|
|
WikiFeatures,
|
|
)
|
|
from app.services.attention.entity_resolver import resolve_entity
|
|
from app.services.attention.entity_resolver import _build_gdelt_query, _normalize_name
|
|
from app.services.attention.feature_materializer import materialize_features
|
|
from app.services.attention.gdelt_collector import collect_gdelt_articles
|
|
from app.services.attention.wiki_collector import collect_wiki_pageviews
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
# Fast-fail gate for /event/{ticker} — blocks bulk scan floods that triggered
|
|
# wiki 429 bursts and contributed to container memory pressure.
|
|
_EVENT_SEMAPHORE = asyncio.Semaphore(8)
|
|
_EVENT_SEMAPHORE_WAIT = 10
|
|
|
|
# Re-resolve a present-but-unresolved entity at most once per this window.
|
|
# Bounds the Wikipedia query rate when fithia2 re-runs its bulk upcoming-
|
|
# earnings scan (at semaphore=8) against still-NULL rows — prevents the 429
|
|
# storm that originally left 1423/1697 mappings broken.
|
|
_RESOLVE_RETRY_WINDOW = timedelta(hours=1)
|
|
|
|
|
|
def _resolve_retry_due(updated_at) -> bool:
|
|
"""True if enough time has elapsed since the last resolution attempt to
|
|
retry a still-unresolved entity. A missing timestamp counts as due."""
|
|
if updated_at is None:
|
|
return True
|
|
if updated_at.tzinfo is None:
|
|
updated_at = updated_at.replace(tzinfo=timezone.utc)
|
|
return datetime.now(timezone.utc) - updated_at >= _RESOLVE_RETRY_WINDOW
|
|
|
|
|
|
def _entity_to_info(entity: CompanyEntityMap) -> EntityInfo:
|
|
return EntityInfo(
|
|
ticker=entity.ticker,
|
|
canonical_name=entity.canonical_name,
|
|
wiki_title=entity.wiki_title,
|
|
gdelt_query=entity.gdelt_query,
|
|
aliases=entity.aliases_json or [],
|
|
resolver_confidence=entity.resolver_confidence or 0.0,
|
|
is_manual_override=entity.is_manual_override or False,
|
|
)
|
|
|
|
|
|
def _features_to_response(
|
|
ticker: str,
|
|
event_date: date,
|
|
entity: CompanyEntityMap,
|
|
features: AttentionFeaturesDaily,
|
|
gdelt_status: str = "not_collected",
|
|
) -> EventAttentionResponse:
|
|
# Compute baseline_10d from spike (inverse: baseline = views / spike)
|
|
baseline_10d = None
|
|
if features.wiki_spike_10d and features.wiki_views and features.wiki_spike_10d > 0:
|
|
baseline_10d = features.wiki_views / features.wiki_spike_10d
|
|
|
|
return EventAttentionResponse(
|
|
ticker=ticker,
|
|
event_date=event_date,
|
|
entity=_entity_to_info(entity),
|
|
wiki=WikiFeatures(
|
|
views=features.wiki_views,
|
|
baseline_10d=round(baseline_10d, 2) if baseline_10d else None,
|
|
spike_10d=round(features.wiki_spike_10d, 4) if features.wiki_spike_10d is not None else None,
|
|
zscore_20d=round(features.wiki_zscore_20d, 4) if features.wiki_zscore_20d is not None else None,
|
|
),
|
|
news=NewsFeatures(
|
|
article_count_1d=features.gdelt_article_count_1d or 0,
|
|
article_count_3d=features.gdelt_article_count_3d or 0,
|
|
unique_domains_3d=features.gdelt_unique_domains_3d or 0,
|
|
us_article_count_3d=features.gdelt_us_article_count_3d or 0,
|
|
gdelt_status=gdelt_status,
|
|
),
|
|
metadata={
|
|
"wiki_title": entity.wiki_title,
|
|
"resolver_confidence": entity.resolver_confidence,
|
|
},
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# Admin / utility routes (static — must come before parameterized routes)
|
|
# ===========================================================================
|
|
|
|
|
|
@router.post(
|
|
"/admin/resolve/{ticker}",
|
|
response_model=EntityResolveResponse,
|
|
summary="Resolve ticker → canonical entity",
|
|
description=(
|
|
"Maps a ticker symbol to a canonical company entity by looking up the company name, "
|
|
"normalizing it, and validating against Wikipedia. Stores the result (canonical name, "
|
|
"wiki_title, gdelt_query) in `company_entity_map`.\n\n"
|
|
"Skips re-resolution if `is_manual_override` is set. "
|
|
"If the company name in the DB is a placeholder (e.g. 'AMZN Corporation'), "
|
|
"falls back to SEC company_tickers.json to fetch the real name and updates the DB."
|
|
),
|
|
tags=["attention-admin"],
|
|
)
|
|
async def admin_resolve_entity(
|
|
ticker: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> EntityResolveResponse:
|
|
ticker = ticker.upper()
|
|
|
|
# Check for existing manual override
|
|
existing_result = await db.execute(
|
|
select(CompanyEntityMap).where(CompanyEntityMap.ticker == ticker)
|
|
)
|
|
existing = existing_result.scalars().first()
|
|
if existing and existing.is_manual_override:
|
|
return EntityResolveResponse(
|
|
ticker=ticker,
|
|
entity=_entity_to_info(existing),
|
|
status="manual_override_skipped",
|
|
message="Manual override is active — resolution skipped.",
|
|
)
|
|
|
|
try:
|
|
entity = await resolve_entity(db, ticker)
|
|
status = "resolved"
|
|
message = f"Entity resolved: wiki_title={entity.wiki_title!r} confidence={entity.resolver_confidence:.2f}"
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
except Exception as exc:
|
|
logger.error("Entity resolution failed for %s: %s", ticker, exc)
|
|
raise HTTPException(status_code=500, detail=f"Resolution failed: {exc}")
|
|
|
|
return EntityResolveResponse(
|
|
ticker=ticker,
|
|
entity=_entity_to_info(entity),
|
|
status=status,
|
|
message=message,
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/admin/collect/wiki/{ticker}",
|
|
response_model=CollectionStatusResponse,
|
|
summary="Collect Wikipedia pageviews for an event date",
|
|
description=(
|
|
"Fetches daily Wikipedia pageview counts for the ticker's canonical wiki_title, "
|
|
"covering `event_date` and enough lookback days (≥20) to compute spike and z-score. "
|
|
"Safe to call on-demand — Wikipedia API has no meaningful rate limit for this use.\n\n"
|
|
"Requires entity resolution to have been run first (`wiki_title` must be set)."
|
|
),
|
|
tags=["attention-admin"],
|
|
)
|
|
async def admin_collect_wiki(
|
|
ticker: str,
|
|
event_date: date = Query(..., description="Event date in YYYY-MM-DD format"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> CollectionStatusResponse:
|
|
ticker = ticker.upper()
|
|
try:
|
|
count = await collect_wiki_pageviews(db, ticker, event_date)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
except Exception as exc:
|
|
logger.error("Wiki collection failed for %s: %s", ticker, exc)
|
|
raise HTTPException(status_code=500, detail=f"Wiki collection failed: {exc}")
|
|
|
|
return CollectionStatusResponse(
|
|
ticker=ticker,
|
|
source="wiki",
|
|
records_collected=count,
|
|
date_range={"event_date": str(event_date)},
|
|
status="success",
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/admin/collect/gdelt/{ticker}",
|
|
response_model=CollectionStatusResponse,
|
|
summary="Collect GDELT news articles for an event date",
|
|
description=(
|
|
"Fetches news articles from GDELT V2 DOC API for the window `event_date ± 1 day`.\n\n"
|
|
"**Coverage**: 2017-01-01 onwards. Requests for earlier dates return 0 immediately.\n\n"
|
|
"**Rate limit**: GDELT enforces a global per-IP quota. This endpoint is protected by "
|
|
"a process-wide lock (10s minimum interval) and retries with exponential backoff "
|
|
"(30s → 60s → 120s) on 429 responses.\n\n"
|
|
"⚠️ **Call this endpoint from a scheduler only** — never trigger it in response to "
|
|
"user requests. Concurrent or rapid calls will exhaust the IP quota and cause "
|
|
"temporary bans. The main `/event/{ticker}` endpoint intentionally does NOT collect "
|
|
"GDELT on-demand for this reason."
|
|
),
|
|
tags=["attention-admin"],
|
|
)
|
|
async def admin_collect_gdelt(
|
|
ticker: str,
|
|
event_date: date = Query(..., description="Event date in YYYY-MM-DD format"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> CollectionStatusResponse:
|
|
ticker = ticker.upper()
|
|
try:
|
|
count = await collect_gdelt_articles(db, ticker, event_date)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=422, detail=str(exc))
|
|
except Exception as exc:
|
|
logger.error("GDELT collection failed for %s: %s", ticker, exc)
|
|
raise HTTPException(status_code=500, detail=f"GDELT collection failed: {exc}")
|
|
|
|
return CollectionStatusResponse(
|
|
ticker=ticker,
|
|
source="gdelt",
|
|
records_collected=count,
|
|
date_range={"event_date": str(event_date)},
|
|
status="success",
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/admin/entity/{ticker}/override",
|
|
response_model=EntityOverrideResponse,
|
|
summary="Manually set wiki_title for a ticker (override automatic resolver)",
|
|
description=(
|
|
"Directly sets the Wikipedia article title for a ticker, bypassing the automatic resolver. "
|
|
"Sets `is_manual_override=True` so the resolver will never overwrite this mapping.\n\n"
|
|
"Use when the resolver persistently picks the wrong article "
|
|
"(e.g. a lawsuit page or an acquired subsidiary instead of the company itself).\n\n"
|
|
"**Example**: `POST /admin/entity/CSCO/override?wiki_title=Cisco%20Systems`"
|
|
),
|
|
tags=["attention-admin"],
|
|
)
|
|
async def admin_override_entity(
|
|
ticker: str,
|
|
wiki_title: str = Query(..., description="Exact Wikipedia article title to use"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> EntityOverrideResponse:
|
|
ticker = ticker.upper()
|
|
|
|
entity_result = await db.execute(
|
|
select(CompanyEntityMap).where(CompanyEntityMap.ticker == ticker)
|
|
)
|
|
entity = entity_result.scalars().first()
|
|
if not entity:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"No entity mapping found for {ticker}. POST /admin/resolve/{ticker} first.",
|
|
)
|
|
|
|
canonical_name = entity.canonical_name
|
|
aliases = entity.aliases_json or []
|
|
gdelt_query = _build_gdelt_query(canonical_name, aliases)
|
|
|
|
from sqlalchemy import update as sa_update
|
|
await db.execute(
|
|
sa_update(CompanyEntityMap)
|
|
.where(CompanyEntityMap.ticker == ticker)
|
|
.values(
|
|
wiki_title=wiki_title,
|
|
gdelt_query=gdelt_query,
|
|
is_manual_override=True,
|
|
resolver_confidence=1.0,
|
|
)
|
|
)
|
|
await db.commit()
|
|
logger.info("Manual override applied: %s → wiki_title=%r", ticker, wiki_title)
|
|
|
|
return EntityOverrideResponse(
|
|
ticker=ticker,
|
|
wiki_title=wiki_title,
|
|
gdelt_query=gdelt_query,
|
|
message=f"Manual override set: {ticker} → {wiki_title!r}. is_manual_override=True.",
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# Parameterized entity route (before /event/ to avoid shadowing)
|
|
# ===========================================================================
|
|
|
|
|
|
@router.get(
|
|
"/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.
|
|
|
|
Returns **404** if no mapping exists — run `POST /admin/resolve/{ticker}` first.
|
|
|
|
**Example**: `GET /attention/entity/AAPL`
|
|
""",
|
|
)
|
|
@with_cache(namespace="attention:entity", ttl=3600, key_params=["ticker"])
|
|
async def get_entity(
|
|
ticker: str,
|
|
response: Response = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> EntityResolveResponse:
|
|
ticker = ticker.upper()
|
|
result = await db.execute(
|
|
select(CompanyEntityMap).where(CompanyEntityMap.ticker == ticker)
|
|
)
|
|
entity = result.scalars().first()
|
|
if not entity:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"No entity mapping found for {ticker}. POST /admin/resolve/{ticker} to create one.",
|
|
)
|
|
return EntityResolveResponse(
|
|
ticker=ticker,
|
|
entity=_entity_to_info(entity),
|
|
status="exists",
|
|
message="Entity mapping retrieved from database.",
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# Main event attention endpoint
|
|
# ===========================================================================
|
|
|
|
|
|
_EVENT_CACHE_TTL_COMPLETE = 3600 # full response with wiki data → 1 h
|
|
_EVENT_CACHE_TTL_INCOMPLETE = 300 # wiki_views still null → 5 min, retry sooner
|
|
|
|
|
|
@router.get(
|
|
"/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 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,
|
|
event_date: date = Query(..., description="Event date in YYYY-MM-DD format"),
|
|
response: Response = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> EventAttentionResponse:
|
|
ticker = ticker.upper()
|
|
cache_key = build_cache_key("attention:event", ticker, str(event_date))
|
|
|
|
cached = await get_cached_response(cache_key)
|
|
if cached:
|
|
cached_body, etag = cached
|
|
if response is not None:
|
|
response.headers["X-Cache"] = "HIT"
|
|
response.headers["Cache-Control"] = f"public, max-age={_EVENT_CACHE_TTL_COMPLETE}"
|
|
response.headers["ETag"] = etag
|
|
response.headers["X-Data-Source"] = "redis-cache"
|
|
return cached_body
|
|
|
|
try:
|
|
await asyncio.wait_for(_EVENT_SEMAPHORE.acquire(), timeout=_EVENT_SEMAPHORE_WAIT)
|
|
except asyncio.TimeoutError:
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail="서버가 바빠서 요청을 처리할 수 없습니다. 잠시 후 다시 시도하세요.",
|
|
)
|
|
try:
|
|
result = await _get_event_attention_impl(ticker, event_date, db)
|
|
finally:
|
|
_EVENT_SEMAPHORE.release()
|
|
|
|
# Incomplete responses (wiki_views still null) cache for only 5 min so they
|
|
# re-trigger collection sooner rather than serving stale nulls for up to 1 h.
|
|
wiki_complete = result.wiki.views is not None
|
|
ttl = _EVENT_CACHE_TTL_COMPLETE if wiki_complete else _EVENT_CACHE_TTL_INCOMPLETE
|
|
etag = await set_cached_response(cache_key, result.model_dump(), ttl_seconds=ttl)
|
|
if response is not None:
|
|
response.headers["X-Cache"] = "MISS"
|
|
response.headers["Cache-Control"] = f"public, max-age={ttl}"
|
|
response.headers["ETag"] = etag
|
|
|
|
return result
|
|
|
|
|
|
async def _get_event_attention_impl(
|
|
ticker: str,
|
|
event_date: date,
|
|
db: AsyncSession,
|
|
) -> EventAttentionResponse:
|
|
# 1. Get or resolve entity
|
|
entity_result = await db.execute(
|
|
select(CompanyEntityMap).where(CompanyEntityMap.ticker == ticker)
|
|
)
|
|
entity = entity_result.scalars().first()
|
|
|
|
if not entity:
|
|
logger.info("No entity mapping for %s — auto-resolving", ticker)
|
|
try:
|
|
entity = await resolve_entity(db, ticker)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
except Exception as exc:
|
|
logger.error("Auto entity resolution failed for %s: %s", ticker, exc)
|
|
raise HTTPException(status_code=500, detail=f"Entity resolution failed: {exc}")
|
|
elif (
|
|
not entity.wiki_title
|
|
and (entity.resolver_confidence or 0.0) < 0.5
|
|
and not entity.is_manual_override
|
|
and _resolve_retry_due(entity.updated_at)
|
|
):
|
|
# Present-but-unresolved row — e.g. a transient Wikipedia 429 during a
|
|
# prior bulk run persisted wiki_title=NULL. Retry resolution, but at
|
|
# most once per _RESOLVE_RETRY_WINDOW so a bulk scan over still-NULL
|
|
# tickers can't re-create the 429 storm. Best-effort: on failure keep
|
|
# serving the (empty) row rather than 500-ing. The resolver's
|
|
# no-downgrade guard ensures a failed retry can't worsen the row.
|
|
logger.info("Entity %s unresolved — attempting on-demand re-resolve", ticker)
|
|
try:
|
|
entity = await resolve_entity(db, ticker)
|
|
except Exception as exc: # noqa: BLE001
|
|
logger.warning("On-demand re-resolve failed for %s: %s", ticker, exc)
|
|
|
|
# 2. Check if features already exist in DB
|
|
features_result = await db.execute(
|
|
select(AttentionFeaturesDaily).where(
|
|
AttentionFeaturesDaily.ticker == ticker,
|
|
AttentionFeaturesDaily.date == event_date,
|
|
)
|
|
)
|
|
features = features_result.scalars().first()
|
|
|
|
needs_wiki = entity.wiki_title and (
|
|
features is None
|
|
or features.wiki_views is None
|
|
or features.wiki_zscore_20d is None
|
|
)
|
|
if features is None or needs_wiki:
|
|
# 3. On-demand collection + materialization.
|
|
# Re-triggers when features are missing OR wiki_views / wiki_zscore_20d is null:
|
|
# - wiki_views null: wiki_title was unset at collection time, or API failed
|
|
# - wiki_zscore_20d null: insufficient lookback data when previously materialized
|
|
# NOTE: GDELT is intentionally excluded here — it must be collected via
|
|
# the scheduler (POST /admin/collect/gdelt/{ticker}) to avoid IP rate bans.
|
|
logger.info("No features (or incomplete wiki) for %s on %s — collecting wiki on-demand", ticker, event_date)
|
|
|
|
if entity.wiki_title:
|
|
try:
|
|
await collect_wiki_pageviews(db, ticker, event_date)
|
|
except Exception as exc:
|
|
logger.warning("Wiki collection failed for %s: %s", ticker, exc)
|
|
|
|
try:
|
|
features = await materialize_features(db, ticker, event_date)
|
|
except Exception as exc:
|
|
logger.error("Feature materialization failed for %s: %s", ticker, exc)
|
|
raise HTTPException(status_code=500, detail=f"Feature materialization failed: {exc}")
|
|
|
|
# Re-fetch entity in case it was updated during resolution
|
|
entity_result2 = await db.execute(
|
|
select(CompanyEntityMap).where(CompanyEntityMap.ticker == ticker)
|
|
)
|
|
entity = entity_result2.scalars().first()
|
|
|
|
# Determine GDELT collection status for the client
|
|
if event_date < GDELT_EARLIEST_DATE:
|
|
gdelt_status = "not_available"
|
|
else:
|
|
window_start = event_date - timedelta(days=1)
|
|
window_end = event_date + timedelta(days=1)
|
|
collected_count = await db.execute(
|
|
select(func.count()).where(
|
|
GdeltArticleRaw.matched_ticker == ticker,
|
|
func.date(GdeltArticleRaw.published_at) >= window_start,
|
|
func.date(GdeltArticleRaw.published_at) <= window_end,
|
|
)
|
|
)
|
|
gdelt_status = "collected" if (collected_count.scalar() or 0) > 0 else "not_collected"
|
|
|
|
return _features_to_response(ticker, event_date, entity, features, gdelt_status)
|