- GET /api/symbols/search?q=...&seed_only= : trigram + prefix + ILIKE 합산 정렬
- GET /api/symbols/{code} : 메타
- GET /api/chart/{code}?days=N&include_* : OHLCV + 일별 감성 + 외인기관거래대금
- POST /api/predict/{code}?horizons=1,3,5 : on-demand 앙상블 예측 + DB 적재
(user_triggered=TRUE)
- GET /api/predict/{code}/latest : 최신 base_date 의 예측 묶음 + base_close
(UI 가 차트 마지막 점에 이어 붙임)
- GET /api/metrics/{code}?window_days=N : 종목 단위 hit_rate / mae (model, horizon 별)
- GET /api/metrics?window_days=N : 전체 누적
- GET /api/news/{code}?source=&limit= : 최신순 뉴스/공시 목록 (감성 점수 포함)
main.py 에 6개 라우터 모두 include.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
102 lines
3.0 KiB
Python
102 lines
3.0 KiB
Python
"""종목 검색 / 메타 API."""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from sqlalchemy import text
|
|
|
|
from app.db.connection import get_engine
|
|
|
|
router = APIRouter(prefix="/api/symbols", tags=["symbols"])
|
|
|
|
|
|
@router.get("/search")
|
|
def search_symbols(
|
|
q: str = Query(..., min_length=1, max_length=40, description="종목명 또는 코드 prefix/부분 일치"),
|
|
limit: int = Query(default=20, ge=1, le=100),
|
|
seed_only: bool = Query(default=False, description="true 면 학습/배치 대상 10종목만"),
|
|
) -> dict:
|
|
"""이름은 trigram + ILIKE, 코드는 prefix 매치.
|
|
|
|
우선순위:
|
|
1) 코드가 정확히 같으면 가장 위
|
|
2) 이름 prefix 매치
|
|
3) 이름 부분 매치 (trigram similarity)
|
|
"""
|
|
q_norm = q.strip()
|
|
if not q_norm:
|
|
raise HTTPException(status_code=400, detail="empty query")
|
|
|
|
eng = get_engine()
|
|
where_seed = "AND is_seed = TRUE" if seed_only else ""
|
|
sql = text(
|
|
f"""
|
|
WITH ranked AS (
|
|
SELECT code, name, market, sector, is_seed,
|
|
CASE
|
|
WHEN code = :q THEN 0
|
|
WHEN code LIKE :prefix THEN 1
|
|
WHEN name LIKE :prefix THEN 2
|
|
WHEN name ILIKE :contains THEN 3
|
|
ELSE 4
|
|
END AS rank,
|
|
similarity(name, :q) AS sim
|
|
FROM symbols
|
|
WHERE active = TRUE
|
|
{where_seed}
|
|
AND (code LIKE :prefix OR name ILIKE :contains OR similarity(name, :q) > 0.2)
|
|
)
|
|
SELECT code, name, market, sector, is_seed
|
|
FROM ranked
|
|
ORDER BY rank ASC, sim DESC, name ASC
|
|
LIMIT :lim
|
|
"""
|
|
)
|
|
with eng.connect() as conn:
|
|
rows = conn.execute(
|
|
sql,
|
|
{
|
|
"q": q_norm,
|
|
"prefix": f"{q_norm}%",
|
|
"contains": f"%{q_norm}%",
|
|
"lim": limit,
|
|
},
|
|
).all()
|
|
return {
|
|
"q": q_norm,
|
|
"count": len(rows),
|
|
"items": [
|
|
{
|
|
"code": r[0],
|
|
"name": r[1],
|
|
"market": r[2],
|
|
"sector": r[3],
|
|
"is_seed": bool(r[4]),
|
|
}
|
|
for r in rows
|
|
],
|
|
}
|
|
|
|
|
|
@router.get("/{code}")
|
|
def get_symbol(code: str) -> dict:
|
|
eng = get_engine()
|
|
with eng.connect() as conn:
|
|
row = conn.execute(
|
|
text(
|
|
"SELECT code, name, market, sector, is_seed, active, created_at "
|
|
"FROM symbols WHERE code = :c"
|
|
),
|
|
{"c": code},
|
|
).first()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail=f"unknown code: {code}")
|
|
return {
|
|
"code": row[0],
|
|
"name": row[1],
|
|
"market": row[2],
|
|
"sector": row[3],
|
|
"is_seed": bool(row[4]),
|
|
"active": bool(row[5]),
|
|
"created_at": str(row[6]) if row[6] else None,
|
|
}
|