Files
trading/graph-engine/main.py
T
kyu 6d3b0bacc0 Initial commit: Korean stock value-investing AI pipeline
- 19개 마이크로서비스 (news-collector, score-engine, ta-engine, dart-collector,
  aux-signal, us-market, graph-engine, telegram-bot, dashboard-api, kis-api 등)
- 가치투자 스코어링 + 10공식 앙상블 보팅 (매직포뮬러/F-Score/Altman/PEG/
  모멘텀/Beneish/GP-A/G-Score/Amihud/BAB)
- 뉴스 수집→형태소→임베딩→중복제거→AI분석 파이프라인
- 기술적분석 + GAT 그래프신경망 + 미증시 동조 시그널

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:33:56 +09:00

636 lines
26 KiB
Python

"""
Graph Engine (port 9090, 172.30.0.25)
한국 종목 그래프 신경망 (GAT) — 다음날 수익률 예측 → stock_scores.graph_score
노드: 한국 활성종목 (dart_corps.is_active=true)
피처(12): 1d/5d/20d 수익률, vol_ratio, rsi, tech_score,
roe, operating_margin, debt_ratio, news_7d, us_overnight, log_mcap
엣지: ① 가격 60일 상관 |corr|>0.4 ② 같은 sector ③ 뉴스 공기 ≥3회
학습: 매주 일요일 06:00 (6mo rolling window)
추론: 매일 08:30 → stock_scores.graph_score
"""
import os
import asyncio
import json
import math
from datetime import date, datetime, timedelta
from typing import Optional, List, Tuple
import asyncpg
import structlog
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.nn.functional as F
from fastapi import FastAPI, Query, BackgroundTasks
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from pytz import timezone
# ─────────────────────────────────────────────────────────────
# 설정
# ─────────────────────────────────────────────────────────────
PG = {
"host": os.getenv("POSTGRES_HOST", "postgres"),
"port": int(os.getenv("POSTGRES_PORT", 5432)),
"database": os.getenv("POSTGRES_DB", "trading_ai"),
"user": os.getenv("POSTGRES_USER", "kyu"),
"password": os.getenv("POSTGRES_PASSWORD", ""),
}
KST = timezone("Asia/Seoul")
MODEL_DIR = os.getenv("GRAPH_MODEL_DIR", "/mnt/nas/models/graph")
os.makedirs(MODEL_DIR, exist_ok=True)
MODEL_PATH = os.path.join(MODEL_DIR, "gat_latest.pt")
FEATURE_DIM = 12
HIDDEN_DIM = 32
ATT_HEADS = 4
DROPOUT = 0.3
CORR_LOOKBACK = 60
CORR_THRESHOLD = 0.65
TOP_K_NEIGHBORS = 20 # 노드당 가격 상관 엣지 상한 (메모리/속도 캡)
NEWS_LOOKBACK = 14
NEWS_COOC_MIN = 3
TRAIN_WINDOW_DAYS = 180
SAMPLE_STRIDE_DAYS = 5 # 6개월 / 5일 = ~36 학습 샘플
logger = structlog.get_logger()
app = FastAPI(title="Graph Engine (GAT)")
pg_pool: Optional[asyncpg.Pool] = None
scheduler = AsyncIOScheduler(timezone=KST)
device = torch.device("cpu")
DDL = """
ALTER TABLE stock_scores
ADD COLUMN IF NOT EXISTS graph_score DOUBLE PRECISION;
CREATE TABLE IF NOT EXISTS graph_model_meta (
model_date DATE PRIMARY KEY,
train_samples INT,
val_samples INT,
val_loss DOUBLE PRECISION,
edge_count INT,
node_count INT,
hidden_dim INT,
notes TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS graph_predictions (
stock_code VARCHAR(10),
predict_date DATE,
pred_return DOUBLE PRECISION,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (stock_code, predict_date)
);
"""
# ─────────────────────────────────────────────────────────────
# GAT 모델 (torch-geometric 없이 순수 torch)
# ─────────────────────────────────────────────────────────────
class GATLayer(nn.Module):
def __init__(self, in_dim, out_dim, heads, dropout):
super().__init__()
assert out_dim % heads == 0
self.heads = heads
self.head_dim = out_dim // heads
self.W = nn.Linear(in_dim, out_dim, bias=False)
a_src = torch.empty(1, heads, self.head_dim)
a_dst = torch.empty(1, heads, self.head_dim)
nn.init.xavier_uniform_(a_src)
nn.init.xavier_uniform_(a_dst)
self.a_src = nn.Parameter(a_src.squeeze(0))
self.a_dst = nn.Parameter(a_dst.squeeze(0))
nn.init.xavier_uniform_(self.W.weight)
self.dropout = dropout
def forward(self, x, edge_index, edge_weight=None):
N = x.size(0)
h = self.W(x).view(N, self.heads, self.head_dim)
src, dst = edge_index[0], edge_index[1]
# 어텐션 logits per (edge, head)
e_src = (h[src] * self.a_src.unsqueeze(0)).sum(-1)
e_dst = (h[dst] * self.a_dst.unsqueeze(0)).sum(-1)
e = F.leaky_relu(e_src + e_dst, 0.2)
if edge_weight is not None:
e = e + edge_weight.unsqueeze(-1).log().clamp(min=-10)
# dst별 softmax: max-subtract for stability
e_max = torch.full((N, self.heads), -1e9, device=x.device)
e_max = e_max.scatter_reduce(0, dst.unsqueeze(-1).expand(-1, self.heads),
e, reduce="amax", include_self=True)
e_exp = torch.exp(e - e_max[dst])
denom = torch.zeros(N, self.heads, device=x.device).index_add_(0, dst, e_exp)
alpha = e_exp / (denom[dst] + 1e-12)
alpha = F.dropout(alpha, p=self.dropout, training=self.training)
# 메시지 집계
m = h[src] * alpha.unsqueeze(-1)
out = torch.zeros(N, self.heads, self.head_dim, device=x.device)
out = out.index_add_(0, dst, m)
return out.reshape(N, -1)
class GraphNet(nn.Module):
def __init__(self, in_dim=FEATURE_DIM, hidden=HIDDEN_DIM,
heads=ATT_HEADS, dropout=DROPOUT):
super().__init__()
self.gat1 = GATLayer(in_dim, hidden, heads, dropout)
self.gat2 = GATLayer(hidden, hidden, heads, dropout)
self.head = nn.Linear(hidden, 1)
self.dropout = dropout
def forward(self, x, edge_index, edge_weight=None):
x = F.elu(self.gat1(x, edge_index, edge_weight))
x = F.dropout(x, p=self.dropout, training=self.training)
x = F.elu(self.gat2(x, edge_index, edge_weight))
return self.head(x).squeeze(-1)
# ─────────────────────────────────────────────────────────────
# 데이터 로더
# ─────────────────────────────────────────────────────────────
async def load_active_codes(conn) -> List[str]:
rows = await conn.fetch(
"SELECT stock_code FROM dart_corps WHERE is_active=true ORDER BY stock_code")
return [r["stock_code"] for r in rows]
async def load_features(conn, codes: List[str], target_date: date) -> np.ndarray:
"""노드 피처 행렬 (N, 12) — 결측은 0으로."""
N = len(codes)
F_ = FEATURE_DIM
out = np.zeros((N, F_), dtype=np.float32)
idx = {c: i for i, c in enumerate(codes)}
# ── 가격 모멘텀 (1d/5d/20d 수익률), vol_ratio ──
rows = await conn.fetch("""
SELECT stock_code, dt, close_price, volume
FROM stock_ohlcv
WHERE dt BETWEEN $1 AND $2
ORDER BY stock_code, dt
""", target_date - timedelta(days=35), target_date)
df = pd.DataFrame(rows, columns=["stock_code", "dt", "close", "volume"])
if not df.empty:
df["close"] = df["close"].astype(float)
df["volume"] = df["volume"].astype(float)
for code, g in df.groupby("stock_code"):
if code not in idx:
continue
g = g.sort_values("dt")
closes = g["close"].values
vols = g["volume"].values
if len(closes) >= 2:
out[idx[code], 0] = (closes[-1] / closes[-2] - 1) * 100
if len(closes) >= 6:
out[idx[code], 1] = (closes[-1] / closes[-6] - 1) * 100
if len(closes) >= 21:
out[idx[code], 2] = (closes[-1] / closes[-21] - 1) * 100
if len(vols) >= 20:
v20 = vols[-20:].mean()
out[idx[code], 3] = (vols[-1] / v20) if v20 > 0 else 1.0
# ── 기술적 (RSI, tech_score) ──
rows = await conn.fetch("""
SELECT DISTINCT ON (stock_code) stock_code, rsi, tech_score
FROM stock_technical
WHERE analyzed_at::date <= $1
ORDER BY stock_code, analyzed_at DESC
""", target_date)
for r in rows:
if r["stock_code"] in idx:
out[idx[r["stock_code"]], 4] = float(r["rsi"] or 50)
out[idx[r["stock_code"]], 5] = float(r["tech_score"] or 0)
# ── 펀더멘털 (ROE, op_margin, debt_ratio) ──
rows = await conn.fetch("""
SELECT DISTINCT ON (stock_code) stock_code, roe, operating_margin, debt_ratio
FROM dart_financials
WHERE reprt_code='11011'
ORDER BY stock_code, bsns_year DESC
""")
for r in rows:
if r["stock_code"] in idx:
out[idx[r["stock_code"]], 6] = float(r["roe"] or 0)
out[idx[r["stock_code"]], 7] = float(r["operating_margin"] or 0)
out[idx[r["stock_code"]], 8] = float(r["debt_ratio"] or 0)
# ── 뉴스 감성 7일 ──
rows = await conn.fetch("""
SELECT primary_stock,
SUM(CASE sentiment WHEN '호재' THEN intensity
WHEN '악재' THEN -intensity ELSE 0 END)::float AS s
FROM news_analysis
WHERE analyzed_at BETWEEN $1 AND $2
AND primary_stock IS NOT NULL
GROUP BY primary_stock
""", target_date - timedelta(days=7), target_date + timedelta(days=1))
for r in rows:
if r["primary_stock"] in idx:
out[idx[r["primary_stock"]], 9] = float(r["s"])
# ── us_overnight_adj (latest) ──
rows = await conn.fetch("""
SELECT DISTINCT ON (stock_code) stock_code, us_overnight_adj
FROM stock_scores
WHERE score_date <= $1 AND us_overnight_adj IS NOT NULL
ORDER BY stock_code, score_date DESC
""", target_date)
for r in rows:
if r["stock_code"] in idx:
out[idx[r["stock_code"]], 10] = float(r["us_overnight_adj"])
# ── log market cap ──
rows = await conn.fetch("""
SELECT DISTINCT ON (stock_code) stock_code, market_cap
FROM stock_prices
WHERE market_cap IS NOT NULL
ORDER BY stock_code, collected_at DESC
""")
for r in rows:
if r["stock_code"] in idx and r["market_cap"]:
out[idx[r["stock_code"]], 11] = math.log10(float(r["market_cap"]) + 1)
# 표준화 (피처별 z-score, 클리핑)
mu = out.mean(axis=0)
sd = out.std(axis=0) + 1e-6
out = np.clip((out - mu) / sd, -5, 5)
return out.astype(np.float32)
async def load_price_corr_edges(conn, codes: List[str], target_date: date,
lookback: int = CORR_LOOKBACK,
threshold: float = CORR_THRESHOLD
) -> Tuple[np.ndarray, np.ndarray]:
"""가격 상관 엣지. 반환: edge_index (2, E), edge_weight (E,)."""
idx = {c: i for i, c in enumerate(codes)}
rows = await conn.fetch("""
SELECT stock_code, dt, close_price FROM stock_ohlcv
WHERE dt BETWEEN $1 AND $2 AND stock_code = ANY($3)
ORDER BY stock_code, dt
""", target_date - timedelta(days=int(lookback * 1.6)),
target_date, codes)
if not rows:
return np.zeros((2, 0), dtype=np.int64), np.zeros(0, dtype=np.float32)
df = pd.DataFrame(rows, columns=["code", "dt", "close"])
pivot = df.pivot(index="dt", columns="code", values="close").astype(float)
pivot = pivot.tail(lookback)
rets = pivot.pct_change().dropna(how="all")
# 결측치 많은 종목 제거 (절반 이상 결측)
rets = rets.dropna(axis=1, thresh=int(len(rets) * 0.5))
if rets.shape[1] < 2:
return np.zeros((2, 0), dtype=np.int64), np.zeros(0, dtype=np.float32)
rets = rets.fillna(0)
M = rets.values # (T, K)
M = (M - M.mean(axis=0)) / (M.std(axis=0) + 1e-9)
K = M.shape[1]
corr = (M.T @ M) / max(M.shape[0] - 1, 1)
np.fill_diagonal(corr, 0)
abs_corr = np.abs(corr)
# 임계값 필터 + 노드당 top-K 이웃만 유지 (메모리 캡)
mask = abs_corr > threshold
src_set: List[int] = []
dst_set: List[int] = []
w_set: List[float] = []
code_arr = list(rets.columns)
for i in range(K):
cands = np.where(mask[i])[0]
if len(cands) == 0:
continue
if len(cands) > TOP_K_NEIGHBORS:
top_idx = np.argpartition(-abs_corr[i, cands], TOP_K_NEIGHBORS)[:TOP_K_NEIGHBORS]
cands = cands[top_idx]
for j in cands:
src_set.append(idx[code_arr[i]])
dst_set.append(idx[code_arr[j]])
w_set.append(float(abs_corr[i, j]))
if not src_set:
return np.zeros((2, 0), dtype=np.int64), np.zeros(0, dtype=np.float32)
return (np.stack([np.array(src_set, dtype=np.int64),
np.array(dst_set, dtype=np.int64)]),
np.array(w_set, dtype=np.float32))
async def load_sector_edges(conn, codes: List[str]) -> np.ndarray:
"""같은 sector 노드 간 양방향 엣지."""
idx = {c: i for i, c in enumerate(codes)}
rows = await conn.fetch("""
SELECT stock_code, sector FROM dart_corps
WHERE is_active=true AND sector IS NOT NULL AND stock_code = ANY($1)
""", codes)
by_sec = {}
for r in rows:
by_sec.setdefault(r["sector"], []).append(r["stock_code"])
src, dst = [], []
for sec, lst in by_sec.items():
# 섹터당 종목 수가 100개 초과면 비대해지므로 무작위 샘플
if len(lst) > 80:
import random
random.seed(hash(sec) & 0xffff)
lst = random.sample(lst, 80)
for i, a in enumerate(lst):
for b in lst[i+1:]:
if a in idx and b in idx:
src += [idx[a], idx[b]]
dst += [idx[b], idx[a]]
if not src:
return np.zeros((2, 0), dtype=np.int64)
return np.stack([np.array(src, dtype=np.int64), np.array(dst, dtype=np.int64)])
async def load_news_edges(conn, codes: List[str], target_date: date,
lookback_days: int = NEWS_LOOKBACK,
min_count: int = NEWS_COOC_MIN) -> np.ndarray:
"""뉴스 공기관계: 같은 뉴스의 affected_stocks 페어 카운트 ≥ min_count."""
idx = {c: i for i, c in enumerate(codes)}
rows = await conn.fetch("""
SELECT affected_stocks FROM news_analysis
WHERE analyzed_at BETWEEN $1 AND $2
AND jsonb_array_length(affected_stocks) > 1
""", target_date - timedelta(days=lookback_days),
target_date + timedelta(days=1))
from collections import Counter
counter: Counter = Counter()
for r in rows:
codes_in_news = r["affected_stocks"]
if isinstance(codes_in_news, str):
codes_in_news = json.loads(codes_in_news)
codes_in_news = [c for c in codes_in_news if c in idx]
for i, a in enumerate(codes_in_news):
for b in codes_in_news[i+1:]:
key = (a, b) if a < b else (b, a)
counter[key] += 1
src, dst = [], []
for (a, b), cnt in counter.items():
if cnt >= min_count:
src += [idx[a], idx[b]]
dst += [idx[b], idx[a]]
if not src:
return np.zeros((2, 0), dtype=np.int64)
return np.stack([np.array(src, dtype=np.int64), np.array(dst, dtype=np.int64)])
async def build_graph(conn, target_date: date):
"""전체 그래프 구성. 반환: (codes, x, edge_index, edge_weight)."""
codes = await load_active_codes(conn)
x = await load_features(conn, codes, target_date)
ei_corr, ew_corr = await load_price_corr_edges(conn, codes, target_date)
ei_sec = await load_sector_edges(conn, codes)
ei_news = await load_news_edges(conn, codes, target_date)
# 결합 (sector/news는 weight=1.0)
ei_all = np.concatenate([ei_corr, ei_sec, ei_news], axis=1)
ew_all = np.concatenate([
ew_corr,
np.full(ei_sec.shape[1], 0.5, dtype=np.float32),
np.full(ei_news.shape[1], 0.7, dtype=np.float32),
])
logger.info("graph.built", nodes=len(codes), edges=int(ei_all.shape[1]),
price=int(ei_corr.shape[1]), sector=int(ei_sec.shape[1]),
news=int(ei_news.shape[1]))
return codes, x, ei_all.astype(np.int64), ew_all.astype(np.float32)
async def load_labels(conn, codes: List[str], target_date: date) -> np.ndarray:
"""target_date 종가 → target_date+1 영업일 종가의 변화율 (%)."""
idx = {c: i for i, c in enumerate(codes)}
labels = np.zeros(len(codes), dtype=np.float32)
mask = np.zeros(len(codes), dtype=bool)
rows = await conn.fetch("""
SELECT stock_code, dt, close_price FROM stock_ohlcv
WHERE dt BETWEEN $1 AND $2 AND stock_code = ANY($3)
ORDER BY stock_code, dt
""", target_date, target_date + timedelta(days=7), codes)
by_code = {}
for r in rows:
by_code.setdefault(r["stock_code"], []).append(
(r["dt"], float(r["close_price"])))
for code, lst in by_code.items():
lst.sort()
if code in idx and len(lst) >= 2:
t0, c0 = lst[0]
t1, c1 = lst[1]
if c0 > 0:
labels[idx[code]] = (c1 / c0 - 1) * 100
mask[idx[code]] = True
return labels, mask
# ─────────────────────────────────────────────────────────────
# 학습
# ─────────────────────────────────────────────────────────────
async def train_model(window_days: int = TRAIN_WINDOW_DAYS,
stride: int = SAMPLE_STRIDE_DAYS,
epochs: int = 30, lr: float = 1e-3):
"""rolling 윈도우 학습. 일별이 아닌 stride 간격으로 샘플링."""
today = date.today()
sample_dates = [today - timedelta(days=window_days - i)
for i in range(0, window_days, stride)]
sample_dates = [d for d in sample_dates if d.weekday() < 5]
if len(sample_dates) < 8:
return {"err": "too few samples", "samples": len(sample_dates)}
val_split = max(1, len(sample_dates) // 5)
train_dates = sample_dates[:-val_split]
val_dates = sample_dates[-val_split:]
logger.info("train.split", train=len(train_dates), val=len(val_dates))
async with pg_pool.acquire() as conn:
# 모든 샘플 그래프 미리 구성
samples = []
for d in sample_dates:
try:
codes, x, ei, ew = await build_graph(conn, d)
y, m = await load_labels(conn, codes, d)
if m.sum() < 10:
continue
samples.append({
"date": d,
"x": torch.tensor(x),
"ei": torch.tensor(ei),
"ew": torch.tensor(ew),
"y": torch.tensor(y),
"m": torch.tensor(m),
})
except Exception as e:
logger.warning("train.sample_err", date=str(d), err=str(e))
if len(samples) < 4:
return {"err": "no valid samples", "got": len(samples)}
val_count = max(1, len(samples) // 5)
train_set = samples[:-val_count]
val_set = samples[-val_count:]
model = GraphNet().to(device)
opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5)
best_val = float("inf")
for epoch in range(epochs):
model.train()
tot = 0.0
for s in train_set:
opt.zero_grad()
pred = model(s["x"].to(device), s["ei"].to(device),
s["ew"].to(device))
mask = s["m"].to(device)
loss = F.huber_loss(pred[mask], s["y"].to(device)[mask], delta=2.0)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
opt.step()
tot += float(loss)
tot /= len(train_set)
model.eval()
v = 0.0
with torch.no_grad():
for s in val_set:
pred = model(s["x"].to(device), s["ei"].to(device),
s["ew"].to(device))
mask = s["m"].to(device)
v += float(F.huber_loss(pred[mask], s["y"].to(device)[mask],
delta=2.0))
v /= len(val_set)
logger.info("train.epoch", epoch=epoch, train_loss=tot, val_loss=v)
if v < best_val:
best_val = v
torch.save({"state": model.state_dict(),
"feature_dim": FEATURE_DIM,
"hidden": HIDDEN_DIM,
"heads": ATT_HEADS}, MODEL_PATH)
async with pg_pool.acquire() as conn:
await conn.execute("""
INSERT INTO graph_model_meta
(model_date, train_samples, val_samples, val_loss,
edge_count, node_count, hidden_dim, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (model_date) DO UPDATE
SET train_samples=$2, val_samples=$3, val_loss=$4,
edge_count=$5, node_count=$6, hidden_dim=$7, notes=$8
""", today, len(train_set), len(val_set), best_val,
int(samples[-1]["ei"].shape[1]), int(samples[-1]["x"].shape[0]),
HIDDEN_DIM, f"epochs={epochs} lr={lr} stride={stride}")
return {"trained": True, "train_samples": len(train_set),
"val_samples": len(val_set), "best_val_loss": best_val}
# ─────────────────────────────────────────────────────────────
# 추론
# ─────────────────────────────────────────────────────────────
async def predict_today() -> dict:
if not os.path.exists(MODEL_PATH):
return {"err": "no trained model"}
today = date.today()
async with pg_pool.acquire() as conn:
codes, x, ei, ew = await build_graph(conn, today)
model = GraphNet().to(device)
ckpt = torch.load(MODEL_PATH, map_location=device, weights_only=True)
model.load_state_dict(ckpt["state"])
model.eval()
with torch.no_grad():
pred = model(torch.tensor(x).to(device),
torch.tensor(ei).to(device),
torch.tensor(ew).to(device))
pred_np = pred.cpu().numpy()
# graph_predictions만 저장 — score-engine이 calculate_daily_scores 시 읽어 ensemble에 적용.
async with pg_pool.acquire() as conn:
async with conn.transaction():
for i, code in enumerate(codes):
p = float(pred_np[i])
await conn.execute("""
INSERT INTO graph_predictions (stock_code, predict_date, pred_return)
VALUES ($1, $2, $3)
ON CONFLICT (stock_code, predict_date) DO UPDATE
SET pred_return=$3, created_at=NOW()
""", code, today, p)
return {"predicted": len(codes), "date": str(today)}
# ─────────────────────────────────────────────────────────────
# FastAPI
# ─────────────────────────────────────────────────────────────
@app.on_event("startup")
async def on_start():
global pg_pool
pg_pool = await asyncpg.create_pool(**PG, min_size=2, max_size=5)
async with pg_pool.acquire() as conn:
await conn.execute(DDL)
# 16:25 KST: score-engine 16:30 calculate_daily_scores 직전 추론
scheduler.add_job(predict_today, CronTrigger(hour=16, minute=25,
day_of_week="mon-fri"),
id="graph_predict", replace_existing=True)
scheduler.add_job(train_model, CronTrigger(day_of_week="sun", hour=6,
minute=0),
id="graph_train", replace_existing=True)
scheduler.start()
logger.info("graph-engine.started")
@app.on_event("shutdown")
async def on_stop():
if scheduler.running:
scheduler.shutdown()
if pg_pool:
await pg_pool.close()
@app.get("/health")
async def health():
return {"status": "ok",
"model_exists": os.path.exists(MODEL_PATH)}
@app.post("/graph/build")
async def manual_build(target: Optional[str] = None):
d = date.fromisoformat(target) if target else date.today()
async with pg_pool.acquire() as conn:
codes, x, ei, ew = await build_graph(conn, d)
return {"nodes": len(codes), "edges": int(ei.shape[1]),
"feature_shape": list(x.shape), "date": str(d)}
@app.post("/train")
async def manual_train(epochs: int = Query(default=30, ge=1, le=200),
window_days: int = Query(default=180, ge=30, le=365),
stride: int = Query(default=5, ge=1, le=30),
bg: BackgroundTasks = None):
if bg:
bg.add_task(train_model, window_days, stride, epochs)
return {"status": "queued", "epochs": epochs}
return await train_model(window_days, stride, epochs)
@app.post("/predict")
async def manual_predict():
return await predict_today()
@app.get("/predict/{code}")
async def predict_stock(code: str, limit: int = Query(default=30, ge=1, le=180)):
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT predict_date, pred_return
FROM graph_predictions
WHERE stock_code=$1
ORDER BY predict_date DESC LIMIT $2
""", code, limit)
return {"code": code, "history": [dict(r) for r in rows]}
@app.get("/status")
async def status():
async with pg_pool.acquire() as conn:
meta = await conn.fetchrow("""
SELECT * FROM graph_model_meta ORDER BY model_date DESC LIMIT 1
""")
latest_pred = await conn.fetchrow("""
SELECT predict_date, COUNT(*) AS n
FROM graph_predictions
GROUP BY predict_date
ORDER BY predict_date DESC LIMIT 1
""")
return {
"model": dict(meta) if meta else None,
"latest_prediction": dict(latest_pred) if latest_pred else None,
"model_exists": os.path.exists(MODEL_PATH),
}