feat: GET /stocks/gainers/snapshots — 과거 5분봉 gainer snapshot 조회 엔드포인트 추가

gainer_snapshots 테이블을 읽는 엔드포인트가 없어 백테스팅이 불가능했던 문제 해결.
?at=<ISO8601> 생략 시 최신 snapshot, as-of semantics로 장외 시각도 자연스럽게 처리.
openapi.json 동기화.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 3 months ago
parent cc024ecbe1
commit e2389eba31

@ -4,11 +4,17 @@ Stock Market Data endpoints
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Response
import logging
import asyncio
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.gainer_snapshot import GainerSnapshot
from app.services.gainers.collector import _floor_to_5min
from app.services.yahoo_most_active_service import yahoo_most_active_service
from app.services.yahoo_52week_gainers_service import yahoo_52week_gainers_service
from app.services.index_constituents_service import index_constituents_service
@ -346,6 +352,80 @@ async def get_day_gainers(
}
@router.get(
"/gainers/snapshots",
summary="Historical top gainers snapshot (5-min interval, DB)",
)
async def get_gainer_snapshot(
at: Optional[datetime] = Query(
None,
description="UTC timestamp to query (ISO 8601). Omit for the latest snapshot.",
),
limit: int = Query(100, ge=1, le=200, description="Number of top gainers to return (max 200)"),
db: AsyncSession = Depends(get_db),
):
"""
Return top gainers stored in the DB at the requested point in time.
- **at** omitted most recent 5-min snapshot.
- **at** provided as-of semantics: the latest snapshot whose `snapshot_at floor(at, 5min)`.
- Returns `404` when no snapshot exists before the requested time.
"""
if at is None:
target = (
await db.execute(select(func.max(GainerSnapshot.snapshot_at)))
).scalar()
else:
floored = _floor_to_5min(
at if at.tzinfo else at.replace(tzinfo=timezone.utc)
)
target = (
await db.execute(
select(func.max(GainerSnapshot.snapshot_at)).where(
GainerSnapshot.snapshot_at <= floored
)
)
).scalar()
if target is None:
raise HTTPException(status_code=404, detail="No gainer snapshots found for the requested time")
rows = (
await db.execute(
select(GainerSnapshot)
.where(GainerSnapshot.snapshot_at == target)
.order_by(GainerSnapshot.rank.asc())
.limit(limit)
)
).scalars().all()
return {
"snapshot_at": target.isoformat(),
"requested_at": at.isoformat() if at else None,
"count": len(rows),
"stocks": [
{
"rank": r.rank,
"symbol": r.symbol,
"name": r.name,
"exchange": r.exchange,
"price": r.price,
"change_percent": r.change_percent,
"volume": r.volume,
"avg_volume_3m": r.avg_volume_3m,
"market_cap": r.market_cap,
"pe_ratio": r.pe_ratio,
"forward_pe": r.forward_pe,
"eps_ttm": r.eps_ttm,
"dividend_yield": r.dividend_yield,
"fifty_two_week_high": r.fifty_two_week_high,
"fifty_two_week_low": r.fifty_two_week_low,
}
for r in rows
],
}
@router.get(
"/trending",
summary="Trending stocks combining most active and 52-week gainers",

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save