diff --git a/lotto/app/backtest.py b/lotto/app/backtest.py index 377c6f4..ed44d77 100644 --- a/lotto/app/backtest.py +++ b/lotto/app/backtest.py @@ -113,10 +113,12 @@ def _db(): return _db_mod -def calibrate_winner(draw_no: int, sample_m: int = 2000) -> Dict[str, Any]: - """DB 진입점: 회차 1개 캘리브레이션 후 저장 (멱등).""" +def calibrate_winner(draw_no: int, sample_m: int = 2000, draws=None) -> Dict[str, Any]: + """DB 진입점: 회차 1개 캘리브레이션 후 저장 (멱등). + draws를 외부에서 전달하면 N+1 조회를 방지한다.""" db = _db() - draws = db.get_all_draw_numbers() + if draws is None: + draws = db.get_all_draw_numbers() row = db.get_draw(draw_no) if row is None: return {"ok": False, "reason": "no_draw"} @@ -142,7 +144,7 @@ def backfill_calibration(batch: int = 50, sample_m: int = 2000) -> Dict[str, Any todo.sort() n = 0 for draw_no in todo[:batch]: - r = calibrate_winner(draw_no, sample_m=sample_m) + r = calibrate_winner(draw_no, sample_m=sample_m, draws=draws) if r.get("ok"): n += 1 return {"calibrated": n, "remaining": max(0, len(todo) - batch)} @@ -177,16 +179,18 @@ def run_forward_purchase(draw_no: int, k: int = 5000, pool_n: int = 20000, hist=graded, best_match=graded["best_match"], avg_meta_score=avg_meta, ) - # 1) engine_w — 그 주 trials(있으면) 아니면 current_base + # 1) engine_w — 그 주 trials(있으면) 아니면 uniform fallback (leak-free) + from datetime import date as _date from . import weight_evolver as we - week_start = we.get_week_start() - trials = db.get_weekly_trials(week_start) if hasattr(db, "get_weekly_trials") else [] + draw_date = _date.fromisoformat(row["drw_date"]) + week_start = we.get_week_start(draw_date) + trials = db.get_weekly_trials(week_start) if trials: for t in trials: bought = purchase_tickets(pool, cache, t["weight"], k) _store("engine_w", f"w{t['day_of_week']}", t["weight"], t["id"], bought) else: - base = db.get_current_base() or [0.2] * 5 + base = [0.2] * 5 bought = purchase_tickets(pool, cache, base, k) _store("engine_w", "base", base, None, bought) diff --git a/lotto/tests/test_backtest_db.py b/lotto/tests/test_backtest_db.py index d68aeb2..6159124 100644 --- a/lotto/tests/test_backtest_db.py +++ b/lotto/tests/test_backtest_db.py @@ -106,6 +106,60 @@ def test_run_forward_purchase_persists_all_strategies(monkeypatch): assert r["n_tickets"] == 20 +def test_calibrate_winner_no_draw(monkeypatch): + """DB에 없는 회차 번호 → ok=False, reason='no_draw'.""" + db = _fresh_db(monkeypatch) + _seed_draws(db, 40) + from app import backtest as bt + r = bt.calibrate_winner(99999) + assert r["ok"] is False + assert r["reason"] == "no_draw" + + +def test_calibrate_winner_insufficient_history(monkeypatch): + """point-in-time 이력이 MIN_HISTORY(30) 미만인 회차 → reason='insufficient_history'. + draw_no=20이면 PIT 이력이 19개(draws 1~19)로 30 미만.""" + db = _fresh_db(monkeypatch) + _seed_draws(db, 40) + from app import backtest as bt + r = bt.calibrate_winner(20) + assert r["ok"] is False + assert r["reason"] == "insufficient_history" + + +def test_run_forward_purchase_with_trials(monkeypatch): + """그 주 weight_trials가 존재하면 engine_w 행의 weight_label이 'w0'..'w5' 형식이어야 한다.""" + db = _fresh_db(monkeypatch) + _seed_draws(db, 40) + # draw 40: drw_date='2020-01-13' → week_start='2020-01-13' + from datetime import date, timedelta + draw_date = date.fromisoformat("2020-01-13") + ws = (draw_date - timedelta(days=draw_date.weekday())).isoformat() + # 해당 주에 trial 2개 심기 (day_of_week 0, 1) + db.save_weight_trial(ws, 0, [0.1, 0.3, 0.2, 0.2, 0.2], "perturb") + db.save_weight_trial(ws, 1, [0.25, 0.25, 0.25, 0.15, 0.1], "perturb") + from app import backtest as bt + res = bt.run_forward_purchase(draw_no=40, k=20, pool_n=500, sample_seed=5) + assert res["ok"] is True + rows = db.get_backtest_runs(draw_no=40) + engine_w_labels = {r["weight_label"] for r in rows if r["strategy"] == "engine_w"} + # trials가 있으므로 'base'가 아닌 'w0', 'w1' 형식이어야 한다 + assert "base" not in engine_w_labels + assert any(lbl.startswith("w") for lbl in engine_w_labels) + + +def test_run_forward_purchase_idempotent(monkeypatch): + """run_forward_purchase 두 번 호출 시 upsert — 행 수 변화 없음.""" + db = _fresh_db(monkeypatch) + _seed_draws(db, 40) + from app import backtest as bt + bt.run_forward_purchase(draw_no=40, k=20, pool_n=500, sample_seed=5) + count_after_first = len(db.get_backtest_runs(draw_no=40)) + bt.run_forward_purchase(draw_no=40, k=20, pool_n=500, sample_seed=5) + count_after_second = len(db.get_backtest_runs(draw_no=40)) + assert count_after_second == count_after_first + + def test_get_calibrated_draw_nos(monkeypatch): """저장된 draw_no 집합이 get_calibrated_draw_nos에 포함되어야 한다.""" db = _fresh_db(monkeypatch)