diff --git a/lotto/app/backtest.py b/lotto/app/backtest.py index 1aa71e4..377c6f4 100644 --- a/lotto/app/backtest.py +++ b/lotto/app/backtest.py @@ -148,6 +148,56 @@ def backfill_calibration(batch: int = 50, sample_m: int = 2000) -> Dict[str, Any return {"calibrated": n, "remaining": max(0, len(todo) - batch)} +def run_forward_purchase(draw_no: int, k: int = 5000, pool_n: int = 20000, + sample_seed: Optional[int] = None) -> Dict[str, Any]: + """회차 추첨 '직전' 시점 데이터로 3전략 구매 → 당첨번호로 채점 → 저장(멱등). + engine_w: 그 주 weight_trials 6개(없으면 current_base 1개)로 각각 구매.""" + db = _db() + draws = db.get_all_draw_numbers() + row = db.get_draw(draw_no) + if row is None: + return {"ok": False, "reason": "no_draw"} + pit = point_in_time_draws(draws, draw_no) + if len(pit) < MIN_HISTORY: + return {"ok": False, "reason": "insufficient_history"} + winning6 = [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]] + bonus = row["bonus"] + + cache = build_analysis_cache(pit) + nw = build_number_weights(cache) + pool = generate_pool(cache, nw, n=pool_n, seed=sample_seed) + + def _store(strategy, label, weight_json, trial_id, tickets): + graded = grade_tickets(tickets, winning6, bonus) + avg_meta = (sum(score_combination(t, cache)["score_total"] for t in tickets) + / max(len(tickets), 1)) + db.save_backtest_run( + draw_no=draw_no, strategy=strategy, weight_label=label, + weight_json=weight_json, trial_id=trial_id, n_tickets=len(tickets), + hist=graded, best_match=graded["best_match"], avg_meta_score=avg_meta, + ) + + # 1) engine_w — 그 주 trials(있으면) 아니면 current_base + 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 [] + 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 + bought = purchase_tickets(pool, cache, base, k) + _store("engine_w", "base", base, None, bought) + + # 2) random_null + _store("random_null", "-", None, None, random_null_tickets(k, seed=sample_seed)) + # 3) coverage + _store("coverage", "-", None, None, coverage_tickets(k, seed=sample_seed)) + + return {"ok": True, "draw_no": draw_no} + + def coverage_tickets(k: int, seed: Optional[int] = None) -> List[List[int]]: """greedy 커버리지 — 아직 덜 쓰인 번호를 우선 배치해 번호를 넓게 분산. (휠링/보장설계는 향후. 현재는 distinct + 번호 사용 균등화)""" diff --git a/lotto/tests/test_backtest_db.py b/lotto/tests/test_backtest_db.py index d3f2b65..d68aeb2 100644 --- a/lotto/tests/test_backtest_db.py +++ b/lotto/tests/test_backtest_db.py @@ -90,6 +90,22 @@ def test_backfill_calibration_idempotent(monkeypatch): assert r3["calibrated"] == 0 +def test_run_forward_purchase_persists_all_strategies(monkeypatch): + db = _fresh_db(monkeypatch) + _seed_draws(db, 40) + 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) + strategies = {r["strategy"] for r in rows} + assert "random_null" in strategies + assert "coverage" in strategies + assert "engine_w" in strategies # base 가중치로 최소 1건 + for r in rows: + assert r["n_tickets"] == 20 + + def test_get_calibrated_draw_nos(monkeypatch): """저장된 draw_no 집합이 get_calibrated_draw_nos에 포함되어야 한다.""" db = _fresh_db(monkeypatch)