- 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>
131 lines
5.7 KiB
Python
131 lines
5.7 KiB
Python
"""
|
||
일일 거래 장부 (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)
|