14 Commits

Author SHA1 Message Date
0f8c71c552 fix(lotto-evolver): previous_base diff + 일요일 cron skip + idempotent evaluate
- weight_evolver.evaluate_weekly: save_base_history 직전에 current_base를
  previous_base로 캡처해 return dict에 포함 → formatter가 진짜 diff 표시 가능
- evaluate_weekly: same effective_from row 이미 존재 시 save skip + idempotent
  return (토 22:00 lotto cron과 agent-office 22:15 재호출 중복 row 방지)
- main._run_weight_evolver_daily: 일요일(weekday=6) 도 skip — 토요일 trial을
  INSERT OR REPLACE로 덮어쓰는 문제 방지
- telegram_lotto._format_evolution_report: eval_result.previous_base 우선
  사용 (없으면 current_base 폴백) → diff 자기 자신 비교 버그 수정
- test_lotto_evolution_format: previous_base 키 추가 + 새 diff 검증 테스트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:35:20 +09:00
1401c5703d docs(CLAUDE): lotto-lab weight_evolver API/스케줄러/테이블 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:27:41 +09:00
92329f6fd5 feat(lotto-evolver): LottoAgent.run_weekly_evolution_report + 토 22:15 cron
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:24:18 +09:00
d0047c2b9d feat(lotto-evolver): 텔레그램 주간 evolution report 포맷 + 발송
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:21:23 +09:00
088944499c feat(lotto-evolver): service_proxy.lotto_evolver_status/evaluate helpers 2026-05-22 03:17:50 +09:00
a9fdbf8a93 feat(weight-evolver): evolver API 5종 (status/history/trials/generate-now/evaluate-now)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:15:57 +09:00
f46851d481 feat(weight-evolver): cron 3종 등록 (월 generate+apply / 일 apply / 토 evaluate)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:14:23 +09:00
11b3700959 feat(weight-evolver): run_simulation이 active W를 score_combination에 전달 2026-05-22 03:12:24 +09:00
1db8a0063d fix(weight-evolver): draws 테이블 컬럼명 n1..n6 사용 (drw_num1..6 X) + datetime import 정렬
evaluate_weekly()에서 당첨번호 참조 시 존재하지 않는 drw_num1..6 컬럼을
실제 테이블 컬럼명 n1..n6으로 수정. datetime/timedelta/timezone import를
파일 중간(line 128)에서 상단 stdlib imports 섹션으로 이동.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:11:37 +09:00
f017a61c79 feat(weight-evolver): DB 통합 진입점 (generate_weekly/apply_today/evaluate_weekly)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:08:56 +09:00
1694823129 feat(analyzer): score_combination에 weights 파라미터 추가 (None=기존 fixed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 03:06:26 +09:00
a4614ebeae feat(weight-evolver): lotto.db에 weight_trials/auto_picks/weight_base_history + CRUD 2026-05-22 03:03:51 +09:00
875e750f77 feat(weight-evolver): 순수 함수 (clamp/perturb/Dirichlet/score/base-rule)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 02:59:38 +09:00
9cb40fb4e5 test(weight-evolver): 순수 함수 + base update rule 단위 테스트 2026-05-22 02:56:06 +09:00
14 changed files with 1006 additions and 9 deletions

View File

@@ -164,10 +164,16 @@ docker compose up -d
| `lotto_briefings` | AI 큐레이터 주간 브리핑 (5세트 + 내러티브 + 토큰·비용 집계) |
| `todos` | 투두리스트 (UUID PK) — personal 서비스로 이전됨, 레거시 테이블 유지 |
| `blog_posts` | 블로그 글 (tags: JSON 배열) — personal 서비스로 이전됨, 레거시 테이블 유지 |
| `weight_trials` | 주별 6일치 후보 가중치 (4 perturb + 2 dirichlet) |
| `auto_picks` | 매일 N=5 시도 번호 + 채점 결과 |
| `weight_base_history` | base 갱신 이력 (winner_4plus / ema_blend / unchanged / cold_start) |
**스케줄러 job**
- 09:10 / 21:10 매일 — 당첨번호 동기화 + 채점 (`sync_latest``check_results_for_draw`)
- 00:05, 04:05, 08:05, 12:05, 16:05, 20:05 — 몬테카를로 시뮬레이션 (20,000후보 → 상위100 → best_picks 20개 교체)
- 월요일 09:00 — weight_evolver_weekly (6개 후보 생성 + 그날 N=5 추출)
- 매일 09:00 — weight_evolver_daily (월요일 제외, 오늘 W로 N=5 추출)
- 토요일 22:00 — weight_evolver_eval (회고 + 다음주 base 갱신)
**lotto-lab API 목록**
@@ -204,6 +210,11 @@ docker compose up -d
| GET | `/api/lotto/briefing/latest` | 최신 브리핑 |
| GET | `/api/lotto/briefing/{draw_no}` | 특정 회차 브리핑 |
| GET | `/api/lotto/briefing` | 브리핑 이력 |
| GET | `/api/lotto/evolver/status` | weight_evolver 이번주 trials + current_base + 진행 상황 |
| GET | `/api/lotto/evolver/history?weeks=12` | base 변경 이력 |
| GET | `/api/lotto/evolver/trials/{week_start}` | 특정 주 6 trials + 채점 결과 |
| POST | `/api/lotto/evolver/generate-now` | 수동 트리거 — 이번주 후보 생성 |
| POST | `/api/lotto/evolver/evaluate-now` | 수동 회고 + 다음주 base 갱신 |
### stock (stock/)
- Windows AI 서버 연동: `WINDOWS_AI_SERVER_URL=http://192.168.45.59:8000`
@@ -586,6 +597,7 @@ docker compose up -d
- 매 4시간 :15 — 로또 sim_check (00/04/08/12/16/20시)
- 일/수 21:15 — 로또 deep_check (큐레이션 후 confidence 포함 평가)
- 09:25 매일 — 로또 daily_digest (지난 24h 발화 텔레그램 1통)
- 토요일 22:15 — 로또 weight_evolver 주간 텔레그램 리포트
**RealestateAgent (`agents/realestate.py`)**
- 진입점: `on_new_matches(matches: list[dict]) -> {sent, sent_ids, message_id}`

View File

@@ -147,6 +147,26 @@ class LottoAgent(BaseAgent):
add_log(self.agent_id, f"daily_digest 발송: 평가 {evaluated} / 발화 {len(sigs)}")
return {"ok": True, **digest}
async def run_weekly_evolution_report(self) -> dict:
"""토 22:15 — lotto-lab evaluate-now 트리거 후 텔레그램 리포트."""
from ..service_proxy import lotto_evolver_evaluate, lotto_evolver_status
from ..notifiers.telegram_lotto import send_evolution_report
from ..db import add_log
try:
eval_result = await lotto_evolver_evaluate()
status = await lotto_evolver_status()
current_base = status.get("current_base") or [0.2] * 5
await send_evolution_report(eval_result, current_base)
add_log(
self.agent_id,
f"weekly_evolution_report 발송: draw={eval_result.get('draw_no')} reason={eval_result.get('update_reason')}",
)
return {"ok": True, **eval_result}
except Exception as e:
add_log(self.agent_id, f"weekly_evolution_report 예외: {e}", level="error")
return {"ok": False, "message": f"{type(e).__name__}: {e}"}
async def _run(self, source: str) -> dict:
task_id = create_task(self.agent_id, "curate_weekly", {"source": source})
await self.transition("working", "후보 수집 및 AI 큐레이션 중...", task_id)

View File

@@ -1,6 +1,6 @@
"""로또 큐레이션·당첨 알림 — 텔레그램 푸시."""
import logging
from typing import Dict, Any
from typing import Dict, Any, List
# 기존 에이전트들과 동일한 패턴: send_raw(text, reply_markup=None, chat_id=None)
# chat_id 생략 시 기본 TELEGRAM_CHAT_ID로 자동 발송.
@@ -159,3 +159,69 @@ async def send_signal_summary(digest: Dict[str, Any]) -> None:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] digest send failed: {e}")
# ---------- Weight Evolver 주간 리포트 ----------
_DAY_NAMES = ["", "", "", "", "", ""]
_METRIC_NAMES = ["freq", "finger", "gap", "cooccur", "divers"]
_REASON_LABEL = {
"winner_4plus": "4개 이상 일치 → base 교체",
"ema_blend": "3개 일치 → EMA blend (0.3)",
"unchanged": "유효 성과 없음 → base 유지",
"cold_start": "초기 균등 적용",
}
def _format_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> str:
"""주간 weight evolution 텔레그램 메시지. ok=False 또는 winner 없으면 빈 문자열."""
if not eval_result or "winner" not in eval_result:
return ""
draw_no = eval_result.get("draw_no", "?")
winner = eval_result["winner"]
new_base = eval_result.get("new_base") or [0.0] * 5
reason = eval_result.get("update_reason", "")
dow = winner.get("day_of_week", 0)
day_name = _DAY_NAMES[dow] if 0 <= dow < len(_DAY_NAMES) else "?"
lines = [
f"🧬 로또 학습 주간 리포트 ({draw_no}회차)",
"",
f"이번주 시도: 6일 × {winner.get('n_picks', 5)}세트",
"",
f"🏆 Winner: {day_name}요일",
f" W = [" + ", ".join(
f"{name} {w:.2f}" for name, w in zip(_METRIC_NAMES, winner["weight"])
) + "]",
f" 최고 적중: {winner.get('max_correct', 0)}개 일치 (max={winner.get('max_correct', 0)})",
f" 평균 점수: {winner.get('avg_score', 0):.2f}",
"",
f"📊 다음주 base 변경 ({reason}):",
]
# 우선순위: eval_result.previous_base > current_base (eval 직후 stale) > 균등 fallback
base_now = eval_result.get("previous_base") or current_base or [0.2] * 5
for i, (cur, new) in enumerate(zip(base_now, new_base)):
diff = new - cur
if abs(diff) < 0.005:
marker = "="
elif diff > 0:
marker = "+" if diff < 0.05 else "++"
else:
marker = "-" if diff > -0.05 else "--"
lines.append(f" {_METRIC_NAMES[i]:8s} {cur:.2f}{new:.2f} ({marker})")
lines.append("")
lines.append(f"{_REASON_LABEL.get(reason, reason)}")
lines.append("")
lines.append(f"[웹에서 차트 보기] ({LOTTO_URL}/evolver)")
return "\n".join(lines)
async def send_evolution_report(eval_result: Dict[str, Any], current_base: List[float]) -> None:
text = _format_evolution_report(eval_result, current_base)
if not text:
return
try:
await send_raw(text)
except Exception as e:
logger.warning(f"[telegram_lotto] evolution report send failed: {e}")

