feat(stock-lab): /run 엔드포인트 — preview/manual_save/auto 모드 매트릭스
This commit is contained in:
@@ -83,3 +83,118 @@ def put_settings(body: schemas.SettingsBody):
|
|||||||
)
|
)
|
||||||
c.commit()
|
c.commit()
|
||||||
return schemas.SettingsResponse(**body.model_dump(), updated_at=now)
|
return schemas.SettingsResponse(**body.model_dump(), updated_at=now)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- /run ----------
|
||||||
|
|
||||||
|
from . import telegram as _tg
|
||||||
|
from .engine import Screener, ScreenContext
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_asof(asof_str, conn: sqlite3.Connection) -> dt.date:
|
||||||
|
if asof_str:
|
||||||
|
return dt.date.fromisoformat(asof_str)
|
||||||
|
row = conn.execute("SELECT max(date) FROM krx_daily_prices").fetchone()
|
||||||
|
if not row or row[0] is None:
|
||||||
|
raise HTTPException(503, "no snapshot available — run /snapshot/refresh first")
|
||||||
|
return dt.date.fromisoformat(row[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _load_settings(conn) -> dict:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT weights_json,node_params_json,gate_params_json,top_n,"
|
||||||
|
"rr_ratio,atr_window,atr_stop_mult FROM screener_settings WHERE id=1"
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"weights": json.loads(row[0]),
|
||||||
|
"node_params": json.loads(row[1]),
|
||||||
|
"gate_params": json.loads(row[2]),
|
||||||
|
"top_n": row[3],
|
||||||
|
"rr_ratio": row[4],
|
||||||
|
"atr_window": row[5],
|
||||||
|
"atr_stop_mult": row[6],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_run(conn, asof, mode, weights, node_params, gate_params, top_n,
|
||||||
|
result, started_at, finished_at) -> int:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO screener_runs (asof,mode,status,started_at,finished_at,
|
||||||
|
weights_json,node_params_json,gate_params_json,top_n,survivors_count,telegram_sent)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,0)""",
|
||||||
|
(asof.isoformat(), mode, "success", started_at, finished_at,
|
||||||
|
json.dumps(weights), json.dumps(node_params), json.dumps(gate_params),
|
||||||
|
top_n, result.survivors_count),
|
||||||
|
)
|
||||||
|
run_id = cur.lastrowid
|
||||||
|
for row in result.rows:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO screener_results (run_id,rank,ticker,name,total_score,
|
||||||
|
scores_json,close,market_cap,entry_price,stop_price,target_price,atr14)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(run_id, row["rank"], row["ticker"], row["name"], row["total_score"],
|
||||||
|
json.dumps(row["scores"]), row["close"], row["market_cap"],
|
||||||
|
row["entry_price"], row["stop_price"], row["target_price"], row["atr14"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/run", response_model=schemas.RunResponse)
|
||||||
|
def post_run(body: schemas.RunRequest):
|
||||||
|
from .registry import NODE_REGISTRY as _NR, GATE_REGISTRY as _GR
|
||||||
|
started_at = dt.datetime.utcnow().isoformat()
|
||||||
|
with _conn() as c:
|
||||||
|
asof = _resolve_asof(body.asof, c)
|
||||||
|
defaults = _load_settings(c)
|
||||||
|
|
||||||
|
if body.mode == "auto":
|
||||||
|
weights = defaults["weights"]
|
||||||
|
node_params = defaults["node_params"]
|
||||||
|
gate_params = defaults["gate_params"]
|
||||||
|
top_n = defaults["top_n"]
|
||||||
|
else:
|
||||||
|
weights = body.weights if body.weights is not None else defaults["weights"]
|
||||||
|
node_params = body.node_params if body.node_params is not None else defaults["node_params"]
|
||||||
|
gate_params = body.gate_params if body.gate_params is not None else defaults["gate_params"]
|
||||||
|
top_n = body.top_n if body.top_n is not None else defaults["top_n"]
|
||||||
|
|
||||||
|
sizer_params = {
|
||||||
|
"atr_window": defaults["atr_window"],
|
||||||
|
"atr_stop_mult": defaults["atr_stop_mult"],
|
||||||
|
"rr_ratio": defaults["rr_ratio"],
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx = ScreenContext.load(c, asof)
|
||||||
|
score_nodes = [cls() for name, cls in _NR.items() if weights.get(name, 0) > 0]
|
||||||
|
gate = _GR["hygiene"]()
|
||||||
|
|
||||||
|
try:
|
||||||
|
screener = Screener(
|
||||||
|
gate=gate, score_nodes=score_nodes, weights=weights,
|
||||||
|
node_params=node_params, gate_params=gate_params,
|
||||||
|
top_n=top_n, sizer_params=sizer_params,
|
||||||
|
)
|
||||||
|
result = screener.run(ctx)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(422, str(e))
|
||||||
|
|
||||||
|
finished_at = dt.datetime.utcnow().isoformat()
|
||||||
|
run_id = None
|
||||||
|
if body.mode in ("manual_save", "auto"):
|
||||||
|
run_id = _persist_run(c, asof, body.mode, weights, node_params, gate_params,
|
||||||
|
top_n, result, started_at, finished_at)
|
||||||
|
|
||||||
|
payload = _tg.build_telegram_payload(
|
||||||
|
asof=asof, mode=body.mode, survivors_count=result.survivors_count,
|
||||||
|
top_n=top_n, rows=result.rows, run_id=run_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return schemas.RunResponse(
|
||||||
|
asof=asof.isoformat(), mode=body.mode, status="success",
|
||||||
|
run_id=run_id, survivors_count=result.survivors_count,
|
||||||
|
weights=weights, top_n=top_n,
|
||||||
|
results=result.rows,
|
||||||
|
telegram_payload=schemas.TelegramPayload(**payload),
|
||||||
|
warnings=result.warnings,
|
||||||
|
)
|
||||||
|
|||||||
@@ -62,3 +62,63 @@ def test_settings_put_then_get_round_trip(client):
|
|||||||
body = r2.json()
|
body = r2.json()
|
||||||
assert body["weights"]["foreign_buy"] == 2.5
|
assert body["weights"]["foreign_buy"] == 2.5
|
||||||
assert body["top_n"] == 30
|
assert body["top_n"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---- /run tests ----
|
||||||
|
|
||||||
|
from app.screener._test_fixtures import make_master, make_prices, make_flow
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_min(conn, asof_iso="2026-05-12"):
|
||||||
|
import datetime as dt
|
||||||
|
now = dt.datetime.utcnow().isoformat()
|
||||||
|
rows = [
|
||||||
|
("BIG1", "큰주식1", "KOSPI", 200_000_000_000, 0, 0, 0, None, now),
|
||||||
|
("BIG2", "큰주식2", "KOSPI", 100_000_000_000, 0, 0, 0, None, now),
|
||||||
|
("SMALL", "작은주식", "KOSPI", 1_000_000_000, 0, 0, 0, None, now),
|
||||||
|
]
|
||||||
|
for r in rows:
|
||||||
|
conn.execute("""INSERT INTO krx_master (ticker,name,market,market_cap,
|
||||||
|
is_managed,is_preferred,is_spac,listed_date,updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)""", r)
|
||||||
|
asof = dt.date(2026, 5, 12)
|
||||||
|
p = make_prices(["BIG1", "BIG2", "SMALL"], days=260, asof=asof)
|
||||||
|
f = make_flow(["BIG1", "BIG2", "SMALL"], days=260, asof=asof,
|
||||||
|
foreign_per_day={"BIG1": 100_000_000, "BIG2": 50_000_000, "SMALL": 0})
|
||||||
|
p.to_sql("krx_daily_prices", conn, if_exists="append", index=False)
|
||||||
|
f.to_sql("krx_flow", conn, if_exists="append", index=False)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_preview_no_save(client):
|
||||||
|
db_path = os.environ["STOCK_DB_PATH"]
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
_seed_min(c)
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
r = client.post("/api/stock/screener/run", json={"mode": "preview", "asof": "2026-05-12"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["status"] == "success"
|
||||||
|
assert body["run_id"] is None
|
||||||
|
assert body["telegram_payload"] is not None
|
||||||
|
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
cnt = c.execute("SELECT count(*) FROM screener_runs").fetchone()[0]
|
||||||
|
assert cnt == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_manual_save_writes_row(client):
|
||||||
|
db_path = os.environ["STOCK_DB_PATH"]
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
_seed_min(c)
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
r = client.post("/api/stock/screener/run",
|
||||||
|
json={"mode": "manual_save", "asof": "2026-05-12"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["run_id"] is not None
|
||||||
|
|
||||||
|
c = sqlite3.connect(db_path)
|
||||||
|
cnt = c.execute("SELECT count(*) FROM screener_runs").fetchone()[0]
|
||||||
|
assert cnt == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user