From 5ec7c2461b398a647fbf593c2b1d0caf38b3c460 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 12 May 2026 13:44:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(stock-lab):=20/run=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=E2=80=94=20preview/manual=5Fsave?= =?UTF-8?q?/auto=20=EB=AA=A8=EB=93=9C=20=EB=A7=A4=ED=8A=B8=EB=A6=AD?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stock-lab/app/screener/router.py | 115 ++++++++++++++++++++++++++ stock-lab/app/test_screener_router.py | 60 ++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/stock-lab/app/screener/router.py b/stock-lab/app/screener/router.py index 6618c25..3cc81b6 100644 --- a/stock-lab/app/screener/router.py +++ b/stock-lab/app/screener/router.py @@ -83,3 +83,118 @@ def put_settings(body: schemas.SettingsBody): ) c.commit() 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, + ) diff --git a/stock-lab/app/test_screener_router.py b/stock-lab/app/test_screener_router.py index 301db2b..09db5a9 100644 --- a/stock-lab/app/test_screener_router.py +++ b/stock-lab/app/test_screener_router.py @@ -62,3 +62,63 @@ def test_settings_put_then_get_round_trip(client): body = r2.json() assert body["weights"]["foreign_buy"] == 2.5 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