View File

@@ -56,6 +56,11 @@ async def _run_lotto_daily_digest():
if agent:
await agent.run_daily_digest()
async def _run_lotto_weekly_evolution_report():
agent = AGENT_REGISTRY.get("lotto")
if agent:
await agent.run_weekly_evolution_report()
async def _run_youtube_research():
agent = AGENT_REGISTRY.get("youtube")
if agent:
@@ -97,6 +102,7 @@ def init_scheduler():
scheduler.add_job(_run_lotto_sim_check, "cron", minute=15, hour="0,4,8,12,16,20", id="lotto_sim_check")
scheduler.add_job(_run_lotto_deep_check, "cron", day_of_week="sun,wed", hour=21, minute=15, id="lotto_deep_check")
scheduler.add_job(_run_lotto_daily_digest, "cron", hour=9, minute=25, id="lotto_digest")
scheduler.add_job(_run_lotto_weekly_evolution_report, "cron", day_of_week="sat", hour=22, minute=15, id="lotto_evolution_weekly")
scheduler.add_job(_run_youtube_research, "cron", hour=9, minute=10, id="youtube_research")
scheduler.add_job(_send_youtube_weekly_report, "cron", day_of_week="mon", hour=8, minute=0, id="youtube_weekly_report")
scheduler.add_job(_poll_pipelines, "interval", seconds=30, id="pipeline_poll")

