4 Commits

Author SHA1 Message Date
383f48c71e feat(stock): GET /api/stock/holidays endpoint (SP-10 task-watcher용)
holidays.json(list) 노출. task-watcher가 휴장일 판정에 조회.
인증 불필요. 주말은 task-watcher가 weekday로 별도 판정.
Plan-B-Infra Phase 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:40:44 +09:00
6be74737c2 docs(plan): Lotto Weight Evolver 구현 plan (13 tasks, Phase 1-4 + 배포)
Why: spec (2026-05-22-lotto-weight-evolver-design.md)을 13개 atomic
task로 분해. TDD red→green→commit 패턴. analyzer.score_combination
기존 fixed 가중치 보존+동적 W 옵션 추가. v1 시그널 자동 cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:38:23 +09:00
3106716e70 docs(plan): Plan-B-Infra — NSSM 자동 시작(SP-9) + task-watcher(SP-10)
SP-9 NSSM 안내(ai_trade HIGH + wsl_docker NORMAL) + SP-10 task-watcher
WSL2 컨테이너(시간대 큐 토글). 박재오 결정: idle 감지 생략 — 시간대만.

8 task: NAS holidays endpoint(1) → task-watcher mode/watcher/main/compose(2-5)
→ NSSM 안내 문서(6) → 박재오 빌드+검증(7) → 메모리(8).

