From c196da4902c78056ea43f3ddb38dcb2631233664 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 31 May 2026 17:06:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(lotto):=20calibrate=5Fwinner=20+=20backfil?= =?UTF-8?q?l=20(=EB=A9=B1=EB=93=B1=C2=B7=EC=B2=AD=ED=81=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- lotto/app/backtest.py | 43 +++++++++++++++++++++++++++++++++ lotto/tests/test_backtest_db.py | 25 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/lotto/app/backtest.py b/lotto/app/backtest.py index 0b73392..1aa71e4 100644 --- a/lotto/app/backtest.py +++ b/lotto/app/backtest.py @@ -105,6 +105,49 @@ def calibrate_winner_compute(draws, target_draw_no, winning6, return {"scores": scores, "percentile": percentile, "cache_draws": len(pit)} +MIN_HISTORY = 30 # point-in-time 캐시 최소 회차 (이 미만은 캘리브레이션 skip) + + +def _db(): + from . import db as _db_mod + return _db_mod + + +def calibrate_winner(draw_no: int, sample_m: int = 2000) -> Dict[str, Any]: + """DB 진입점: 회차 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"]] + res = calibrate_winner_compute(draws, draw_no, winning6, sample_m=sample_m) + db.save_winner_calibration( + draw_no=draw_no, winning=winning6, scores=res["scores"], + percentile=res["percentile"], my_pick_avg=None, + cache_draws=res["cache_draws"], + ) + return {"ok": True, "draw_no": draw_no, **res} + + +def backfill_calibration(batch: int = 50, sample_m: int = 2000) -> Dict[str, Any]: + """미처리 회차만 batch개 캘리브레이션 (멱등·재개 가능).""" + db = _db() + draws = db.get_all_draw_numbers() + done = db.get_calibrated_draw_nos() + todo = [d for d, _ in draws if d not in done and d > MIN_HISTORY] + todo.sort() + n = 0 + for draw_no in todo[:batch]: + r = calibrate_winner(draw_no, sample_m=sample_m) + if r.get("ok"): + n += 1 + return {"calibrated": n, "remaining": max(0, len(todo) - batch)} + + 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 00123b7..d3f2b65 100644 --- a/lotto/tests/test_backtest_db.py +++ b/lotto/tests/test_backtest_db.py @@ -65,6 +65,31 @@ def test_winner_calibration_upsert(monkeypatch): assert row["score_total"] == 2.00 +def _seed_draws(db, n=40): + rows = [] + import random as _r; _r.seed(2) + for i in range(1, n + 1): + s = sorted(_r.sample(range(1, 46), 6)) + rows.append({"drw_no": i, "drw_date": f"2020-01-{(i%28)+1:02d}", + "n1": s[0], "n2": s[1], "n3": s[2], "n4": s[3], + "n5": s[4], "n6": s[5], "bonus": ((s[5] % 45) + 1)}) + db.upsert_many_draws(rows) + +def test_backfill_calibration_idempotent(monkeypatch): + db = _fresh_db(monkeypatch) + _seed_draws(db, 40) + from app import backtest as bt + r1 = bt.backfill_calibration(batch=15, sample_m=200) + # 첫 회차는 point-in-time 데이터가 빈약 → min_history 이후만 처리 + done1 = len(db.get_calibrated_draw_nos()) + assert done1 > 0 + r2 = bt.backfill_calibration(batch=100, sample_m=200) # 나머지 + done2 = len(db.get_calibrated_draw_nos()) + assert done2 >= done1 + r3 = bt.backfill_calibration(batch=100, sample_m=200) # 재실행 → 추가 0 + assert r3["calibrated"] == 0 + + def test_get_calibrated_draw_nos(monkeypatch): """저장된 draw_no 집합이 get_calibrated_draw_nos에 포함되어야 한다.""" db = _fresh_db(monkeypatch)