View File

@@ -377,3 +377,20 @@ async def lotto_latest_draw() -> Optional[int]:
return None
except Exception:
return None
async def lotto_evolver_status() -> Dict[str, Any]:
"""GET /api/lotto/evolver/status — 이번주 trials + 다음주 base 정보."""
from .config import LOTTO_BACKEND_URL
resp = await _client.get(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/status")
resp.raise_for_status()
return resp.json()
async def lotto_evolver_evaluate() -> Dict[str, Any]:
"""POST /api/lotto/evolver/evaluate-now — 회고 트리거 (텔레그램 리포트용)."""
from .config import LOTTO_BACKEND_URL
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(f"{LOTTO_BACKEND_URL}/api/lotto/evolver/evaluate-now")
resp.raise_for_status()
return resp.json()

View File

@@ -0,0 +1,87 @@
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.notifiers.telegram_lotto import _format_evolution_report
def test_evolution_report_winner_4plus():
eval_result = {
"ok": True,
"draw_no": 1225,
"week_start": "2026-05-18",
"winner": {
"day_of_week": 3,
"weight": [0.18, 0.32, 0.20, 0.22, 0.08],
"avg_score": 0.42,
"max_correct": 4,
"n_picks": 5,
},
"new_base": [0.18, 0.32, 0.20, 0.22, 0.08],
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
"update_reason": "winner_4plus",
"per_day": [
{"day_of_week": 0, "avg_score": 0.20, "max_correct": 2},
{"day_of_week": 3, "avg_score": 0.42, "max_correct": 4},
],
}
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
text = _format_evolution_report(eval_result, current_base)
assert "🧬" in text
assert "1225" in text
assert "목요일" in text or "Winner" in text
assert "4개 일치" in text or "max=4" in text
assert "winner_4plus" in text
def test_evolution_report_unchanged():
eval_result = {
"ok": True,
"draw_no": 1226,
"week_start": "2026-05-25",
"winner": {
"day_of_week": 1,
"weight": [0.21, 0.19, 0.20, 0.20, 0.20],
"avg_score": 0.10,
"max_correct": 2,
"n_picks": 5,
},
"new_base": [0.20, 0.20, 0.20, 0.20, 0.20],
"update_reason": "unchanged",
"per_day": [],
}
current_base = [0.20, 0.20, 0.20, 0.20, 0.20]
text = _format_evolution_report(eval_result, current_base)
assert "unchanged" in text or "유지" in text
assert "2개 일치" in text or "max=2" in text
def test_evolution_report_empty_returns_empty():
"""evaluate가 ok=False면 빈 문자열 (발송 skip)."""
text = _format_evolution_report({"ok": False, "reason": "no_trials"}, [0.2]*5)
assert text == ""
def test_evolution_report_uses_previous_base_for_diff():
"""previous_base와 new_base 차이가 메시지 diff에 정확히 반영됨."""
eval_result = {
"ok": True,
"draw_no": 1227,
"winner": {
"day_of_week": 0,
"weight": [0.30, 0.20, 0.20, 0.20, 0.10],
"avg_score": 0.50,
"max_correct": 4,
"n_picks": 5,
},
"new_base": [0.30, 0.20, 0.20, 0.20, 0.10],
"previous_base": [0.20, 0.20, 0.20, 0.20, 0.20],
"update_reason": "winner_4plus",
}
# current_base는 stale (post-update 값) — previous_base가 우선 적용되어야 함
text = _format_evolution_report(eval_result, [0.30, 0.20, 0.20, 0.20, 0.10])
# freq: 0.20 → 0.30 (+0.10 = "++")
# divers: 0.20 → 0.10 (-0.10 = "--")
assert "0.20 → 0.30" in text # freq 증가
assert "0.20 → 0.10" in text # divers 감소
assert "(++)" in text or "(+)" in text # freq marker
assert "(--)" in text or "(-)" in text # divers marker

View File

@@ -170,7 +170,11 @@ def build_number_weights(cache: Dict[str, Any]) -> Dict[int, float]:
return weights
def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, float]:
def score_combination(
numbers: List[int],
cache: Dict[str, Any],
weights: Optional[List[float]] = None,
) -> Dict[str, float]:
"""
6개 번호 조합의 통계적 품질 점수 계산 (0~1 범위 정규화).
@@ -181,6 +185,13 @@ def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, fl
- score_cooccur (15%): 공동 출현 기댓값 대비
- score_diversity (10%): 연속번호, 범위, 구간 다양성
Args:
numbers: 6개 번호 리스트
cache: build_analysis_cache() 반환 딕셔너리
weights: 5가지 기법별 가중치 리스트 [frequency, fingerprint, gap, cooccur, diversity].
None이면 기본값 [0.25, 0.30, 0.20, 0.15, 0.10] 사용.
길이가 5가 아니면 ValueError 발생.
Returns:
{"score_total": ..., "score_frequency": ..., ...}
"""
@@ -282,12 +293,16 @@ def score_combination(numbers: List[int], cache: Dict[str, Any]) -> Dict[str, fl
)
# ── 최종 가중 합산 ────────────────────────────────────────────────────────
if weights is None:
weights = [0.25, 0.30, 0.20, 0.15, 0.10]
if len(weights) != 5:
raise ValueError("weights must have 5 elements")
score_total = (
score_frequency * 0.25
+ score_fingerprint * 0.30
+ score_gap * 0.20
+ score_cooccur * 0.15
+ score_diversity * 0.10
score_frequency * weights[0]
+ score_fingerprint * weights[1]
+ score_gap * weights[2]
+ score_cooccur * weights[3]
+ score_diversity * weights[4]
)
return {

View File

@@ -300,7 +300,51 @@ def init_db() -> None:
_ensure_column(conn, "lotto_briefings", "tier_rationale",
"ALTER TABLE lotto_briefings ADD COLUMN tier_rationale TEXT NOT NULL DEFAULT '{}'")
# ── weight_trials / auto_picks / weight_base_history 테이블 ──────────
conn.execute("""
CREATE TABLE IF NOT EXISTS weight_trials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL,
day_of_week INTEGER NOT NULL,
weight_json TEXT NOT NULL,
source TEXT NOT NULL,
base_at_gen TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(week_start, day_of_week)
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_wt_week
ON weight_trials(week_start, day_of_week)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS auto_picks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trial_id INTEGER NOT NULL REFERENCES weight_trials(id) ON DELETE CASCADE,
pick_no INTEGER NOT NULL,
numbers TEXT NOT NULL,
meta_score REAL,
correct INTEGER,
rank INTEGER,
graded_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(trial_id, pick_no)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ap_trial ON auto_picks(trial_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_ap_graded ON auto_picks(graded_at)")
conn.execute("""
CREATE TABLE IF NOT EXISTS weight_base_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
effective_from TEXT NOT NULL,
weight_json TEXT NOT NULL,
source_trial_id INTEGER REFERENCES weight_trials(id),
update_reason TEXT,
winner_score REAL,
winner_max_correct INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
""")
def upsert_draw(row: Dict[str, Any]) -> None:
@@ -1247,3 +1291,155 @@ def list_reviews(limit: int = 10) -> List[Dict[str, Any]]:
).fetchall()
return [_review_row(r) for r in rows]
# --- weight_trials / auto_picks / weight_base_history CRUD ---
def save_weight_trial(
week_start: str,
day_of_week: int,
weight: List[float],
source: str,
base_at_gen: Optional[List[float]] = None,
) -> int:
with _conn() as conn:
cur = conn.execute(
"""
INSERT INTO weight_trials (week_start, day_of_week, weight_json, source, base_at_gen)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(week_start, day_of_week) DO UPDATE SET
weight_json = excluded.weight_json,
source = excluded.source,
base_at_gen = excluded.base_at_gen
""",
(week_start, day_of_week, json.dumps(weight),
source, json.dumps(base_at_gen) if base_at_gen else None),
)
if cur.lastrowid:
return cur.lastrowid
row = conn.execute(
"SELECT id FROM weight_trials WHERE week_start=? AND day_of_week=?",
(week_start, day_of_week),
).fetchone()
return int(row["id"])
def get_weight_trial(week_start: str, day_of_week: int) -> Optional[Dict[str, Any]]:
with _conn() as conn:
row = conn.execute(
"SELECT * FROM weight_trials WHERE week_start=? AND day_of_week=?",
(week_start, day_of_week),
).fetchone()
if not row:
return None
d = dict(row)
d["weight"] = json.loads(d.pop("weight_json"))
if d.get("base_at_gen"):
d["base_at_gen"] = json.loads(d["base_at_gen"])
return d
def get_weekly_trials(week_start: str) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM weight_trials WHERE week_start=? ORDER BY day_of_week",
(week_start,),
).fetchall()
out = []
for r in rows:
d = dict(r)
d["weight"] = json.loads(d.pop("weight_json"))
if d.get("base_at_gen"):
d["base_at_gen"] = json.loads(d["base_at_gen"])
out.append(d)
return out
def save_auto_pick(
trial_id: int,
pick_no: int,
numbers: List[int],
meta_score: Optional[float] = None,
) -> int:
with _conn() as conn:
cur = conn.execute(
"""
INSERT OR REPLACE INTO auto_picks (trial_id, pick_no, numbers, meta_score)
VALUES (?, ?, ?, ?)
""",
(trial_id, pick_no, json.dumps(sorted(numbers)), meta_score),
)
return cur.lastrowid
def get_auto_picks(trial_id: int) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM auto_picks WHERE trial_id=? ORDER BY pick_no",
(trial_id,),
).fetchall()
out = []
for r in rows:
d = dict(r)
d["numbers"] = json.loads(d["numbers"])
out.append(d)
return out
def update_auto_pick_grade(pick_id: int, correct: int, rank: Optional[int]) -> None:
with _conn() as conn:
conn.execute(
"""
UPDATE auto_picks
SET correct=?, rank=?, graded_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id=?
""",
(correct, rank, pick_id),
)
def get_current_base() -> Optional[List[float]]:
"""weight_base_history 최신 row의 weight. 없으면 None (cold start)."""
with _conn() as conn:
row = conn.execute(
"SELECT weight_json FROM weight_base_history ORDER BY id DESC LIMIT 1",
).fetchone()
if not row:
return None
return json.loads(row["weight_json"])
def save_base_history(
effective_from: str,
weight: List[float],
source_trial_id: Optional[int],
update_reason: str,
winner_score: Optional[float],
winner_max_correct: Optional[int],
) -> int:
with _conn() as conn:
cur = conn.execute(
"""
INSERT INTO weight_base_history
(effective_from, weight_json, source_trial_id, update_reason,
winner_score, winner_max_correct)
VALUES (?, ?, ?, ?, ?, ?)
""",
(effective_from, json.dumps(weight), source_trial_id,
update_reason, winner_score, winner_max_correct),
)
return cur.lastrowid
def get_base_history(limit: int = 12) -> List[Dict[str, Any]]:
with _conn() as conn:
rows = conn.execute(
"SELECT * FROM weight_base_history ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
out = []
for r in rows:
d = dict(r)
d["weight"] = json.loads(d.pop("weight_json"))
out.append(d)
return out

View File

@@ -25,6 +25,7 @@ from .db import (
)
from .analyzer import build_analysis_cache, build_number_weights, score_combination
from .utils import weighted_sample_6
from .weight_evolver import get_active_weight
def run_simulation(
@@ -54,6 +55,7 @@ def run_simulation(
# ── 1. 통계 캐시 및 가중치 구성 (시뮬레이션 전체에서 재사용) ────────────
cache = build_analysis_cache(draws)
weights = build_number_weights(cache)
active_weights = get_active_weight() # None → analyzer uses fixed default
# ── 2. 후보 생성 및 스코어링 ──────────────────────────────────────────────
candidates: List[Dict[str, Any]] = []
@@ -69,7 +71,7 @@ def run_simulation(
continue
seen_keys.add(key)
scores = score_combination(nums, cache)
scores = score_combination(nums, cache, weights=active_weights)
candidates.append({
"numbers": sorted(nums),
**scores,

View File

@@ -38,6 +38,11 @@ from .strategy_evolver import (
get_weights_with_trend, recalculate_weights,
generate_smart_recommendation,
)
from .weight_evolver import (
generate_weekly_candidates_and_save,
apply_today_and_pick,
evaluate_weekly,
)
from .routers import curator as curator_router
from .routers import briefing as briefing_router
from .routers import review as review_router
@@ -111,9 +116,42 @@ def on_startup():
id="grade_weekly_review",
)
scheduler.add_job(_run_weight_evolver_weekly, "cron", day_of_week="mon", hour=9, minute=0, id="weight_evolver_weekly")
scheduler.add_job(_run_weight_evolver_daily, "cron", hour=9, minute=0, id="weight_evolver_daily")
scheduler.add_job(_run_weight_evolver_eval, "cron", day_of_week="sat", hour=22, minute=0, id="weight_evolver_eval")
scheduler.start()
async def _run_weight_evolver_weekly():
"""월 09:00 — 6개 후보 생성 후 inline으로 apply_today도 호출."""
try:
generate_weekly_candidates_and_save()
apply_today_and_pick(n=5)
except Exception as e:
logger.error(f"[weight_evolver_weekly] {e}")
async def _run_weight_evolver_daily():
"""매일 09:00 (월/일 제외 — 월=weekly inline, 일=토 trial 보호)."""
try:
from datetime import datetime, timezone, timedelta
KST = timezone(timedelta(hours=9))
if datetime.now(KST).weekday() in (0, 6):
return
apply_today_and_pick(n=5)
except Exception as e:
logger.error(f"[weight_evolver_daily] {e}")
async def _run_weight_evolver_eval():
"""토 22:00 — 회고 + 다음주 base 갱신."""
try:
evaluate_weekly()
except Exception as e:
logger.error(f"[weight_evolver_eval] {e}")
@app.get("/health")
def health():
return {"ok": True}
@@ -383,6 +421,62 @@ def api_strategy_evolve():
return {"ok": True, "weights": new_weights}
# ── weight-evolver API ───────────────────────────────────────────────────────
@app.get("/api/lotto/evolver/status")
async def evolver_status():
"""현재 base + 이번주 trials + auto_picks 진행 상황."""
from .weight_evolver import get_week_start
from .db import get_current_base, get_weekly_trials, get_auto_picks, get_latest_draw
ws = get_week_start()
trials = get_weekly_trials(ws)
trials_with_picks = []
for t in trials:
picks = get_auto_picks(t["id"])
trials_with_picks.append({**t, "picks": picks})
latest = get_latest_draw()
return {
"week_start": ws,
"current_base": get_current_base(),
"trials": trials_with_picks,
"latest_draw": latest["drw_no"] if latest else None,
}
@app.get("/api/lotto/evolver/history")
async def evolver_history(weeks: int = 12):
"""weight_base_history 최근 N개."""
from .db import get_base_history
return {"items": get_base_history(limit=weeks)}
@app.get("/api/lotto/evolver/trials/{week_start}")
async def evolver_trials(week_start: str):
"""특정 주 6 trials + 채점 결과."""
from .db import get_weekly_trials, get_auto_picks
trials = get_weekly_trials(week_start)
out = []
for t in trials:
picks = get_auto_picks(t["id"])
out.append({**t, "picks": picks})
return {"week_start": week_start, "trials": out}
@app.post("/api/lotto/evolver/generate-now")
async def evolver_generate_now():
"""수동 트리거 — 이번주 후보 생성."""
from .weight_evolver import generate_weekly_candidates_and_save
candidates = generate_weekly_candidates_and_save()
return {"ok": True, "candidates_count": len(candidates), "candidates": candidates}
@app.post("/api/lotto/evolver/evaluate-now")
async def evolver_evaluate_now():
"""수동 회고 + 다음주 base 갱신."""
from .weight_evolver import evaluate_weekly
return evaluate_weekly()
# ── 스마트 추천 API ────────────────────────────────────────────────────────
@app.get("/api/lotto/recommend/smart")

View File

@@ -4,3 +4,4 @@ requests==2.32.3
httpx==0.27.2
beautifulsoup4==4.12.3
APScheduler==3.10.4
numpy>=1.26

314
lotto/app/weight_evolver.py Normal file
View File

@@ -0,0 +1,314 @@
# lotto/app/weight_evolver.py
"""5종 시뮬 점수 가중치 자율 학습 루프.
순수 함수 (clamp/perturb/Dirichlet/score/base-rule) + DB 진입점은 별도 섹션.
"""
from __future__ import annotations
import math
import random
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
MIN_WEIGHT = 0.05
N_METRICS = 5
DEFAULT_UNIFORM = [0.2] * N_METRICS # cold start
RANK_BY_CORRECT = {6: 1, 5: 3, 4: 4, 3: 5}
RANK_BONUS = {1: 1.0, 2: 0.8, 3: 0.6, 4: 0.3, 5: 0.1}
def clamp_and_normalize(W: List[float], min_w: float = MIN_WEIGHT) -> List[float]:
"""각 값 ≥ min_w + 합=1.0. 보장 안 되면 raise."""
if len(W) != N_METRICS:
raise ValueError(f"W must have {N_METRICS} elements")
# Iteratively clamp then normalize until all values satisfy min_w floor.
# (Normalizing after clamping can reduce some already-floored values below
# min_w when the denominator is large — iterate to convergence.)
vals = [float(w) for w in W]
for _ in range(100): # converges in a few iterations in practice
clamped = [max(min_w, v) for v in vals]
total = sum(clamped)
vals = [v / total for v in clamped]
if all(v >= min_w - 1e-12 for v in vals):
break
return vals
def perturb_weights(
base: List[float],
sigma: float = 0.05,
seed: Optional[int] = None,
) -> List[float]:
"""base에 정규분포 noise(σ) 추가 → clamp+normalize."""
if seed is not None:
np.random.seed(seed)
noise = np.random.normal(0, sigma, size=N_METRICS)
perturbed = [b + n for b, n in zip(base, noise)]
return clamp_and_normalize(perturbed)
def dirichlet_weights(
alpha: float = 2.0,
seed: Optional[int] = None,
) -> List[float]:
"""Dirichlet(α, α, α, α, α) 샘플 → clamp+normalize."""
if seed is not None:
np.random.seed(seed)
sample = np.random.dirichlet([alpha] * N_METRICS).tolist()
return clamp_and_normalize(sample)
def generate_weekly_candidates(
base: Optional[List[float]] = None,
seed: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""6개 후보 — 4 perturb + 2 dirichlet. day_of_week 0..5 매핑.
Returns:
[{"day_of_week": 0, "weight": [...], "source": "perturb"}, ...]
"""
if base is None:
base = DEFAULT_UNIFORM[:]
if seed is not None:
np.random.seed(seed)
trials = []
for i in range(4):
trials.append({
"day_of_week": i,
"weight": perturb_weights(base, sigma=0.05),
"source": "perturb",
})
for i in range(4, 6):
trials.append({
"day_of_week": i,
"weight": dirichlet_weights(alpha=2.0),
"source": "dirichlet",
})
return trials
def count_match(pick: List[int], winning: List[int]) -> int:
"""본번호 6개 일치 개수. 보너스 제외."""
return len(set(pick) & set(winning[:6]))
def calc_pick_score(pick_numbers: List[int], winning_numbers: List[int]) -> float:
"""correct/6 + RANK_BONUS. 보너스 번호 미고려."""
correct = count_match(pick_numbers, winning_numbers)
base = correct / 6.0
rank = RANK_BY_CORRECT.get(correct)
bonus = RANK_BONUS.get(rank, 0) if rank else 0
return base + bonus
def decide_base_update(
winner_max_correct: int,
winner_W: List[float],
current_base: Optional[List[float]],
) -> Tuple[List[float], str]:
"""Hybrid base update rule.
Returns:
(new_base, reason) — reason ∈ {'winner_4plus','ema_blend','unchanged','cold_start'}
"""
if winner_max_correct >= 4:
return list(winner_W), "winner_4plus"
if winner_max_correct == 3 and current_base is not None:
blended = [0.3 * w + 0.7 * c for w, c in zip(winner_W, current_base)]
return clamp_and_normalize(blended), "ema_blend"
if current_base is None:
return DEFAULT_UNIFORM[:], "cold_start"
return list(current_base), "unchanged"
# ---------- DB-touching entry points ----------
KST = timezone(timedelta(hours=9))
def _db():
from . import db as _db_mod
return _db_mod
def _today_kst():
return datetime.now(KST).date()
def get_week_start(d=None) -> str:
"""주어진 날짜의 월요일 ISO 'YYYY-MM-DD'."""
if d is None:
d = _today_kst()
ws = d - timedelta(days=d.weekday())
return ws.isoformat()
def get_active_weight() -> Optional[List[float]]:
"""오늘 적용 중인 W. 없으면 None (균등 폴백)."""
today = _today_kst()
week_start = get_week_start(today)
dow = today.weekday()
if dow == 6:
dow = 5 # 일요일은 토요일 W 유지
trial = _db().get_weight_trial(week_start, dow)
if trial:
return trial["weight"]
return None
def generate_weekly_candidates_and_save(seed: Optional[int] = None) -> List[Dict[str, Any]]:
"""월요일 09:00 cron 진입점. 6 trials 생성 후 DB 저장."""
db = _db()
base = db.get_current_base()
if base is None:
base = DEFAULT_UNIFORM[:]
db.save_base_history(
effective_from=get_week_start(),
weight=base,
source_trial_id=None,
update_reason="cold_start",
winner_score=None,
winner_max_correct=None,
)
candidates = generate_weekly_candidates(base, seed=seed)
week_start = get_week_start()
for c in candidates:
db.save_weight_trial(
week_start=week_start,
day_of_week=c["day_of_week"],
weight=c["weight"],
source=c["source"],
base_at_gen=base,
)
return candidates
def apply_today_and_pick(n: int = 5) -> Dict[str, Any]:
"""매일 09:00 cron 진입점. 오늘 W로 N=5 세트 추출 후 auto_picks 저장."""
db = _db()
from . import analyzer, recommender
today = _today_kst()
week_start = get_week_start(today)
dow = min(today.weekday(), 5)
trial = db.get_weight_trial(week_start, dow)
if trial is None:
return {"ok": False, "reason": "no_trial_for_today"}
W = trial["weight"]
draws = db.get_all_draw_numbers()
cache = analyzer.build_analysis_cache(draws)
picks_saved = []
for i in range(1, n + 1):
try:
r = recommender.recommend_numbers(draws)
nums = r["numbers"]
s = analyzer.score_combination(nums, cache, weights=W)
pid = db.save_auto_pick(trial["id"], i, nums, meta_score=s["score_total"])
picks_saved.append({"id": pid, "numbers": nums, "score": s["score_total"]})
except Exception:
continue
return {
"ok": True,
"trial_id": trial["id"],
"weight": W,
"picks": picks_saved,
}
def evaluate_weekly() -> Dict[str, Any]:
"""토 22:00 cron 진입점. 6일 trials × N picks 채점 + base 갱신."""
db = _db()
today = _today_kst()
week_start = get_week_start(today)
trials = db.get_weekly_trials(week_start)
if not trials:
return {"ok": False, "reason": "no_trials"}
latest = db.get_latest_draw()
if latest is None:
return {"ok": False, "reason": "no_latest_draw"}
winning = [
latest["n1"], latest["n2"], latest["n3"],
latest["n4"], latest["n5"], latest["n6"],
]
per_day = []
for trial in trials:
picks = db.get_auto_picks(trial["id"])
if not picks:
continue
day_scores = []
max_c = 0
for p in picks:
correct = count_match(p["numbers"], winning)
rank = RANK_BY_CORRECT.get(correct)
db.update_auto_pick_grade(p["id"], correct, rank)
day_scores.append(calc_pick_score(p["numbers"], winning))
if correct > max_c:
max_c = correct
avg_score = sum(day_scores) / len(day_scores)
per_day.append({
"trial_id": trial["id"],
"day_of_week": trial["day_of_week"],
"weight": trial["weight"],
"avg_score": avg_score,
"max_correct": max_c,
"n_picks": len(picks),
})
if not per_day:
return {"ok": False, "reason": "no_picks_graded"}
winner = max(per_day, key=lambda d: d["avg_score"])
current_base = db.get_current_base()
new_base, reason = decide_base_update(
winner_max_correct=winner["max_correct"],
winner_W=winner["weight"],
current_base=current_base,
)
next_monday = today + timedelta(days=(7 - today.weekday()) % 7 or 7)
next_monday_iso = next_monday.isoformat()
# Idempotent guard: 같은 effective_from으로 이미 저장된 row가 있으면 skip
existing = db.get_base_history(limit=1)
if existing and existing[0]["effective_from"] == next_monday_iso:
return {
"ok": True,
"draw_no": latest["drw_no"],
"week_start": week_start,
"previous_base": existing[0].get("weight"),
"winner": winner,
"new_base": existing[0]["weight"], # 이미 저장된 값
"update_reason": existing[0].get("update_reason", "idempotent_skip"),
"per_day": per_day,
}
db.save_base_history(
effective_from=next_monday_iso,
weight=new_base,
source_trial_id=winner["trial_id"],
update_reason=reason,
winner_score=winner["avg_score"],
winner_max_correct=winner["max_correct"],
)
return {
"ok": True,
"draw_no": latest["drw_no"],
"week_start": week_start,
"previous_base": current_base, # save 이전에 캡처한 값 — diff 계산용
"winner": winner,
"new_base": new_base,
"update_reason": reason,
"per_day": per_day,
}

View File

@@ -0,0 +1,45 @@
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "app"))
import pytest
from analyzer import score_combination, build_analysis_cache
@pytest.fixture
def cache():
# build_analysis_cache expects [(drw_no, [n1,n2,n3,n4,n5,n6]), ...] tuples
fake_draws = [
(1, [1, 2, 3, 4, 5, 6]),
(2, [7, 8, 9, 10, 11, 12]),
]
return build_analysis_cache(fake_draws)
def test_score_default_uses_fixed_weights(cache):
"""weights=None은 기존 fixed [0.25, 0.30, 0.20, 0.15, 0.10]과 동등."""
s = score_combination([1, 2, 3, 4, 5, 6], cache)
assert "score_total" in s
assert 0.0 <= s["score_total"] <= 2.0
for k in ("score_frequency", "score_fingerprint", "score_gap",
"score_cooccur", "score_diversity"):
assert k in s
def test_score_with_custom_weights_sums_correctly(cache):
"""weights=[1,0,0,0,0]은 score_total == score_frequency."""
s = score_combination([1, 2, 3, 4, 5, 6], cache, weights=[1.0, 0.0, 0.0, 0.0, 0.0])
assert s["score_total"] == pytest.approx(s["score_frequency"], rel=1e-3)
def test_score_with_uniform_weights(cache):
"""weights=[0.2]*5는 단순 평균."""
s = score_combination([1, 2, 3, 4, 5, 6], cache, weights=[0.2] * 5)
expected = 0.2 * (s["score_frequency"] + s["score_fingerprint"]
+ s["score_gap"] + s["score_cooccur"] + s["score_diversity"])
assert s["score_total"] == pytest.approx(expected, rel=1e-3)
def test_score_weights_wrong_length_raises(cache):
with pytest.raises((ValueError, AssertionError)):
score_combination([1, 2, 3, 4, 5, 6], cache, weights=[0.5, 0.5])

