Files
web-page-backend/lotto/tests/test_backtest_db.py

321 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os, tempfile
def _fresh_db(monkeypatch):
tmp = tempfile.mkdtemp()
path = os.path.join(tmp, "lotto.db")
from app import db
monkeypatch.setattr(db, "DB_PATH", path)
db.init_db()
return db
def test_backtest_tables_exist(monkeypatch):
db = _fresh_db(monkeypatch)
with db._conn() as conn:
tables = {r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
assert "backtest_runs" in tables
assert "winner_calibration" in tables
def test_backtest_runs_unique(monkeypatch):
db = _fresh_db(monkeypatch)
db.save_backtest_run(draw_no=100, strategy="random_null", weight_label="-",
weight_json=None, trial_id=None, n_tickets=10,
hist={"m3":1,"m4":0,"m5":0,"m6":0,"bonus_hits":0},
best_match=3, avg_meta_score=0.5)
db.save_backtest_run(draw_no=100, strategy="random_null", weight_label="-",
weight_json=None, trial_id=None, n_tickets=10,
hist={"m3":2,"m4":0,"m5":0,"m6":0,"bonus_hits":0},
best_match=3, avg_meta_score=0.6) # 멱등 upsert
rows = db.get_backtest_runs(draw_no=100)
assert len(rows) == 1
assert rows[0]["m3"] == 2 # 마지막 값으로 갱신
_SCORES = {
"score_total": 1.23,
"score_frequency": 0.30,
"score_fingerprint": 0.25,
"score_gap": 0.20,
"score_cooccur": 0.28,
"score_diversity": 0.20,
}
def test_winner_calibration_upsert(monkeypatch):
"""save_winner_calibration 두 번 호출 시 upsert — 행 1개, 값은 마지막 것."""
db = _fresh_db(monkeypatch)
winning = [3, 7, 15, 22, 33, 41]
db.save_winner_calibration(draw_no=200, winning=winning,
scores=_SCORES, percentile=75.0,
my_pick_avg=0.9, cache_draws=100)
# 두 번째 저장 — percentile, my_pick_avg 업데이트
scores2 = {**_SCORES, "score_total": 2.00}
db.save_winner_calibration(draw_no=200, winning=winning,
scores=scores2, percentile=80.0,
my_pick_avg=1.1, cache_draws=110)
row = db.get_winner_calibration(200)
assert row is not None
# 행이 1개만 존재하는지 확인
with db._conn() as conn:
cnt = conn.execute(
"SELECT COUNT(*) AS c FROM winner_calibration WHERE draw_no=200"
).fetchone()["c"]
assert cnt == 1
assert row["percentile"] == 80.0
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_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_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)
winning = [1, 2, 3, 4, 5, 6]
for draw_no in (301, 302, 303):
db.save_winner_calibration(draw_no=draw_no, winning=winning,
scores=_SCORES, percentile=50.0,
my_pick_avg=0.5, cache_draws=50)
nos = db.get_calibrated_draw_nos()
assert isinstance(nos, set)
assert {301, 302, 303}.issubset(nos)
def test_track_record_and_review_payload(monkeypatch):
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)
bt.calibrate_winner(40, sample_m=200)
tr = bt.track_record()
assert "random_null" in tr["by_strategy"]
# 이제 random_null은 N_NULL_TRIALS=6 행이므로 6*20=120장
assert tr["by_strategy"]["random_null"]["n_tickets"] >= 20
payload = bt.build_review_payload(40)
assert payload["draw_no"] == 40
assert "winner_analysis" in payload # 당첨조합 5분석치
assert "forward" in payload # 이번 회차 전략별 성적
assert "calibration_trend" in payload
assert payload["winner_analysis"] is not None
assert "score_total" in payload["winner_analysis"]
def test_run_forward_purchase_random_null_count(monkeypatch):
"""run_forward_purchase는 random_null을 N_NULL_TRIALS=6개 저장해야 한다."""
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=7)
assert res["ok"] is True
rows = db.get_backtest_runs(draw_no=40)
null_rows = [r for r in rows if r["strategy"] == "random_null"]
assert len(null_rows) == bt.N_NULL_TRIALS # 6개
null_labels = {r["weight_label"] for r in null_rows}
assert null_labels == {f"r{i}" for i in range(bt.N_NULL_TRIALS)}
for r in null_rows:
assert r["n_tickets"] == 20
def test_evaluate_weekly_gated_keeps_base_unchanged(monkeypatch):
"""Fix 5 통합 테스트 (end-to-end gated path).
접근: DB에 draws, weight_trials, auto_picks, backtest_runs, base_history를 직접 심어
evaluate_weekly()의 gated 분기가 base를 바꾸지 않음을 검증한다.
gated 조건: engine_w 최고 prize_score random_best < LIFT_EPSILON(10.0).
engine_best=5, random_best=20 → lift=-15 → gated.
evaluate_weekly 내부 흐름:
- get_weekly_trials(week_start) : _today_kst() 기준 week_start 사용
- get_latest_draw() : draws 테이블에서 max(drw_no) 반환
두 참조가 같은 날짜 기준이어야 하므로 _today_kst를 monkeypatch로 고정하고
draws의 최신 회차 날짜(drw_date)를 해당 주의 날짜로 맞춘다.
"""
import json as _json
from datetime import date, timedelta, datetime as _dt, timezone as _tz, timedelta as _td
db = _fresh_db(monkeypatch)
# KST 오늘 날짜 — evaluate_weekly가 이 날짜를 기준으로 week_start 계산
KST = _tz(_td(hours=9))
today_kst = _dt.now(KST).date()
from app import weight_evolver as we
week_start = we.get_week_start(today_kst)
# 1) draws 심기 — 최신 회차의 drw_date를 week_start 주 안의 날짜로 맞춤
import random as _r; _r.seed(99)
rows = []
for i in range(1, 41):
s = sorted(_r.sample(range(1, 46), 6))
# 마지막 회차(40)는 오늘 날짜 사용 (week_start 주 내)
if i == 40:
drw_date = today_kst.isoformat()
else:
drw_date = f"2020-01-{(i % 28) + 1:02d}"
rows.append({
"drw_no": i, "drw_date": drw_date,
"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)
latest = db.get_latest_draw()
assert latest is not None
assert latest["drw_date"] == today_kst.isoformat()
# 2) weight trial 1개 심기 (day_of_week=0, week_start=오늘 주)
trial_w = [0.2, 0.2, 0.2, 0.2, 0.2]
db.save_weight_trial(week_start, 0, trial_w, "perturb")
trial_rows = db.get_weekly_trials(week_start)
assert len(trial_rows) == 1
trial_id = trial_rows[0]["id"]
# 3) auto_picks 1개 심기 (winning 번호와 2개 일치 → max_correct=2)
winning6 = [latest["n1"], latest["n2"], latest["n3"],
latest["n4"], latest["n5"], latest["n6"]]
pick = winning6[:2] + [40, 41, 42, 43]
db.save_auto_pick(trial_id, 1, pick, meta_score=0.5)
# 4) backtest_runs: engine_w prize_score=5, random_null 6개 prize_score=20 (gated 확실)
LOW_HIST = {"m3": 5, "m4": 0, "m5": 0, "m6": 0, "bonus_hits": 0} # prize=5
HIGH_HIST = {"m3": 20, "m4": 0, "m5": 0, "m6": 0, "bonus_hits": 0} # prize=20
draw_no = latest["drw_no"]
db.save_backtest_run(
draw_no=draw_no, strategy="engine_w", weight_label="w0",
weight_json=_json.dumps(trial_w), trial_id=trial_id, n_tickets=20,
hist=LOW_HIST, best_match=2, avg_meta_score=0.5,
)
from app import backtest as bt
for i in range(bt.N_NULL_TRIALS):
db.save_backtest_run(
draw_no=draw_no, strategy="random_null", weight_label=f"r{i}",
weight_json=None, trial_id=None, n_tickets=20,
hist=HIGH_HIST, best_match=3, avg_meta_score=0.5,
)
# 5) current base 저장 (이전 주 월요일 effective_from)
base_w = [0.2, 0.2, 0.2, 0.2, 0.2]
prev_monday = (today_kst - timedelta(weeks=1, days=today_kst.weekday())).isoformat()
db.save_base_history(
effective_from=prev_monday,
weight=base_w,
source_trial_id=None,
update_reason="cold_start",
winner_score=None,
winner_max_correct=None,
)
assert db.get_current_base() == base_w
# 6) evaluate_weekly 호출 — _today_kst()를 monkeypatch로 오늘 날짜 고정
monkeypatch.setattr(we, "_today_kst", lambda: today_kst)
result = we.evaluate_weekly()
assert result.get("ok") is True, f"evaluate_weekly 실패: {result}"
# gated path 검증
update_reason = result.get("update_reason", "")
assert update_reason in ("unchanged_gated", "idempotent_skip"), (
f"gated여야 하는데 reason='{update_reason}' — 게이팅 로직 깨짐"
)
# base가 바뀌지 않았는지 검증
new_base = result.get("new_base")
assert new_base == base_w, (
f"gated인데 base가 변경됨: {new_base} != {base_w}"
)