spec 정정: signal_v2→ai_trade, Ubuntu-22.04→24.04, web-ai-services→web-ai/services.
완료 시 spec 12 SP 전부 완료.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:37:36 +09:00
a126155948 docs(spec): Lotto Weight Evolver — 자율 학습 루프 설계 (v2)
Why: v1 능동 모니터링 위에 매주 6가지 가중치 시도+토요일 회고+
winner 기반 base 갱신 루프를 lotto-lab에 추가. 5종 시뮬 점수
가중치를 사람 없이 자가 학습.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:12:12 +09:00
5 changed files with 2968 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,929 @@
# Plan-B-Infra — NSSM 자동 시작 + task-watcher (시간대 큐 토글) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Windows AI 머신의 서비스(ai_trade + WSL2 Docker)를 NSSM으로 부팅 시 자동 시작 + 우선순위 설정(SP-9), 그리고 시간대 기반으로 `queue:paused`를 토글하는 task-watcher 컨테이너 신설(SP-10). 트레이딩 시간대(비휴장 평일 07:0016:30)에 무거운 render 작업을 일시정지하여 KIS 트레이딩 우선순위 보장.
**Architecture:** task-watcher는 WSL2 Docker 컨테이너로 30초마다 `current_mode()` 판정(KST 시각 + NAS `/api/stock/holidays` 조회) → 트레이딩 시간대면 `SET queue:paused 1 EX 600`, 그 외엔 `DEL queue:paused`. 모든 render worker(insta/music/video)가 BLPOP 전 `queue:paused`를 확인하므로 단일 키로 전체 일시정지. NSSM(SP-9)은 박재오 Windows 머신에서 수동 설치 — plan은 정확한 명령 + 안내 문서 제공.
**Tech Stack:** Python 3.12 / `redis>=5.0` / `httpx` (holidays fetch) / `zoneinfo` (KST) / Docker Engine in WSL2 / NSSM (Windows service manager) / FastAPI (NAS stock holidays endpoint)
**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §3 시간대별 우선순위 모드, §10 SP-9·SP-10. **박재오 결정 (2026-05-22): idle/게임 감지 생략 — 시간대만으로 토글** (spec §3의 "박재오 활동 감지 시 SET" → "트레이딩 시간대면 무조건 SET"). idle 감지가 없으므로 WSL2 컨테이너로 구현 가능 (Win32 input API 불필요).
**Spec 갱신 사항 (현 상태 반영):**
- `signal_v2`**`ai_trade`** (rename 완료, web-ai/ai_trade/)
- `Ubuntu-22.04`**`Ubuntu-24.04`** (Plan-B-Base에서 변경)
- `web-ai-services`**`web-ai/services`** (실제 경로)
- `/api/stock/holidays` endpoint **미존재 → 신설** (Task 1)
**Prerequisites (✅ 모두 완료):**
- Plan-A / Plan-B-Base / Plan-B-Insta / Plan-B-Music / Plan-B-Video 모두 완료
- WSL2 mirror mode + Redis chown 999:999 영구 적용
- services/.env 분기 패턴 정착 (NAS_BASE_URL service-local default)
---
## Phase 구조
| Phase | 내용 | Task |
|-------|------|------|
| **1. NAS stock holidays endpoint** | `/api/stock/holidays` GET 신설 (task-watcher가 조회) | 1 |
| **2. Windows task-watcher** | mode 판정 + Redis 토글 loop + Dockerfile + compose | 2~6 |
| **3. NSSM 안내 + 검증** | SP-9 NSSM 안내 문서 + 박재오 빌드 + end-to-end | 7~8 |
---
## File Structure
### Phase 1 — NAS web-backend
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-backend/stock/app/main.py` | `GET /api/stock/holidays` endpoint 추가 | holidays.json + 주말 노출 |
| `web-backend/stock/app/test_holidays_endpoint.py` (Create) | 2 tests | TDD |
### Phase 2 — Windows web-ai/services/task-watcher
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-ai/services/task-watcher/mode.py` (Create) | `current_mode(now, holidays)` 순수 함수 + `fetch_holidays()` | 시간대 판정 |
| `web-ai/services/task-watcher/watcher.py` (Create) | 30초 loop + Redis 토글 | dispatcher |
| `web-ai/services/task-watcher/main.py` (Create) | FastAPI + lifespan(watcher spawn) + /health | entry |
| `web-ai/services/task-watcher/Dockerfile` (Create) | python:3.12-slim | image |
| `web-ai/services/task-watcher/requirements.txt` (Create) | fastapi, redis, httpx, pytest | deps |
| `web-ai/services/task-watcher/.env.example` (Create) | REDIS_URL, STOCK_BASE_URL, TRADING_START, TRADING_END | secrets |
| `web-ai/services/task-watcher/tests/test_mode.py` (Create) | current_mode 6 cases | TDD |
| `web-ai/services/task-watcher/tests/__init__.py` (Create) | 빈 marker | pkg |
| `web-ai/services/docker-compose.yml` | task-watcher service 추가 (port 18713) | compose |
### Phase 3 — 안내 문서
| 파일 | 변경 | 책임 |
|------|------|------|
| `web-ai/services/task-watcher/NSSM_SETUP.md` (Create) | SP-9 NSSM 설치 안내 (ai_trade + wsl_docker + task-watcher) | 박재오 수동 가이드 |
---
## Task 1: NAS stock — `/api/stock/holidays` endpoint + tests
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`
### Step 1: 실패 테스트 작성
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`:
```python
"""GET /api/stock/holidays — task-watcher 휴장일 조회용."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_holidays_returns_list():
r = client.get("/api/stock/holidays")
assert r.status_code == 200
data = r.json()
assert "holidays" in data
assert isinstance(data["holidays"], list)
def test_holidays_entries_are_iso_dates():
r = client.get("/api/stock/holidays")
holidays = r.json()["holidays"]
# 비어 있지 않다면 ISO date 형식 (YYYY-MM-DD)
if holidays:
import datetime as dt
for h in holidays[:5]:
dt.date.fromisoformat(h) # raise 안 하면 통과
```
### Step 2: 테스트 실패 확인
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_holidays_endpoint.py -v`
Expected: FAIL — endpoint 404.
### Step 3: `main.py`에 endpoint 추가
`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`에서 `_HOLIDAYS_PATH` (현재 line 82 부근) 정의를 활용. 적절한 위치(다른 `@app.get` 근처)에 추가:
```python
@app.get("/api/stock/holidays")
def get_holidays():
"""task-watcher가 조회하는 휴장일 목록. holidays.json 그대로 노출 (인증 불필요)."""
import json
try:
with open(_HOLIDAYS_PATH, encoding="utf-8") as f:
data = json.load(f)
# holidays.json 구조가 list이거나 {"holidays": [...]} 또는 {year: [...]} 형태일 수 있음
if isinstance(data, list):
holidays = data
elif isinstance(data, dict) and "holidays" in data:
holidays = data["holidays"]
elif isinstance(data, dict):
# {year: [dates]} → flatten
holidays = [d for v in data.values() if isinstance(v, list) for d in v]
else:
holidays = []
except (OSError, ValueError):
holidays = []
return {"holidays": holidays}
```
**주의:** 작성 전 `holidays.json` 실제 구조를 확인할 것 (`Read web-backend/stock/app/holidays.json`). 위 코드는 list / `{"holidays":[]}` / `{year:[]}` 3가지 형태를 모두 처리하지만, 실제 구조에 맞게 단순화 가능.
### Step 4: 테스트 통과
Run: `python -m pytest app/test_holidays_endpoint.py -v`
Expected: 2 PASS.
### Step 5: 회귀 확인
Run: `python -m pytest app/ -v 2>&1 | tail -5`
Expected: 기존 stock 테스트 모두 통과 + 새 2개.
### Step 6: 커밋
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend
git add stock/app/main.py stock/app/test_holidays_endpoint.py
git commit -m "$(cat <<'EOF'
feat(stock): GET /api/stock/holidays endpoint (SP-10 task-watcher용)
holidays.json 노출. task-watcher가 휴장일 판정에 조회.
인증 불필요 (민감 정보 아님). 주말은 task-watcher가 weekday로 별도 판정.
Plan-B-Infra Phase 1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
## Context
- spec §3: "휴장일 단일 소스 — web-backend/stock/app/holidays.json 정본. NAS stock이 GET /api/stock/holidays로 노출."
- 현재 holidays.json은 `_is_holiday()` 내부 함수에서만 사용, HTTP endpoint 없음 → 신설.
- stock 컨테이너는 이미 deploy.sh BUILD_TARGETS에 등재됨 (신규 lab 아님 — deploy scripts 추가 불필요).
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-backend`
## Report
- Status: DONE | DONE_WITH_CONCERNS | BLOCKED
- holidays.json 실제 구조 (확인 결과)
- 2 PASS + 회귀
- 커밋 SHA
---
## Task 2: Windows task-watcher — mode.py (current_mode + fetch_holidays) + tests
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/__init__.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`
### Step 1: 실패 테스트 작성
`tests/__init__.py`: (빈 파일)
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`:
```python
"""current_mode — 시간대 + 휴장일 판정 (순수 함수)."""
import datetime as dt
from zoneinfo import ZoneInfo
from mode import current_mode
KST = ZoneInfo("Asia/Seoul")
HOLIDAYS = {"2026-05-25"} # 가상 휴장일 (월요일)
def _kst(y, m, d, hh, mm):
return dt.datetime(y, m, d, hh, mm, tzinfo=KST)
def test_weekday_trading_hours_is_trading():
# 2026-05-22 금요일 10:00 — 트레이딩 시간대
assert current_mode(_kst(2026, 5, 22, 10, 0), HOLIDAYS) == "trading"
def test_weekday_before_open_is_free():
# 평일 06:00 — 장 전
assert current_mode(_kst(2026, 5, 22, 6, 0), HOLIDAYS) == "free"
def test_weekday_after_close_is_free():
# 평일 17:00 — 장 마감 후
assert current_mode(_kst(2026, 5, 22, 17, 0), HOLIDAYS) == "free"
def test_weekend_is_free():
# 2026-05-23 토요일 10:00
assert current_mode(_kst(2026, 5, 23, 10, 0), HOLIDAYS) == "free"
def test_holiday_weekday_is_free():
# 2026-05-25 월요일이지만 휴장일 → 트레이딩 시간대라도 free
assert current_mode(_kst(2026, 5, 25, 10, 0), HOLIDAYS) == "free"
def test_trading_boundary_inclusive_start_exclusive_end():
# 07:00 정각 = 트레이딩 시작, 16:30 정각 = 마감 (16:30은 free)
assert current_mode(_kst(2026, 5, 22, 7, 0), HOLIDAYS) == "trading"
assert current_mode(_kst(2026, 5, 22, 16, 29), HOLIDAYS) == "trading"
assert current_mode(_kst(2026, 5, 22, 16, 30), HOLIDAYS) == "free"
```
### Step 2: 테스트 실패 확인
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -m pytest tests/test_mode.py -v`
Expected: FAIL — `mode` 모듈 미존재.
### Step 3: `mode.py` 작성
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`:
```python
"""시간대 + 휴장일 기반 모드 판정 (idle 감지 생략 — 박재오 결정 2026-05-22).
trading: 비휴장 평일 07:0016:30 (장중) → queue:paused SET
free: 그 외 (장 전/후, 주말, 휴장) → queue:paused DEL
"""
from __future__ import annotations
import datetime as dt
import logging
import os
from typing import Set
from zoneinfo import ZoneInfo
import httpx
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
STOCK_BASE_URL = os.getenv("STOCK_BASE_URL", "http://192.168.45.54:18500")
# 트레이딩 윈도우 (HH:MM, KST). .env로 조정 가능.
TRADING_START = os.getenv("TRADING_START", "07:00")
TRADING_END = os.getenv("TRADING_END", "16:30")
def _parse_hhmm(s: str) -> dt.time:
hh, mm = s.split(":")
return dt.time(int(hh), int(mm))
def current_mode(now: dt.datetime, holidays: Set[str]) -> str:
"""now(KST aware) + holidays(ISO date set) → 'trading' | 'free'."""
# 주말 (토=5, 일=6)
if now.weekday() >= 5:
return "free"
# 휴장일
if now.date().isoformat() in holidays:
return "free"
# 트레이딩 윈도우 [start, end)
start = _parse_hhmm(TRADING_START)
end = _parse_hhmm(TRADING_END)
t = now.timetz().replace(tzinfo=None)
if start <= t < end:
return "trading"
return "free"
def fetch_holidays() -> Set[str]:
"""NAS stock /api/stock/holidays 조회. 실패 시 빈 set (안전 — free로 판정)."""
try:
r = httpx.get(f"{STOCK_BASE_URL}/api/stock/holidays", timeout=10.0)
if r.status_code == 200:
return set(r.json().get("holidays", []))
logger.warning("holidays fetch returned %d", r.status_code)
except Exception:
logger.exception("holidays fetch 실패")
return set()
```
### Step 4: 테스트 통과
Run: `python -m pytest tests/test_mode.py -v`
Expected: 6 PASS.
### Step 5: 커밋
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/task-watcher/mode.py services/task-watcher/tests/__init__.py services/task-watcher/tests/test_mode.py
git commit -m "$(cat <<'EOF'
feat(task-watcher): mode.py — 시간대+휴장일 판정 (SP-10)
current_mode(now, holidays): 비휴장 평일 07:0016:30 → trading, 그 외 free.
fetch_holidays(): NAS /api/stock/holidays 조회 (실패 시 빈 set = free 안전).
TRADING_START/END env로 윈도우 조정. idle 감지 생략 (박재오 결정).
6 tests (평일 장중/장전/장후, 주말, 휴장, 경계).
Plan-B-Infra Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
## Context
- KST 시각 + holidays set → trading/free 순수 함수. 테스트 용이 (now를 인자로).
- holidays는 fetch_holidays()로 NAS 조회. 매 loop마다 호출하면 부하 — watcher.py에서 캐싱 (Task 3).
- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-ai`
## Report
- Status / 6 PASS / 커밋 SHA
---
## Task 3: Windows task-watcher — watcher.py (Redis 토글 loop)
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`
### Step 1: `watcher.py` 작성
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`:
```python
"""30초마다 current_mode 판정 → queue:paused 토글.
trading → SET queue:paused 1 EX 600 (10분 TTL — watcher 죽어도 자동 해제)
free → DEL queue:paused
holidays는 1시간마다 refresh (매 loop fetch 부하 회피).
"""
from __future__ import annotations
import asyncio
import datetime as dt
import logging
import os
from zoneinfo import ZoneInfo
import redis.asyncio as aioredis
from mode import current_mode, fetch_holidays, KST
logger = logging.getLogger(__name__)
REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379")
PAUSED_KEY = "queue:paused"
LOOP_INTERVAL = 30 # 초
HOLIDAYS_REFRESH = 3600 # 1시간
PAUSED_TTL = 600 # 10분 (watcher 죽어도 자동 해제)
async def watcher_loop():
redis = aioredis.from_url(REDIS_URL, decode_responses=False)
holidays = fetch_holidays()
last_holiday_refresh = dt.datetime.now(KST)
last_mode = None
logger.info("task-watcher started (trading window 토글)")
while True:
try:
now = dt.datetime.now(KST)
# holidays 주기적 refresh
if (now - last_holiday_refresh).total_seconds() >= HOLIDAYS_REFRESH:
holidays = fetch_holidays()
last_holiday_refresh = now
mode = current_mode(now, holidays)
if mode == "trading":
await redis.set(PAUSED_KEY, b"1", ex=PAUSED_TTL)
else:
await redis.delete(PAUSED_KEY)
if mode != last_mode:
logger.info("mode 전환: %s%s (paused=%s)", last_mode, mode, mode == "trading")
last_mode = mode
await asyncio.sleep(LOOP_INTERVAL)
except asyncio.CancelledError:
logger.info("watcher_loop cancelled")
raise
except Exception:
logger.exception("watcher_loop iteration 실패, 30초 후 재시도")
await asyncio.sleep(LOOP_INTERVAL)
```
### Step 2: 임포트 smoke
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -c "from watcher import watcher_loop; print('OK')"`
Expected: `OK`.
### Step 3: 커밋
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/task-watcher/watcher.py
git commit -m "$(cat <<'EOF'
feat(task-watcher): watcher.py — 30초 loop + queue:paused 토글 (SP-10)
trading → SET queue:paused 1 EX 600 / free → DEL.
holidays 1시간마다 refresh. PAUSED_TTL 600s (watcher 죽어도 자동 해제 — 안전).
mode 전환 시에만 로그.
Plan-B-Infra Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
## Context
- `PAUSED_TTL=600`이 핵심 안전장치: task-watcher가 죽어도 10분 후 자동으로 paused 해제 → 큐 영구 정지 방지.
- holidays는 1시간 캐싱 (매 30초 fetch 안 함).
- render worker들(insta/music/video)이 이미 `queue:paused` 체크 로직 보유 (Plan-B-Insta/Music/Video).
## Report
- Status / smoke 결과 / 커밋 SHA
---
## Task 4: Windows task-watcher — main.py + Dockerfile + requirements + .env.example
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/main.py`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/Dockerfile`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/requirements.txt`
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/.env.example`
### Step 1: `requirements.txt`
```
fastapi==0.115.6
uvicorn[standard]==0.34.0
redis>=5.0
httpx>=0.27
pytest>=8.0
```
### Step 2: `Dockerfile`
```dockerfile
FROM python:3.12-slim-bookworm
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
```
(tzdata 추가 — zoneinfo Asia/Seoul 사용.)
### Step 3: `.env.example`
```
# Plan-B-Infra — task-watcher
# NAS Redis
REDIS_URL=redis://192.168.45.54:6379
# NAS stock holidays endpoint
STOCK_BASE_URL=http://192.168.45.54:18500
# 트레이딩 윈도우 (KST, HH:MM) — 이 시간대에만 queue:paused
TRADING_START=07:00
TRADING_END=16:30
```
### Step 4: `main.py`
```python
"""task-watcher FastAPI entry — health + lifespan (watcher loop spawn)."""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
import watcher
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
watcher_task = asyncio.create_task(watcher.watcher_loop())
logger.info("task-watcher lifespan 시작")
try:
yield
finally:
watcher_task.cancel()
try:
await watcher_task
except asyncio.CancelledError:
pass
logger.info("task-watcher lifespan 종료")
app = FastAPI(lifespan=lifespan)
@app.get("/health")
def health():
return {"ok": True, "service": "task-watcher"}
```
### Step 5: smoke + 회귀
Run:
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher
python -c "from main import app; print(len(app.routes))"
python -m pytest tests/ -v 2>&1 | tail -5
```
Expected: 숫자 출력 + 6 PASS (test_mode).
### Step 6: 커밋
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/task-watcher/main.py services/task-watcher/Dockerfile services/task-watcher/requirements.txt services/task-watcher/.env.example
git commit -m "$(cat <<'EOF'
feat(task-watcher): main.py + Dockerfile + requirements + env (SP-10)
FastAPI lifespan에서 watcher_loop 스폰. /health. tzdata(zoneinfo Asia/Seoul).
.env: REDIS_URL, STOCK_BASE_URL, TRADING_START/END.
Plan-B-Infra Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
## Report
- Status / routes 개수 / 6 PASS / 커밋 SHA
---
## Task 5: Windows services/docker-compose — task-watcher entry
**Files:**
- Modify: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`
### Step 1: video-render service 다음에 task-watcher 추가
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`에 추가:
```yaml
task-watcher:
build:
context: ./task-watcher
container_name: task-watcher
restart: unless-stopped
ports:
- "18713:8000"
environment:
- TZ=Asia/Seoul
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
- STOCK_BASE_URL=${STOCK_BASE_URL:-http://192.168.45.54:18500}
- TRADING_START=${TRADING_START:-07:00}
- TRADING_END=${TRADING_END:-16:30}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 60s
timeout: 5s
retries: 3
```
### Step 2: YAML 검증
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services && python -c "import yaml; yaml.safe_load(open('docker-compose.yml')); print('valid YAML')"`
Expected: `valid YAML`.
### Step 3: 커밋 + push
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/docker-compose.yml
git commit -m "$(cat <<'EOF'
feat(task-watcher): services/docker-compose entry (SP-10)
port 18713, REDIS_URL/STOCK_BASE_URL/TRADING_START/END env.
insta/music/video-render와 같은 services 묶음. outbound only.
Plan-B-Infra Phase 2 완료 — 박재오 빌드 대기.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push 2>&1 # 자격증명 실패 시 박재오 수동 push
```
## Report
- Status / YAML 검증 / 커밋 SHA / push 결과
---
## Task 6: NSSM 안내 문서 (SP-9)
**Files:**
- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`
SP-9는 박재오 Windows 머신에서 NSSM 수동 설치. controller는 정확한 명령 + 안내 문서 작성. (코드 아님 — 안내 문서.)
### Step 1: `NSSM_SETUP.md` 작성
`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`:
```markdown
# NSSM 자동 시작 설정 (SP-9)
Windows AI 머신 부팅 시 ai_trade(트레이딩) + WSL2 Docker(render workers + task-watcher) 자동 시작.
## 1. NSSM 다운로드
https://nssm.cc/download → nssm-2.24.zip → `C:\nssm\nssm.exe` 배치 (또는 PATH 등록).
## 2. ai_trade (Native Python, HIGH priority)
⚠️ spec의 signal_v2는 ai_trade로 rename됨. 경로/포트 확인.
```powershell
# 관리자 PowerShell
C:\nssm\nssm.exe install ai_trade "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001"
C:\nssm\nssm.exe set ai_trade AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade"
C:\nssm\nssm.exe set ai_trade Priority HIGH_PRIORITY_CLASS
C:\nssm\nssm.exe set ai_trade Start SERVICE_AUTO_START
C:\nssm\nssm.exe set ai_trade AppStdout "C:\Users\jaeoh\nssm-logs\ai_trade.log"
C:\nssm\nssm.exe set ai_trade AppStderr "C:\Users\jaeoh\nssm-logs\ai_trade.log"
```
(ai_trade의 실제 진입점이 main:app + port 8001인지 확인. 다르면 조정.)
## 3. WSL2 Docker (NORMAL priority — render workers + task-watcher)
```powershell
C:\nssm\nssm.exe install wsl_docker "C:\Windows\System32\wsl.exe" "-d Ubuntu-24.04 -- sh -c 'sudo service docker start && cd /workspace/web-ai/services && docker compose up -d'"
C:\nssm\nssm.exe set wsl_docker Priority NORMAL_PRIORITY_CLASS
C:\nssm\nssm.exe set wsl_docker Start SERVICE_AUTO_START
C:\nssm\nssm.exe set wsl_docker AppStdout "C:\Users\jaeoh\nssm-logs\wsl_docker.log"
```
⚠️ 변경점: Ubuntu-22.04 → **Ubuntu-24.04**, web-ai-services → **web-ai/services**. WSL 경로는 `/mnt/c/...` 또는 박재오 WSL 마운트 기준 (`/workspace`가 web-ai에 매핑되어 있으면 그대로).
`sudo service docker start`가 비밀번호 요구하면 sudoers에 NOPASSWD 추가:
```bash
# WSL2 안
echo "$USER ALL=(ALL) NOPASSWD: /usr/sbin/service docker start" | sudo tee /etc/sudoers.d/docker-start
```
## 4. 서비스 시작 + 확인
```powershell
C:\nssm\nssm.exe start ai_trade
C:\nssm\nssm.exe start wsl_docker
# 상태 확인
C:\nssm\nssm.exe status ai_trade
C:\nssm\nssm.exe status wsl_docker
sc query ai_trade
```
## 5. 검증
```powershell
# ai_trade
curl http://localhost:8001/health # 또는 ai_trade의 실제 health endpoint
# WSL2 docker 컨테이너 (재부팅 후 자동 시작 확인)
wsl -d Ubuntu-24.04 -- docker ps
# insta-render, music-render, video-render, task-watcher 4개 Up 확인
```
## 6. 재부팅 테스트
Windows 재부팅 → 로그인 → 수동 조작 없이:
- ai_trade 서비스 자동 시작 (HIGH priority)
- WSL2 + Docker + 4 컨테이너 자동 시작 (NORMAL priority)
- task-watcher가 trading window에 queue:paused 토글 시작
## task-watcher 동작 확인
```bash
# WSL2
docker logs task-watcher --tail 20
# 기대: "task-watcher started" + mode 전환 로그 (trading/free)
# Redis 큐 상태 (NAS 또는 LAN)
docker exec redis redis-cli GET queue:paused
# 트레이딩 시간대(평일 07:00-16:30): "1"
# 그 외: (nil)
```
```
### Step 2: 커밋 + push
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ai
git add services/task-watcher/NSSM_SETUP.md
git commit -m "$(cat <<'EOF'
docs(task-watcher): NSSM_SETUP.md — SP-9 자동 시작 안내
ai_trade(HIGH, native python :8001) + wsl_docker(NORMAL, WSL2 Ubuntu-24.04
docker compose up). spec의 signal_v2→ai_trade, 22.04→24.04, web-ai-services
→web-ai/services 정정. sudoers NOPASSWD + 재부팅 검증 절차.
Plan-B-Infra Phase 3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
git push 2>&1
```
## Report
- Status / 커밋 SHA / push 결과
---
## Task 7: 박재오 빌드 + task-watcher 검증
**Files:** (변경 없음 — 박재오 측 작업 + 검증)
### Step 1: web-backend push (Task 1 holidays endpoint)
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git push
```
→ NAS deployer가 stock 컨테이너 rebuild. `/api/stock/holidays` 활성화.
### Step 2: 박재오 NAS 측 holidays endpoint 확인
```bash
curl https://gahusb.synology.me/api/stock/holidays
# → {"holidays": ["2026-01-01", ...]}
```
### Step 3: 박재오 Windows 측 task-watcher 빌드
```bash
cd /workspace/web-ai && git pull
cd /workspace/web-ai/services
docker compose build task-watcher
docker compose up -d task-watcher
docker logs task-watcher --tail 20
# 기대: "task-watcher lifespan 시작" + "task-watcher started" + mode 로그
curl -m 3 http://localhost:18713/health
```
### Step 4: 시간대 토글 검증
현재 KST 시각 기준:
```bash
# 트레이딩 시간대(평일 07:00-16:30)면 paused=1, 아니면 nil
docker exec task-watcher python -c "import datetime as dt; from zoneinfo import ZoneInfo; from mode import current_mode, fetch_holidays; print('now mode:', current_mode(dt.datetime.now(ZoneInfo('Asia/Seoul')), fetch_holidays()))"
# Redis 확인 (NAS 또는 LAN)
ssh nas
docker exec redis redis-cli GET queue:paused
```
기대:
- 평일 07:00-16:30 (비휴장): `current_mode` = "trading", `queue:paused` = "1"
- 그 외: "free", (nil)
### Step 5: render worker가 paused 존중하는지 (선택)
트레이딩 시간대에 video 생성 요청 → worker가 BLPOP 전 paused 확인 → 10초 대기 반복 (처리 보류). free 시간대 되면 자동 처리. (이미 Plan-B-Insta/Music/Video worker에 `queue:paused` 체크 로직 있음.)
### Step 6: 메모리 기록
`reference_plan_b_infra_complete.md` 작성 + MEMORY.md 인덱스 추가 (Task 8에서).
## Report
- holidays endpoint 응답
- task-watcher health + mode
- queue:paused 토글 확인
---
## Task 8: 메모리 기록 + 최종 정리
**Files:**
- Create: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/reference_plan_b_infra_complete.md`
- Modify: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/MEMORY.md`
### Step 1: `reference_plan_b_infra_complete.md`
```markdown
---
name: plan-b-infra-complete
description: 2026-05-22 Plan-B-Infra — NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). spec 12 SP 전부 완료
metadata:
type: reference
---
Plan-B-Infra 2026-05-22 완료. spec §10 SP-9 + SP-10. 이로써 NAS↔Windows 분산 아키텍처 spec의 12 SP 전부 완료.
## SP-10 task-watcher (구현)
- web-ai/services/task-watcher/ WSL2 컨테이너 (port 18713)
- 30초 loop: current_mode(KST + holidays) → queue:paused 토글
- trading(비휴장 평일 07:00-16:30) → SET queue:paused 1 EX 600 / free → DEL
- **idle/게임 감지 생략** (박재오 결정 2026-05-22) — WSL2 컨테이너는 Win32 input API 접근 불가. 시간대만으로 판정.
- PAUSED_TTL 600s = watcher 죽어도 10분 후 자동 해제 (큐 영구정지 방지 안전장치)
- holidays는 NAS GET /api/stock/holidays (신설) 1시간 캐싱
- TRADING_START/END env로 윈도우 조정
## SP-9 NSSM (박재오 수동)
- NSSM_SETUP.md 안내 문서. ai_trade(HIGH, native :8001) + wsl_docker(NORMAL, WSL2 docker compose up)
- spec 정정: signal_v2→ai_trade, Ubuntu-22.04→24.04, web-ai-services→web-ai/services
## NAS holidays endpoint (신설)
- GET /api/stock/holidays — holidays.json 노출. 기존엔 _is_holiday() 내부 함수만 있었음.
## 다음
- frontend video/music/insta UI (backend gateway만 완료, UI 별도)
- FOLLOW-UP B: -lab suffix 제거
```
### Step 2: MEMORY.md 인덱스 추가
`reference_plan_b_video_complete.md` 항목 뒤:
```markdown
- [Plan-B-Infra 완료](reference_plan_b_infra_complete.md) — 2026-05-22 NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). idle 감지 생략. spec 12 SP 전부 완료
```
### Step 3: 양쪽 push 확인
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-backend && git status && git log --oneline -3
cd C:/Users/jaeoh/Desktop/workspace/web-ai && git status && git log --oneline -5
```
### Step 4: 박재오 보고
- spec 12 SP 전부 완료
- task-watcher 시간대 토글 동작
- NSSM은 박재오 수동 (NSSM_SETUP.md 참고)
## Report
- 메모리 파일 생성
- push 상태
- 최종 보고
---
## Self-Review
**1. Spec coverage**
| Spec 요구사항 | 구현 위치 | 상태 |
|--------------|-----------|------|
| SP-9 §10: NSSM ai_trade(HIGH) + wsl_docker(NORMAL) 자동 시작 | Task 6 NSSM_SETUP.md | ✓ (박재오 수동 + 안내) |
| SP-10 §10: task-watcher 컨테이너 30초 loop | Task 3 watcher.py | ✓ |
| SP-10 §10: current_mode (시간대 + holidays + KST) | Task 2 mode.py | ✓ |
| SP-10 §10: queue:paused 토글 (free→DEL, trading→SET) | Task 3 | ✓ |
| §3 휴장일 단일 소스 GET /api/stock/holidays | Task 1 | ✓ (신설) |
| 박재오 결정: idle 감지 생략 — 시간대만 | Task 2 (is_user_active 제거) | ✓ |
| §3 트레이딩 모드 = 평일 비휴장 07:00-16:30 | Task 2 TRADING_START/END | ✓ |
**spec 대비 의도적 변경 (박재오 승인):**
- idle/게임 감지 생략 — spec §10 SP-10의 `is_user_active()` 제거. trading 시간대면 무조건 paused.
- spec §3의 🟡 일반(16:30-23:30) 모드 → free로 통합 (트레이딩 시간대만 paused).
**2. Placeholder scan:** 통과. NSSM_SETUP.md의 "(확인)" 표기는 박재오 환경 검증 안내 (placeholder 아님).
**3. Type consistency:**
- `current_mode(now: dt.datetime, holidays: Set[str]) -> str` — Task 2 정의, Task 3 watcher_loop + Task 7 검증 호출 일관
- `fetch_holidays() -> Set[str]` — Task 2 정의, Task 3 호출
- mode 값 `"trading"` | `"free"` 2개 — Task 2/3/7 일관
- `PAUSED_KEY = "queue:paused"` — Task 3, render workers의 PAUSED_KEY와 동일 문자열 (Plan-B-Insta/Music/Video)
**4. 함정 사전 인지:**
- task-watcher는 services/ 컨테이너 (NAS lab 아님) → deploy.sh 6위치 등재 불필요
- holidays endpoint(stock)는 기존 컨테이너 수정 → deploy.sh 등재 이미 됨
- services/.env: TRADING_START/END는 task-watcher 전용 → 다른 서비스와 충돌 없음 (compose default로 분기)
- PAUSED_TTL로 watcher 장애 시 큐 영구정지 방지
플랜 완성. 모든 검토 통과.
---
## 부록 — 알려진 결정 + follow-up
**박재오 결정 (2026-05-22):** idle/게임 감지 생략. 시간대만으로 큐 토글. 박재오 7결정 #1의 "Windows 작업 감지 큐 정지"는 부분 포기 (시간대 기반만). 향후 idle 감지 필요 시 Windows native idle-reporter(GetLastInputInfo) → Redis user:last_input_ts 기록 → task-watcher가 읽는 hybrid로 확장 가능.
**spec 12 SP 완료 후 follow-up:**
- frontend `/video` `/music` UI (backend gateway만 완료)
- FOLLOW-UP B: `-lab` suffix 일괄 제거
- GCS lifecycle (Veo Vertex 미사용으로 무관 — Gemini API는 GCS 안 씀)
- Sora 2 alternative (2026-09-24 deprecated 대비)

View File

@@ -0,0 +1,419 @@
# Lotto Weight Evolver — 자율 학습 루프 설계 (v2)
- **상태**: Draft (사용자 리뷰 대기)
- **작성일**: 2026-05-22
- **대상 컨테이너**: lotto (lotto-lab) + agent-office (텔레그램 보고)
- **선행 작업**: v1 LottoAgent 능동성 확장 (2026-05-20 배포)
- **목표**: 5종 시뮬 점수 가중치를 매주 6가지로 변형 시도 → 토요일 회고 → winner 가중치를 다음 주 base로 적용 → 무한 반복 자가 학습 루프
---
## 1. 문제 정의
현재 `analyzer.score_combination()`은 5종 점수(`score_frequency`, `score_fingerprint`, `score_gap`, `score_cooccur`, `score_diversity`)를 **균등 합산**으로 `score_total`을 계산한다. 어떤 메트릭이 실제 추첨 결과와 더 잘 상관되는지에 대한 학습 없이 가중치가 고정.
또한 `purchase_history` 기반 `strategy_evolver`**사용자가 실제 구매한 번호만** 학습 시그널로 사용. 사람이 안 사면 학습 안 됨.
사용자 요구: 에이전트가 사람 없이도 **매일 다른 가중치로 시뮬레이션 → 번호 시도 → 토요일 추첨 후 best 가중치 식별 → 다음주 base 갱신**의 무한 학습 루프.
## 2. 의사결정 요약
| 결정 사항 | 선택 | 비고 |
|---|---|---|
| 학습 대상 | 시뮬 점수 5종 가중치 (`W = [w_freq, w_finger, w_gap, w_cooccur, w_diversity]`) | 메타 전략 가중치는 strategy_evolver가 별도 학습 (v2에서 손대지 않음) |
| 탐험 전략 | 현재 base 주변 4개 perturbation + Dirichlet 무작위 2개 | 매주 월요일 6개 후보 |
| 일일 시도량 | N = 5 세트/일 × 6일 = 30 세트/주 | 통계적 의미 + 비용 균형 |
| 평가 시그널 | strategy_evolver의 `RANK_BONUS` + `correct/6` | 기존 패턴 재사용으로 일관성 |
| Base 적용 강도 | Hybrid — winner_max_correct ≥ 4면 교체, =3이면 EMA blend (0.3), ≤2면 유지 | 노이즈에 base가 헤매지 않도록 보호 |
| v1과의 결합 | W가 `analyzer.score_combination`에 반영 → best_picks 점수 자동 영향 → v1 시그널 자동 cascade | 별도 통합 코드 없음 |
| strategy_evolver와의 상호작용 | strategy_evolver는 `score_total`을 그대로 입력으로 사용 → W 변경 시 입력 분포가 함께 변함. **의도된 간접 영향** | v3에서 메타 가중치도 함께 학습할 때 명시적으로 분리 검토 |
| 자동 구매 | v2 비포함 | 사람 결정 영역 — purchase_history는 사람이 등록 |
## 3. 아키텍처
### 3.1 컴포넌트 다이어그램
```
┌─────────────────────────────────────────────────────────────┐
│ lotto-lab (자율 학습 루프 추가) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ weight_evolver.py (신규) │ │
│ │ • generate_weekly_candidates() ← 월 09:00 │ │
│ │ • apply_today_weight() ← 매일 09:00 │ │
│ │ • evaluate_weekly() ← 토 22:00 │ │
│ │ • update_base() ← evaluate 안에서 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ analyzer.score_combination(numbers, cache, │ │
│ │ weights=None) 확장 │ │
│ │ • weights=None → 균등 합산 (기존 호환) │ │
│ │ • weights=[..] → 가중 합산 │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ lotto.db 신규 테이블 3개 │
│ • weight_trials (주별 6일치 후보 가중치) │
│ • auto_picks (매일 N=5 시도 번호 + 채점 결과) │
│ • weight_base_history (base 변경 이력) │
│ │
│ 기존 시뮬 cron (00/04/08/12/16/20:05) — 변경 없음. │
│ 단 best_picks 재계산 시 활성 W를 읽어 적용. │
└─────────────────────────────────────────────────────────────┘
↓ (HTTP)
┌─────────────────────────────────────────────────────────────┐
│ agent-office │
│ │
│ cron 신규 1종: lotto_evolution_weekly (토 22:15) │
│ LottoAgent.run_weekly_evolution_report() (신규) │
│ notifiers/telegram_lotto.send_evolution_report() (신규) │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 책임 경계
- **lotto-lab**: 가중치 생성·적용·평가·base 갱신 + DB CRUD + API. 시그널/알림 책임 없음.
- **agent-office**: 토요일 22:15 lotto-lab API 폴링 → 텔레그램 보고 1통.
- **v1 (signals layer)**: 변경 없음. W 변경의 효과는 best_picks 분포 변화로 자동 흡수.
- **strategy_evolver (메타 가중치 5종)**: 그대로 둠.
## 4. 가중치 진화 알고리즘
### 4.1 Weight Vector
```
W = [w_freq, w_finger, w_gap, w_cooccur, w_diversity]
제약: w_i ≥ 0.05, sum(W) = 1.0
```
(MIN_WEIGHT=0.05는 한 메트릭이 죽지 않도록 보호. strategy_evolver의 MIN_WEIGHT 패턴.)
### 4.2 주간 6개 후보 생성
`generate_weekly_candidates()` — 매주 월요일 09:00 KST.
```python
W_base = get_current_base() # weight_base_history 최신 row, 없으면 [0.2]*5
# 4개 Local Perturbation
for i in range(4):
noise = np.random.normal(0, 0.05, size=5)
W_i = W_base + noise
W_i = clamp(W_i, min=0.05)
W_i = W_i / W_i.sum()
save_trial(week_start, day=i, W_i, source='perturb', base=W_base)
# 2개 Dirichlet 탐험
for i in range(4, 6):
W_i = np.random.dirichlet([2.0]*5)
W_i = clamp(W_i, min=0.05)
W_i = W_i / W_i.sum()
save_trial(week_start, day=i, W_i, source='dirichlet', base=W_base)
```
- `σ=0.05` 정규분포: 각 메트릭 ±10%p 안쪽 변동
- `α=2.0` Dirichlet: 균등 분포에 약간 치우치게, 극단 가중치도 포함
### 4.3 일일 W 적용
`apply_today_weight()` — 매일 09:00 KST.
```python
W_today = get_trial(week_start, day_of_week=today)
set_active_weight(W_today) # 메모리 캐시 or DB row (W_active 테이블 또는 file)
generate_n_picks(N=5, weight=W_today) # auto_picks에 5세트 저장
```
같은 W로 그날 기존 시뮬 cron (4시간마다 6회) best_picks 재계산.
### 4.4 토요일 회고
`evaluate_weekly()` — 매주 토요일 22:00 KST (추첨 20:35 KST + sync 21:10 → 22:00 안전).
```python
winning_numbers = get_latest_draw().numbers # 1224, 1225, ...
trials = get_trials(week_start) # 6 trials
scores_per_day = []
for trial in trials:
picks = get_auto_picks(trial.id) # N=5
day_score = mean(
calc_pick_score(p.numbers, winning_numbers) for p in picks
)
max_correct = max(
count_match(p.numbers, winning_numbers) for p in picks
)
update_pick_grades(picks, winning_numbers) # auto_picks 채점 결과 저장
scores_per_day.append({
"trial_id": trial.id,
"day": trial.day_of_week,
"weight": trial.weight,
"score": day_score,
"max_correct": max_correct,
})
winner = max(scores_per_day, key=lambda s: s.score)
update_base(winner)
```
**점수 함수** (strategy_evolver `calc_draw_score` 패턴, 단순화):
v2에서는 보너스 번호를 평가에 포함하지 않음 → 5개 일치를 2등/3등으로 구분 불가. 따라서 보너스 무시한 단순 매핑:
```python
# correct → rank 매핑 (보너스 제외)
RANK_BY_CORRECT = {
6: 1, # 1등
5: 3, # 3등 (보너스 평가 안 함 → 2등 표시 X)
4: 4,
3: 5,
}
RANK_BONUS = {1: 1.0, 2: 0.8, 3: 0.6, 4: 0.3, 5: 0.1}
def calc_pick_score(pick_numbers, winning_numbers):
correct = count_match(pick_numbers, winning_numbers[:6])
base = correct / 6.0
rank = RANK_BY_CORRECT.get(correct)
bonus = RANK_BONUS.get(rank, 0)
return base + bonus
```
(rank=2의 보너스 0.8은 매핑되지 않으므로 v2 점수에 등장하지 않음. v3에서 보너스 번호 평가 도입 시 활성화.)
### 4.5 Base 갱신 규칙 (Hybrid)
```python
if winner.max_correct >= 4:
W_base_next = winner.weight
reason = "winner_4plus"
elif winner.max_correct == 3:
W_base_next = 0.3 * winner.weight + 0.7 * W_base_current
reason = "ema_blend"
else:
W_base_next = W_base_current
reason = "unchanged"
save_to_weight_base_history(W_base_next, reason, winner)
```
성과가 약할 때 base를 그대로 두는 게 핵심 — base가 노이즈에 따라 헤매지 않음.
### 4.6 Cold start (첫 주)
`weight_base_history`가 비어있으면 `W_base = [0.2]*5` (균등) 가정. 첫 주는 4 perturbation이 모두 균등 주변, 2 Dirichlet 탐험.
## 5. 데이터 모델
### 5.1 weight_trials
```sql
CREATE TABLE IF NOT EXISTS weight_trials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL, -- 'YYYY-MM-DD' (해당 주 월요일)
day_of_week INTEGER NOT NULL, -- 0=월 .. 5=토
weight_json TEXT NOT NULL, -- '[0.18, 0.22, ...]'
source TEXT NOT NULL, -- 'perturb' | 'dirichlet'
base_at_gen TEXT, -- 생성 시점 W_base (참조용)
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(week_start, day_of_week)
);
CREATE INDEX idx_wt_week ON weight_trials(week_start, day_of_week);
```
### 5.2 auto_picks
```sql
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, -- 1..5
numbers TEXT NOT NULL, -- JSON 정렬 6개
meta_score REAL, -- 활성 W로 계산한 score_total
correct INTEGER, -- 채점 후 채워짐
rank INTEGER, -- 1..5 또는 NULL
graded_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
UNIQUE(trial_id, pick_no)
);
CREATE INDEX idx_ap_trial ON auto_picks(trial_id);
CREATE INDEX idx_ap_graded ON auto_picks(graded_at);
```
### 5.3 weight_base_history
```sql
CREATE TABLE IF NOT EXISTS weight_base_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
effective_from TEXT NOT NULL, -- 'YYYY-MM-DD' (적용 시작 월요일)
weight_json TEXT NOT NULL,
source_trial_id INTEGER REFERENCES weight_trials(id), -- NULL=cold start
update_reason TEXT, -- 'winner_4plus' | 'ema_blend' | 'unchanged' | 'cold_start'
winner_score REAL,
winner_max_correct INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
```
마이그레이션: `lotto/app/db.py``init_db()``CREATE TABLE IF NOT EXISTS` 추가만으로 idempotent. 기존 테이블 영향 없음.
## 6. analyzer.score_combination 시그니처 확장
```python
# 기존
def score_combination(numbers, cache) -> Dict[str, float]:
...
return {
"score_frequency": ...,
"score_fingerprint": ...,
"score_gap": ...,
"score_cooccur": ...,
"score_diversity": ...,
"score_total": sum(5 scores) # 균등 합산
}
# 변경
def score_combination(numbers, cache, weights: Optional[List[float]] = None) -> Dict[str, float]:
...
scores = [s_freq, s_finger, s_gap, s_cooccur, s_diversity]
if weights is None:
total = sum(scores)
else:
total = sum(s * w for s, w in zip(scores, weights))
return {
"score_frequency": ...,
...
"score_total": total
}
```
- 기본값 None → 기존 호출 호환 (변경 없는 효과)
- 시뮬 cron / smart_recommendation 등은 `get_active_weight()` 결과 전달
- 활성 W가 없으면 (cold start 이전) None 그대로 → 균등 합산 폴백
### 6.1 활성 W 조회 (`get_active_weight()`)
별도 캐시 테이블 없이 `weight_trials`에서 오늘 요일 row 직접 조회:
```python
def get_active_weight() -> Optional[List[float]]:
today = datetime.now(KST).date()
week_start = today - timedelta(days=today.weekday()) # 이번주 월요일
day_of_week = today.weekday() # 0=월, 6=일
if day_of_week == 6: # 일요일은 trial 없음 → 직전 토요일 W 유지
day_of_week = 5
row = db.get_weight_trial(week_start.isoformat(), day_of_week)
return json.loads(row["weight_json"]) if row else None
```
- 컨테이너 재시작·timezone 변화에 영향 없음 (DB 진실 기준)
- 일요일(6)은 토요일 W를 그대로 사용 (회고 cron 22:00 전까지)
- 첫 주 월요일 generate가 안 끝났을 때만 None 반환 → 균등 폴백
## 7. API 추가 (lotto-lab)
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | `/api/lotto/evolver/status` | 현재 base + 이번주 6 trials + 진행 상황 |
| GET | `/api/lotto/evolver/history?weeks=12` | 주별 winner + base 변경 이력 |
| GET | `/api/lotto/evolver/trials/{week_start}` | 특정 주 trials + 채점 결과 |
| POST | `/api/lotto/evolver/generate-now` | 수동 트리거 (다음 월요일 후보 생성) |
| POST | `/api/lotto/evolver/evaluate-now` | 수동 채점 (디버그) |
## 8. 스케줄러 cron (lotto-lab)
```python
scheduler.add_job(generate_weekly_candidates, "cron", day_of_week="mon", hour=9, minute=0, id="weight_evolver_weekly")
scheduler.add_job(apply_today_weight, "cron", hour=9, minute=0, id="weight_evolver_daily")
scheduler.add_job(evaluate_weekly, "cron", day_of_week="sat", hour=22, minute=0, id="weight_evolver_eval")
```
순서 보장: 월요일 09:00에 generate가 먼저 row 저장 후 commit, 그 다음 같은 시각 apply가 그 row 읽음. APScheduler가 동일 시간 job 직렬 실행 보장하지 않으므로 **월요일에 generate 함수 마지막에 inline으로 apply_today_weight 호출** — race 제거.
## 9. agent-office 통합 (텔레그램 주간 보고)
### 9.1 cron 추가
```python
scheduler.add_job(_run_lotto_weekly_evolution_report, "cron", day_of_week="sat", hour=22, minute=15, id="lotto_evolution_weekly")
```
### 9.2 LottoAgent.run_weekly_evolution_report (신규)
```python
async def run_weekly_evolution_report(self) -> dict:
from ..service_proxy import lotto_evolver_status
from ..notifiers.telegram_lotto import send_evolution_report
status = await lotto_evolver_status()
await send_evolution_report(status)
return {"ok": True, **status}
```
### 9.3 텔레그램 메시지 폼
```
🧬 로또 학습 주간 리포트 (1225회차)
이번주 시도: 6일 × 5세트 = 30번
🏆 Winner: 목요일 (W_4)
W = [freq 0.18, finger 0.32, gap 0.20, cooccur 0.22, divers 0.08]
최고 적중: 4개 일치 (1세트)
평균 점수: 0.42 (vs 다른 요일 0.18~0.30)
📊 다음주 base 변경:
freq 0.20 → 0.18 (-)
finger 0.20 → 0.32 (+)
gap 0.20 → 0.20 (=)
cooccur 0.20 → 0.22 (+)
divers 0.20 → 0.08 (--)
reason: winner_4plus (4개 이상 일치 → base 교체)
[웹에서 차트 보기] (/lotto/evolver)
```
## 10. v1 시그널과의 연동 (자동 cascade)
별도 코드 추가 없음. 활성 W가 `analyzer.score_combination`에 반영되면:
1. 매 4시간 시뮬 cron이 새 W로 best_picks 갱신
2. score 분포 자체가 변하므로 v1의 `sim_consensus_score`가 자동으로 새 분포 평가
3. W 변경 직후 outlier 패턴이 나오면 자연스럽게 sim_signal urgent fire
→ 사용자는 두 종류 텔레그램 받음:
- **🧬 토 22:15 weekly evolution report** (정해진 리듬)
- **🚨 평시 v1 urgent / 📊 v1 digest** (시그널 기반)
## 11. 구현 Phase
| Phase | 범위 | 검증 |
|---|---|---|
| 1 | DB 마이그레이션 + `weight_evolver.py` (순수 함수: generate/evaluate + 점수 함수) + 단위 테스트 | pytest로 perturbation·Dirichlet·점수·base 갱신 룰 검증 |
| 2 | analyzer.score_combination 시그니처 확장 + active weight 캐시 | 기존 시뮬 cron이 새 시그니처로 정상 동작 (regression X) |
| 3 | cron 3종 등록 + API 5종 | 수동 트리거로 generate→apply→evaluate 전체 흐름 확인 |
| 4 | agent-office 통합 (cron + 텔레그램 폼 + 테스트) | 토요일 22:15 자동 발송 확인 |
각 Phase 끝 commit + 자동 배포.
## 12. 비기능 요구
- **백워드 호환**: `analyzer.score_combination` 기본값 None → 기존 호출 그대로 작동
- **장애 격리**: 가중치 적용 실패 시 균등 합산 폴백, evaluate 실패해도 다음 주 base는 직전 값 유지
- **테스트**:
- `weight_evolver` 순수 함수 (clamp, normalize, perturbation, base update rule) — 단위 테스트
- `analyzer.score_combination(weights=...)` — 가중 합산 정확성 테스트
- `evaluate_weekly` mock 추첨번호 시나리오 — base 갱신 분기 3가지 (winner_4plus / ema_blend / unchanged)
- **관측**: `weight_base_history` 테이블로 모든 base 변경 추적 가능 (rollback도 가능)
## 13. 비목표 (Out of scope)
- 메타 전략(combined/simulation/heatmap/manual/custom) 가중치 학습 — strategy_evolver 영역, v3 후속
- 6일 trials의 day-transition에서 이전 W로 계산된 best_picks를 새 W로 재계산하는 처리 — 다음 시뮬 cron에서 자동 덮어씀
- Multi-objective 학습 (적중 + 분포 균등 등 복합 점수)
- 자동 구매 (purchase_history 자동 채움)
- 프론트 `/lotto/evolver` UI — v2 백엔드 완성 후 별도 PR (web-ui repo)
## 14. v3 후속 검토
- Multi-armed bandit (UCB1) — 탐험·활용 균형 더 정교
- 메타 전략 가중치도 함께 학습 (2-layer Bayesian Optimization)
- 가중치 공간을 RL agent로 학습 (policy gradient)
- 자동 구매 후보 픽 (winner W로 1주 N장 자동 발주, 사람 승인 후)

View File

@@ -162,6 +162,17 @@ def get_indices():
"""주요 지표(KOSPI 등) 실시간 크롤링 조회"""
return fetch_major_indices()
@app.get("/api/stock/holidays")
def get_holidays():
"""task-watcher가 조회하는 휴장일 목록. holidays.json(list) 노출 (인증 불필요)."""
try:
with open(_HOLIDAYS_PATH, encoding="utf-8") as f:
data = json.load(f)
holidays = data if isinstance(data, list) else []
except (OSError, ValueError):
holidays = []
return {"holidays": holidays}
@app.post("/api/stock/scrap")
def trigger_scrap():
"""수동 스크랩 트리거"""

View File

@@ -0,0 +1,22 @@
"""GET /api/stock/holidays — task-watcher 휴장일 조회용."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_holidays_returns_list():
r = client.get("/api/stock/holidays")
assert r.status_code == 200
data = r.json()
assert "holidays" in data
assert isinstance(data["holidays"], list)
def test_holidays_entries_are_iso_dates():
r = client.get("/api/stock/holidays")
holidays = r.json()["holidays"]
if holidays:
import datetime as dt
for h in holidays[:5]:
dt.date.fromisoformat(h) # raise 안 하면 통과