View File

@@ -0,0 +1,122 @@
# lotto/tests/test_weight_evolver.py
import json
import math
import pytest
from app import weight_evolver as we
def test_clamp_and_normalize_min_floor():
"""모든 값이 0.05 이상이 되도록 보장 + 합=1.0."""
W = we.clamp_and_normalize([0.01, 0.6, 0.2, 0.1, 0.09])
assert all(w >= 0.05 - 1e-9 for w in W)
assert abs(sum(W) - 1.0) < 1e-9
def test_clamp_and_normalize_negative_becomes_floor():
W = we.clamp_and_normalize([-0.1, 0.5, 0.3, 0.2, 0.1])
assert W[0] >= 0.05 - 1e-9
assert abs(sum(W) - 1.0) < 1e-9
def test_perturbation_changes_around_base():
"""σ=0.05 정규분포 perturbation 후 정규화 — 각 값이 합리적 범위 안."""
base = [0.2, 0.2, 0.2, 0.2, 0.2]
W = we.perturb_weights(base, sigma=0.05, seed=42)
assert abs(sum(W) - 1.0) < 1e-9
assert all(w >= 0.05 - 1e-9 for w in W)
def test_dirichlet_random_distribution():
"""Dirichlet α=2 — 5종 비음수 합=1."""
W = we.dirichlet_weights(alpha=2.0, seed=42)
assert abs(sum(W) - 1.0) < 1e-9
assert all(0.05 - 1e-9 <= w <= 1.0 for w in W)
def test_generate_weekly_candidates_count():
"""6개 후보 생성 — 4 perturb + 2 dirichlet."""
base = [0.2, 0.2, 0.2, 0.2, 0.2]
trials = we.generate_weekly_candidates(base, seed=42)
assert len(trials) == 6
sources = [t["source"] for t in trials]
assert sources.count("perturb") == 4
assert sources.count("dirichlet") == 2
days = sorted(t["day_of_week"] for t in trials)
assert days == [0, 1, 2, 3, 4, 5]
def test_calc_pick_score_six_match():
"""6개 모두 일치 → 1등 → base=1.0 + bonus 1.0 = 2.0."""
score = we.calc_pick_score([1, 2, 3, 4, 5, 6], [1, 2, 3, 4, 5, 6])
assert score == pytest.approx(2.0)
def test_calc_pick_score_four_match():
"""4개 일치 → 4등 → base=4/6 + bonus 0.3."""
score = we.calc_pick_score([1, 2, 3, 4, 7, 8], [1, 2, 3, 4, 5, 6])
assert score == pytest.approx(4/6 + 0.3)
def test_calc_pick_score_three_match():
"""3개 일치 → 5등 → base=3/6 + bonus 0.1."""
score = we.calc_pick_score([1, 2, 3, 7, 8, 9], [1, 2, 3, 4, 5, 6])
assert score == pytest.approx(3/6 + 0.1)
def test_calc_pick_score_two_match_no_bonus():
"""2개 일치 → 미당첨 → base=2/6 + bonus 0."""
score = we.calc_pick_score([1, 2, 7, 8, 9, 10], [1, 2, 3, 4, 5, 6])
assert score == pytest.approx(2/6)
def test_decide_base_update_winner_4plus_replaces():
"""winner_max_correct ≥ 4 → 교체."""
current = [0.2, 0.2, 0.2, 0.2, 0.2]
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
new_base, reason = we.decide_base_update(
winner_max_correct=4,
winner_W=winner_W,
current_base=current,
)
assert new_base == winner_W
assert reason == "winner_4plus"
def test_decide_base_update_winner_3_ema_blend():
"""winner_max_correct = 3 → 0.3*winner + 0.7*current."""
current = [0.2, 0.2, 0.2, 0.2, 0.2]
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
new_base, reason = we.decide_base_update(
winner_max_correct=3,
winner_W=winner_W,
current_base=current,
)
expected = [0.3 * w + 0.7 * c for w, c in zip(winner_W, current)]
assert all(abs(a - b) < 1e-9 for a, b in zip(new_base, expected))
assert reason == "ema_blend"
def test_decide_base_update_winner_lt3_unchanged():
"""winner_max_correct ≤ 2 → 직전 base 유지."""
current = [0.2, 0.2, 0.2, 0.2, 0.2]
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
new_base, reason = we.decide_base_update(
winner_max_correct=2,
winner_W=winner_W,
current_base=current,
)
assert new_base == current
assert reason == "unchanged"
def test_decide_base_update_cold_start_returns_default():
"""current_base=None (첫 회) → 균등 default 반환."""
winner_W = [0.1, 0.3, 0.2, 0.3, 0.1]
new_base, reason = we.decide_base_update(
winner_max_correct=4,
winner_W=winner_W,
current_base=None,
)
assert new_base == winner_W
assert reason == "winner_4plus"