feat(v3.2): DailyLedger + RiskGate + news_snapshot + backtest_runner

- DailyLedger: 당일 매수 회계 + 연속 손절 카운터 + 매수 신호 점수 한 객체로 집약 (bot.py 정리)
- RiskGate: 테마당 동시 보유 + 노출 비율 상한 검증 (포트폴리오 레벨)
- news_snapshot: 뉴스 SQLite 영구 저장 + 사후 감성 재검증 인프라
- backtest_runner: 전 종목 KIS 일봉 기반 백테스트 (Sharpe/MDD/Calmar)
- bot.py 274 line 정리 (DailyLedger 분리)
- backtest.py 173 line 재작성 (v3.2 next-bar 체결 + 거래세)
- daily_launcher.py 폐기 (warmup_and_restart 통합)
- .gitignore: .claude/ 제외

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 02:57:26 +09:00
parent 0aebca7ff0
commit 42b91d03cf
12 changed files with 869 additions and 492 deletions

View File

@@ -0,0 +1,130 @@
"""
일일 거래 장부 (DailyLedger) — v3.2
bot.py에 흩어져 있던 당일 상태를 한 객체로 집약:
- 당일 누적 매수금액 (KIS T+2 미차감 보완용)
- 연속 손절 카운터 + 매수 일시중단 타이머
- 미매도 종목의 매수 신호 점수 (앙상블 학습용)
- 일별 스냅샷/주간평가 플래그
날짜가 바뀌면 reset_if_new_day()가 자동 초기화.
순수 객체로 구현 — 외부 I/O 없음 → 단위 테스트 가능.
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta, date as date_cls
from typing import Dict, Optional
@dataclass
class DailyLedger:
# ── 당일 매수 회계 ──
today_buy_total: int = 0
today_buy_date: Optional[date_cls] = None
# ── 연속 손절 / 매수 일시 중단 ──
consecutive_stop_losses: int = 0
buy_paused_until: Optional[datetime] = None
stop_loss_pause_threshold: int = 3
stop_loss_pause_minutes: int = 30
# ── 앙상블 학습용: 미매도 종목의 매수 신호 점수 ──
buy_scores: Dict[str, dict] = field(default_factory=dict)
# ── 일일 플래그 ──
snapshot_taken: bool = False
weekly_eval_sent: bool = False
# ──────────────────────────────────────────────
# 날짜 전환
# ──────────────────────────────────────────────
def reset_if_new_day(self, now: datetime) -> bool:
"""
오늘 날짜 기준으로 상태 초기화. 이미 오늘 자로 초기화됐으면 no-op.
Returns:
True — 실제로 초기화를 수행한 경우
False — 같은 날이라 그대로 둔 경우
"""
today = now.date()
if self.today_buy_date == today:
return False
self.today_buy_total = 0
self.today_buy_date = today
self.buy_scores.clear()
self.snapshot_taken = False
self.weekly_eval_sent = False
# 연속 손절 카운터 / 일시중단 타이머는 날짜 전환 시에만 초기화
self.consecutive_stop_losses = 0
self.buy_paused_until = None
return True
# ──────────────────────────────────────────────
# 매수 / 매도 기록
# ──────────────────────────────────────────────
def record_buy(self, ticker: str, amount: int, scores: dict) -> None:
"""매수 체결 기록. amount는 집행 금액(원), scores는 앙상블 신호."""
self.today_buy_total += int(amount)
self.buy_scores[ticker] = dict(scores)
def pop_buy_scores(self, ticker: str) -> Optional[dict]:
"""매도 체결 시 앙상블 학습을 위해 매수 당시 신호를 반환하고 제거."""
return self.buy_scores.pop(ticker, None)
# ──────────────────────────────────────────────
# 손절 관리
# ──────────────────────────────────────────────
def record_sell_outcome(self, outcome_pct: float, now: datetime) -> bool:
"""
매도 결과를 반영해 연속 손절 카운터 업데이트.
Returns:
True — 임계치 도달 → 매수 일시중단 활성화됨
False — 임계치 미도달
"""
if outcome_pct < 0:
self.consecutive_stop_losses += 1
if self.consecutive_stop_losses >= self.stop_loss_pause_threshold:
self.buy_paused_until = now + timedelta(
minutes=self.stop_loss_pause_minutes
)
return True
else:
self.consecutive_stop_losses = 0
return False
def is_buy_paused(self, now: datetime) -> bool:
"""
매수 일시중단 상태 조회. 만료되면 자동 해제 + 카운터 리셋.
"""
if self.buy_paused_until is None:
return False
if now >= self.buy_paused_until:
self.buy_paused_until = None
self.consecutive_stop_losses = 0
return False
return True
# ──────────────────────────────────────────────
# 예수금 계산 (KIS T+2 보완)
# ──────────────────────────────────────────────
def effective_today_buy(self, kis_today_buy: int) -> int:
"""
KIS API가 반환한 당일 매수금(`thdt_buy_amt`)과
로컬 누적값 중 더 큰 값을 신뢰.
(모의투자는 T+2 미차감으로 인해 과소 보고되는 경우 있음)
"""
return max(int(kis_today_buy or 0), self.today_buy_total)
def available_deposit(self, raw_deposit: int, max_daily_buy_ratio: float,
kis_today_buy: int = 0) -> int:
"""
당일 사용 가능한 예수금 계산.
max_daily_buy = raw_deposit × ratio
avail = min(raw_deposit, max_daily_buy) effective_today_buy
"""
if raw_deposit <= 0:
return 0
max_daily_buy = int(raw_deposit * max_daily_buy_ratio)
used = self.effective_today_buy(kis_today_buy)
return max(0, min(raw_deposit, max_daily_buy) - used)

View File

@@ -0,0 +1,150 @@
"""
포트폴리오 리스크 게이트 (v3.2)
매수 체결 직전 호출되어 포트폴리오 레벨 제약을 검증:
1. 총 보유 종목 수 상한
2. 테마당 동시 보유 종목 수 상한
3. 테마당 노출 금액 비율 상한 (총자산 대비)
기존 매수 필터(예수금, 종목당 상한, 사이클당 매수 수)는 유지하고
이 게이트가 "같은 테마에 집중되는 포지션"을 차단한다.
순수 함수로 구현 — 의존성 없음 → 단위 테스트 가능.
"""
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional
@dataclass
class RiskDecision:
allowed: bool
reason: str = ""
max_allowed_amount: int = 0 # 일부만 허용되는 경우 (테마 노출 상한)
@dataclass
class RiskConfig:
max_total_holdings: int = 7
max_tickers_per_theme: int = 2
max_theme_exposure_ratio: float = 0.40
class PortfolioRiskGate:
"""
사용 예:
gate = PortfolioRiskGate(theme_map, RiskConfig())
decision = gate.evaluate_buy(
ticker="005930",
candidate_amount=3_000_000,
current_holdings=[{"ticker":"000660","eval_amount":2_500_000}, ...],
total_capital=50_000_000,
)
if not decision.allowed: skip
elif decision.max_allowed_amount < candidate_amount: partial buy
"""
def __init__(self, theme_lookup, config: Optional[RiskConfig] = None):
"""
Args:
theme_lookup: callable(ticker:str) -> list[str] (종목→테마 매핑 함수)
혹은 dict 형태도 허용.
config: RiskConfig
"""
if callable(theme_lookup):
self._theme_of = theme_lookup
elif isinstance(theme_lookup, dict):
self._theme_of = lambda t: theme_lookup.get(t, [])
else:
raise TypeError("theme_lookup must be callable or dict")
self.config = config or RiskConfig()
# ──────────────────────────────────────────────
# 내부: 테마별 현재 노출 집계
# ──────────────────────────────────────────────
def _aggregate_by_theme(self, holdings: Iterable[dict]) -> Dict[str, dict]:
"""
Returns:
{theme: {"tickers": set, "amount": int}}
"""
agg: Dict[str, dict] = {}
for h in holdings:
tkr = h.get("ticker")
amt = int(h.get("eval_amount", 0) or 0)
if not tkr:
continue
themes = self._theme_of(tkr) or []
for th in themes:
bucket = agg.setdefault(th, {"tickers": set(), "amount": 0})
bucket["tickers"].add(tkr)
bucket["amount"] += amt
return agg
# ──────────────────────────────────────────────
# 공개 API
# ──────────────────────────────────────────────
def evaluate_buy(self, ticker: str, candidate_amount: int,
current_holdings: List[dict],
total_capital: int) -> RiskDecision:
"""
매수 허가 여부 판단.
Returns:
RiskDecision
- allowed=False: 이유와 함께 차단
- allowed=True : max_allowed_amount만큼 허용 (candidate_amount 이하)
"""
if candidate_amount <= 0 or total_capital <= 0:
return RiskDecision(False, "invalid_amount")
cfg = self.config
# 이미 보유 중이면 추가 매수는 이 게이트 대상 아님 (scale-in은 상위에서 처리)
held_tickers = {h.get("ticker") for h in current_holdings}
is_new_position = ticker not in held_tickers
# 1. 총 보유 종목 수 상한
if is_new_position and len(held_tickers) >= cfg.max_total_holdings:
return RiskDecision(
False,
f"max_total_holdings: {len(held_tickers)}/{cfg.max_total_holdings}"
)
themes = self._theme_of(ticker) or []
if not themes:
# 테마 정보 없음 → 테마 제약은 건너뛰고 통과
return RiskDecision(True, "no_theme_info", candidate_amount)
by_theme = self._aggregate_by_theme(current_holdings)
allowed_amount = candidate_amount
blocking_reasons = []
for th in themes:
bucket = by_theme.get(th, {"tickers": set(), "amount": 0})
# 2. 테마당 종목 수 상한 (신규 포지션일 때만)
if is_new_position and len(bucket["tickers"]) >= cfg.max_tickers_per_theme:
blocking_reasons.append(
f"theme[{th}] tickers {len(bucket['tickers'])}/{cfg.max_tickers_per_theme}"
)
continue
# 3. 테마당 노출 금액 비율 상한
max_theme_amount = int(total_capital * cfg.max_theme_exposure_ratio)
remaining = max_theme_amount - bucket["amount"]
if remaining <= 0:
blocking_reasons.append(
f"theme[{th}] exposure {bucket['amount']:,}/{max_theme_amount:,}"
)
continue
# 테마 잔여액이 candidate보다 작으면 부분 허용
allowed_amount = min(allowed_amount, remaining)
if blocking_reasons:
return RiskDecision(False, "; ".join(blocking_reasons))
if allowed_amount <= 0:
return RiskDecision(False, "theme_exposure_full")
return RiskDecision(True, "ok", allowed_amount)