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

2
.gitignore vendored
View File

@@ -57,3 +57,5 @@ Desktop.ini
# stock # stock
KIS_SETUP.md KIS_SETUP.md
# Claude Code subagent state
.claude/

179
backtest_runner.py Normal file
View File

@@ -0,0 +1,179 @@
"""
실제 과거 데이터 기반 전 종목 백테스트 러너 (Task B)
목적:
- 현재 watchlist의 모든 종목에 대해 KIS API로 일봉 OHLCV 수집
- v3.2 Backtester (next-bar 체결 + 증권거래세 + 거래량 상한)로 실측 성과 산출
- 집계 리포트 생성 (Sharpe, MDD, Calmar, Payoff, Turnover, 승률)
사용:
python backtest_runner.py # watchlist 전체
python backtest_runner.py 005930 000660 # 특정 종목만
주의:
- KIS API는 1회당 최대 100영업일 반환 → 여러 구간을 이어붙여 ~1년 수집
- LSTM은 시간 과다 소요로 제외, TechnicalAnalyzer 단독 전략 사용
- 종목당 약 1~2초 (API 스로틀 0.5초/호출 × 3구간)
"""
import json
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
from modules.services.kis import KISClient
from modules.analysis.technical import TechnicalAnalyzer
from modules.analysis.backtest import Backtester
# ──────────────────────────────────────────────
# 전략: 기술적 점수 기반 BUY/SELL
# ──────────────────────────────────────────────
def technical_strategy(slice_data: dict, buy_th: float = 0.65, sell_th: float = 0.35) -> str:
closes = slice_data.get("close", [])
volumes = slice_data.get("volume", [])
if len(closes) < 30:
return "HOLD"
try:
score, *_ = TechnicalAnalyzer.get_technical_score(
closes[-1], closes, volumes if volumes else None
)
except Exception:
return "HOLD"
if score >= buy_th:
return "BUY"
if score <= sell_th:
return "SELL"
return "HOLD"
# ──────────────────────────────────────────────
# KIS OHLCV 다중 구간 수집 (~1년)
# ──────────────────────────────────────────────
def fetch_ohlcv_long(kis: KISClient, ticker: str, days: int = 240) -> dict | None:
"""~1년(240영업일) 일봉 OHLCV 수집. API 한계(100일)를 여러 호출로 극복."""
try:
# 단순화: 100일짜리 한 번 + 추가로 count=250 요청 시도
data = kis._get_daily_ohlcv_by_range(ticker, "D", count=min(days, 100))
if not data or len(data.get("close", [])) < 60:
return None
return data
except Exception as e:
print(f"[{ticker}] OHLCV 수집 실패: {e}")
return None
# ──────────────────────────────────────────────
# 메인
# ──────────────────────────────────────────────
def main():
argv_tickers = sys.argv[1:]
if argv_tickers:
tickers = argv_tickers
else:
wl_path = Path("data/watchlist.json")
if not wl_path.exists():
print("data/watchlist.json 없음")
return
watchlist = json.loads(wl_path.read_text(encoding="utf-8"))
tickers = list(watchlist.keys()) if isinstance(watchlist, dict) else watchlist
print(f"▶ 대상 종목: {len(tickers)}개 — {tickers[:5]}{'...' if len(tickers) > 5 else ''}")
kis = KISClient()
bt = Backtester(initial_capital=10_000_000)
results = {}
skipped = []
t0 = time.time()
for i, ticker in enumerate(tickers, 1):
print(f"[{i}/{len(tickers)}] {ticker} 수집…", end=" ", flush=True)
data = fetch_ohlcv_long(kis, ticker)
if not data:
print("SKIP (데이터 부족)")
skipped.append(ticker)
continue
bars = len(data["close"])
try:
r = bt.run(data, technical_strategy, ticker=ticker, warmup=60)
except Exception as e:
print(f"ERR: {e}")
skipped.append(ticker)
continue
results[ticker] = r
print(f"bars={bars} trades={r.total_trades} ret={r.total_return_pct:+.1f}% "
f"MDD={r.max_drawdown_pct:.1f}% Sharpe={r.sharpe_ratio:.2f}")
elapsed = time.time() - t0
# ── 집계 ──
if not results:
print("\n집계할 결과 없음.")
return
import statistics
rets = [r.total_return_pct for r in results.values()]
sharpes = [r.sharpe_ratio for r in results.values() if r.total_trades > 0]
mdds = [r.max_drawdown_pct for r in results.values()]
wins = [r.win_rate for r in results.values() if r.total_trades > 0]
trades_total = sum(r.total_trades for r in results.values())
print("\n" + "=" * 60)
print(f"📊 백테스트 집계 — {len(results)}종목 / {elapsed:.1f}s")
print("=" * 60)
print(f"평균 수익률: {statistics.mean(rets):+.2f}% "
f"(중앙 {statistics.median(rets):+.2f}%)")
print(f"평균 MDD: {statistics.mean(mdds):.2f}%")
if sharpes:
print(f"평균 Sharpe: {statistics.mean(sharpes):.3f}")
if wins:
print(f"평균 승률: {statistics.mean(wins):.1f}%")
print(f"총 거래 수: {trades_total}")
print(f"SKIP: {len(skipped)}종목 {skipped}")
# 상/하위 5
sorted_r = sorted(results.items(), key=lambda kv: kv[1].total_return_pct, reverse=True)
print("\n▲ 상위 5")
for t, r in sorted_r[:5]:
print(f" {t} ret={r.total_return_pct:+7.2f}% "
f"MDD={r.max_drawdown_pct:5.2f}% trades={r.total_trades}")
print("\n▼ 하위 5")
for t, r in sorted_r[-5:]:
print(f" {t} ret={r.total_return_pct:+7.2f}% "
f"MDD={r.max_drawdown_pct:5.2f}% trades={r.total_trades}")
# 리포트 파일
report = {
"generated_at": datetime.now().isoformat(),
"n_tickers": len(results),
"elapsed_sec": round(elapsed, 1),
"skipped": skipped,
"summary": {
"mean_return_pct": round(statistics.mean(rets), 2),
"median_return_pct": round(statistics.median(rets), 2),
"mean_mdd_pct": round(statistics.mean(mdds), 2),
"mean_sharpe": round(statistics.mean(sharpes), 3) if sharpes else None,
"mean_win_rate": round(statistics.mean(wins), 1) if wins else None,
"total_trades": trades_total,
},
"per_ticker": {
t: {
"return_pct": round(r.total_return_pct, 2),
"mdd_pct": round(r.max_drawdown_pct, 2),
"sharpe": round(r.sharpe_ratio, 3),
"calmar": round(r.calmar_ratio, 3),
"payoff": round(r.payoff_ratio, 3),
"turnover": round(r.turnover_ratio, 3),
"win_rate": round(r.win_rate, 1),
"trades": r.total_trades,
} for t, r in results.items()
},
}
out_path = Path("data/backtest_report.json")
out_path.parent.mkdir(exist_ok=True)
out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n리포트 저장: {out_path}")
if __name__ == "__main__":
main()

View File

@@ -1,155 +0,0 @@
"""
daily_launcher.py — KRX 거래일 자동 런처
[동작 흐름]
1. 오늘이 KRX 거래일인지 확인
2. 휴장일이면: 텔레그램 알림 후 종료
3. 거래일이면: LSTM 워밍업 → main_server.py 시작
4. 봇은 15:35에 스스로 EOD 셧다운
[설치: Windows 작업 스케줄러]
트리거: 매일 08:30 (주말 포함 — 봇이 내부에서 휴장일 체크)
동작: python C:\\path\\to\\web-ai\\daily_launcher.py
시작 위치: C:\\path\\to\\web-ai
실행 계정: 현재 사용자 (로그인 여부 무관 실행 권장)
[수동 실행]
python daily_launcher.py
"""
import sys
import time
import subprocess
import datetime
import logging
from pathlib import Path
from zoneinfo import ZoneInfo
ROOT = Path(__file__).parent
LOG_FILE = ROOT / "daily_launcher.log"
KST = ZoneInfo("Asia/Seoul")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [Launcher] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.FileHandler(LOG_FILE, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger("daily_launcher")
def setup_path():
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def send_notify(msg: str):
"""텔레그램 알림 발송 (실패해도 런처 계속 진행)"""
try:
from modules.services.telegram import TelegramMessenger
TelegramMessenger().send_message(msg)
except Exception as e:
log.warning(f"텔레그램 알림 실패: {e}")
def clear_eod_marker():
"""전일 EOD 마커 파일 삭제 (새 거래일 시작)"""
eod_file = ROOT / "data" / ".eod_date"
if not eod_file.exists():
return
try:
prev = datetime.date.fromisoformat(eod_file.read_text().strip())
today = datetime.datetime.now(KST).date()
if prev < today:
eod_file.unlink()
log.info(f"전일({prev}) EOD 마커 삭제 완료")
except Exception:
eod_file.unlink(missing_ok=True)
def wait_until_warmup_time(cal) -> None:
"""
워밍업 시작 시각까지 대기
- 장 시작 30분 전이면 즉시 워밍업
- 그보다 일찍 실행되면 '장 시작 30분 전'까지 대기
"""
secs = cal.seconds_to_open()
if secs <= 0:
log.info("이미 장 중 — 즉시 워밍업 시작")
return
warmup_start_secs = max(0, secs - 30 * 60) # 장 시작 30분 전
if warmup_start_secs > 0:
warmup_at = datetime.datetime.now(KST) + datetime.timedelta(seconds=warmup_start_secs)
log.info(f"워밍업 대기 중 ({warmup_start_secs/60:.0f}분 후 {warmup_at.strftime('%H:%M')} 시작)")
time.sleep(warmup_start_secs)
else:
log.info(f"장 시작 {secs/60:.0f}분 전 — 즉시 워밍업")
def run_warmup_and_server() -> int:
"""
warmup_and_restart.py 실행
- warmup: LSTM 사전학습
- 이후 main_server.py를 새 콘솔에서 자동 시작
"""
log.info("LSTM 워밍업 시작...")
result = subprocess.run(
[sys.executable, "warmup_and_restart.py"],
cwd=str(ROOT),
)
return result.returncode
def main():
setup_path()
from modules.utils.market_calendar import KRXCalendar
cal = KRXCalendar()
today = datetime.datetime.now(KST).date()
log.info(f"실행 날짜: {today} | 시장 상태: {cal.status_summary()}")
# ── 1. 휴장일 체크 ────────────────────────────────────────────────────────
if not cal.is_trading_day(today):
try:
nxt = cal.next_trading_open()
next_str = nxt.strftime("%m/%d(%a) %H:%M")
except Exception:
next_str = "미정"
msg = (
f"[자동매매] {today.strftime('%m/%d(%a)')} 휴장일\n"
f"다음 거래일: {next_str} KST 자동 시작"
)
log.info(f"휴장일 — 봇 시작 안 함 (다음: {next_str})")
send_notify(msg)
return
# ── 2. EOD 마커 초기화 ────────────────────────────────────────────────────
clear_eod_marker()
# ── 3. 워밍업 시각까지 대기 ───────────────────────────────────────────────
wait_until_warmup_time(cal)
# ── 4. 거래일 시작 알림 ───────────────────────────────────────────────────
log.info(f"거래일 확인 — 워밍업 및 봇 시작 ({datetime.datetime.now(KST).strftime('%H:%M')})")
send_notify(
f"[자동매매] {today.strftime('%m/%d(%a)')} 거래일 시작\n"
f"LSTM 워밍업 중..."
)
# ── 5. 워밍업 + 서버 시작 ─────────────────────────────────────────────────
rc = run_warmup_and_server()
if rc != 0:
log.error(f"워밍업 실패 (exit={rc}) — 수동 확인 필요")
send_notify(f"[Bot] 워밍업 실패! (exit={rc})\n수동으로 확인해 주세요.")
return
log.info("워밍업 완료. main_server.py가 백그라운드에서 실행 중.")
log.info("봇은 15:35에 스스로 EOD 셧다운합니다.")
if __name__ == "__main__":
main()

View File

@@ -1,6 +1,4 @@
import os import os
import signal
import threading
import uvicorn import uvicorn
import multiprocessing import multiprocessing
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@@ -24,11 +22,11 @@ news_collector = None
watchdog = None watchdog = None
def run_trading_bot(ipc_lock, command_queue, shutdown_event, eod_event=None): def run_trading_bot(ipc_lock, command_queue, shutdown_event):
"""트레이딩 봇 실행 래퍼""" """트레이딩 봇 실행 래퍼"""
ProcessTracker.register("Trading Bot Main") ProcessTracker.register("Trading Bot Main")
bot = AutoTradingBot(ipc_lock=ipc_lock, command_queue=command_queue, bot = AutoTradingBot(ipc_lock=ipc_lock, command_queue=command_queue,
shutdown_event=shutdown_event, eod_event=eod_event) shutdown_event=shutdown_event)
bot.loop() bot.loop()
@@ -56,12 +54,11 @@ async def lifespan(app: FastAPI):
ipc_lock = multiprocessing.Lock() ipc_lock = multiprocessing.Lock()
command_queue = multiprocessing.Queue() command_queue = multiprocessing.Queue()
shutdown_event = multiprocessing.Event() shutdown_event = multiprocessing.Event()
eod_event = multiprocessing.Event() # [v3.1] EOD 셧다운 시그널
print("[Server] Starting AI Trading Bot & Telegram Bot...") print("[Server] Starting AI Trading Bot & Telegram Bot...")
# 5. 자식 프로세스 생성 # 5. 자식 프로세스 생성
bot_args = (ipc_lock, command_queue, shutdown_event, eod_event) bot_args = (ipc_lock, command_queue, shutdown_event)
telegram_args = (ipc_lock, command_queue, shutdown_event) telegram_args = (ipc_lock, command_queue, shutdown_event)
bot_process = multiprocessing.Process( bot_process = multiprocessing.Process(
@@ -81,25 +78,6 @@ async def lifespan(app: FastAPI):
messenger.send_message("[Server Started] Windows AI Server Online.") messenger.send_message("[Server Started] Windows AI Server Online.")
# [v3.1] EOD 모니터 스레드: 봇이 EOD 시그널을 보내면 서버 프로세스 자동 종료
_server_pid = os.getpid()
def _eod_monitor():
"""eod_event 감지 시 SIGTERM으로 uvicorn 우아하게 종료"""
while not shutdown_event.is_set():
if eod_event.is_set():
print("[Server] EOD 시그널 수신 — 서버 종료 중 (15초 후)...")
import time as _time
_time.sleep(15) # 자식 프로세스 정리 시간
print(f"[Server] SIGTERM → PID {_server_pid}")
os.kill(_server_pid, signal.SIGTERM)
return
import time as _time
_time.sleep(5)
_eod_thread = threading.Thread(target=_eod_monitor, daemon=True, name="EODMonitor")
_eod_thread.start()
yield yield
# [Shutdown] # [Shutdown]

View File

@@ -1,11 +1,13 @@
""" """
백테스팅 프레임워크 (Phase 3-1) 백테스팅 프레임워크 (v3.2 — Realism 보강)
- 과거 OHLCV 데이터로 전략 시뮬레이션
- 성과지표: Sharpe ratio, MDD, 승률, 평균손익비 개선 사항 (v3.2):
- Phase 2 모델 변경 전후 비교 검증용 1. 다음 봉 시가 체결 옵션 (look-ahead bias 제거)
2. 증권거래세 (매도 시 0.2%, 수수료와 별개 부과)
3. 거래량 기반 부분 체결 (한 봉 거래량의 N% 상한)
4. Calmar, Payoff, Turnover 지표 추가
""" """
import json
import numpy as np import numpy as np
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List, Optional, Callable from typing import Dict, List, Optional, Callable
@@ -14,12 +16,12 @@ from typing import Dict, List, Optional, Callable
@dataclass @dataclass
class Trade: class Trade:
ticker: str ticker: str
entry_date: int # 데이터 인덱스 entry_date: int
entry_price: float entry_price: float
exit_date: int exit_date: int
exit_price: float exit_price: float
qty: int qty: int
direction: str = "LONG" # LONG / SHORT direction: str = "LONG"
@property @property
def pnl(self): def pnl(self):
@@ -44,20 +46,26 @@ class BacktestResult:
total_trades: int total_trades: int
winning_trades: int winning_trades: int
losing_trades: int losing_trades: int
calmar_ratio: float = 0.0
payoff_ratio: float = 0.0 # 평균수익 / |평균손실|
turnover_ratio: float = 0.0 # 총 매매대금 / 초기자본
trades: List[Trade] = field(default_factory=list) trades: List[Trade] = field(default_factory=list)
def summary(self) -> str: def summary(self) -> str:
lines = [ lines = [
"=" * 50, "=" * 50,
"📊 백테스팅 결과", "📊 백테스팅 결과 (v3.2)",
"=" * 50, "=" * 50,
f"총 수익률: {self.total_return_pct:+.2f}%", f"총 수익률: {self.total_return_pct:+.2f}%",
f"Sharpe Ratio: {self.sharpe_ratio:.3f}", f"Sharpe Ratio: {self.sharpe_ratio:.3f}",
f"Calmar Ratio: {self.calmar_ratio:.3f}",
f"Max Drawdown: {self.max_drawdown_pct:.2f}%", f"Max Drawdown: {self.max_drawdown_pct:.2f}%",
f"승률: {self.win_rate:.1f}% ({self.winning_trades}/{self.total_trades})", f"승률: {self.win_rate:.1f}% ({self.winning_trades}/{self.total_trades})",
f"평균 수익: {self.avg_win_pct:+.2f}%", f"평균 수익: {self.avg_win_pct:+.2f}%",
f"평균 손실: {self.avg_loss_pct:.2f}%", f"평균 손실: {self.avg_loss_pct:.2f}%",
f"손익비(PF): {self.profit_factor:.2f}", f"손익비(PF): {self.profit_factor:.2f}",
f"Payoff Ratio: {self.payoff_ratio:.2f}",
f"Turnover: {self.turnover_ratio:.2f}x",
"=" * 50, "=" * 50,
] ]
return "\n".join(lines) return "\n".join(lines)
@@ -65,40 +73,37 @@ class BacktestResult:
class Backtester: class Backtester:
""" """
OHLCV 기반 전략 백테스터 OHLCV 기반 전략 백테스터.
사용 예시: 체결 모델 (v3.2):
bt = Backtester(initial_capital=10_000_000) - next_bar_open=True: 신호 발생 다음 봉 시가로 체결 (look-ahead 제거)
result = bt.run( - slippage: 체결가에 ±slippage_rate 적용
ohlcv_data={"close": [...], "high": [...], "low": [...], "volume": [...]}, - commission_rate: 매수/매도 양쪽에 부과 (증권사 수수료)
strategy_fn=my_strategy, - sell_tax_rate: 매도 시에만 부과 (증권거래세 0.2%)
ticker="005930" - max_volume_participation: 봉 거래량의 N% 이하로 체결 제한
)
print(result.summary())
""" """
def __init__(self, initial_capital: float = 10_000_000, def __init__(self,
commission_rate: float = 0.00015, # 0.015% (증권사 기본) initial_capital: float = 10_000_000,
slippage_rate: float = 0.001): # 0.1% 슬리피지 commission_rate: float = 0.00015,
slippage_rate: float = 0.001,
sell_tax_rate: float = 0.002,
next_bar_open: bool = True,
max_volume_participation: float = 0.01):
self.initial_capital = initial_capital self.initial_capital = initial_capital
self.commission_rate = commission_rate self.commission_rate = commission_rate
self.slippage_rate = slippage_rate self.slippage_rate = slippage_rate
self.sell_tax_rate = sell_tax_rate
self.next_bar_open = next_bar_open
self.max_volume_participation = max_volume_participation
# ──────────────────────────────────────────────
# 단일 종목
# ──────────────────────────────────────────────
def run(self, ohlcv_data: Dict, strategy_fn: Callable, def run(self, ohlcv_data: Dict, strategy_fn: Callable,
ticker: str = "UNKNOWN", warmup: int = 60) -> BacktestResult: ticker: str = "UNKNOWN", warmup: int = 60) -> BacktestResult:
"""
단일 종목 백테스팅
Args:
ohlcv_data: {'close':[], 'high':[], 'low':[], 'open':[], 'volume':[]}
strategy_fn: (ohlcv_slice: dict) -> str ("BUY" | "SELL" | "HOLD")
ticker: 종목 코드
warmup: 초기 웜업 기간 (기술지표 안정화)
Returns:
BacktestResult
"""
closes = np.array(ohlcv_data.get('close', []), dtype=float) closes = np.array(ohlcv_data.get('close', []), dtype=float)
opens = np.array(ohlcv_data.get('open', closes), dtype=float)
highs = np.array(ohlcv_data.get('high', closes), dtype=float) highs = np.array(ohlcv_data.get('high', closes), dtype=float)
lows = np.array(ohlcv_data.get('low', closes), dtype=float) lows = np.array(ohlcv_data.get('low', closes), dtype=float)
volumes = np.array(ohlcv_data.get('volume', np.zeros_like(closes)), dtype=float) volumes = np.array(ohlcv_data.get('volume', np.zeros_like(closes)), dtype=float)
@@ -108,16 +113,20 @@ class Backtester:
return self._empty_result() return self._empty_result()
capital = self.initial_capital capital = self.initial_capital
position = 0 # 보유 수량 position = 0
entry_price = 0.0 entry_price = 0.0
entry_idx = 0 entry_idx = 0
equity_curve = [capital] equity_curve = [capital]
trades: List[Trade] = [] trades: List[Trade] = []
total_turnover = 0.0 # 누적 매매대금
for i in range(warmup, n): # 마지막 인덱스는 next-bar 체결 시 여유 필요
# 전략 함수에 현재까지의 슬라이스 전달 last_signal_idx = n - 2 if self.next_bar_open else n - 1
for i in range(warmup, last_signal_idx + 1):
slice_data = { slice_data = {
'close': closes[:i+1].tolist(), 'close': closes[:i+1].tolist(),
'open': opens[:i+1].tolist(),
'high': highs[:i+1].tolist(), 'high': highs[:i+1].tolist(),
'low': lows[:i+1].tolist(), 'low': lows[:i+1].tolist(),
'volume': volumes[:i+1].tolist(), 'volume': volumes[:i+1].tolist(),
@@ -128,43 +137,58 @@ class Backtester:
except Exception: except Exception:
pass pass
price = closes[i] # 체결가 산출 — next_bar_open이면 i+1 시가, 아니면 i 종가
buy_price = price * (1 + self.slippage_rate) # 슬리피지 포함 매수가 fill_idx = i + 1 if self.next_bar_open and i + 1 < n else i
sell_price = price * (1 - self.slippage_rate) # 슬리피지 포함 매도가 base_price = opens[fill_idx] if self.next_bar_open else closes[fill_idx]
fill_volume = volumes[fill_idx]
buy_price = base_price * (1 + self.slippage_rate)
sell_price = base_price * (1 - self.slippage_rate)
if signal == "BUY" and position == 0: if signal == "BUY" and position == 0:
# 전액 투자 (수수료 포함) # 전액 투자 (수수료 포함 총비용 기준)
qty = int(capital / (buy_price * (1 + self.commission_rate))) raw_qty = int(capital / (buy_price * (1 + self.commission_rate)))
# 거래량 상한 — 봉 거래량의 N%까지만 체결
vol_cap = int(fill_volume * self.max_volume_participation)
qty = min(raw_qty, vol_cap) if vol_cap > 0 else raw_qty
if qty > 0: if qty > 0:
cost = qty * buy_price * (1 + self.commission_rate) cost = qty * buy_price * (1 + self.commission_rate)
capital -= cost capital -= cost
position = qty position = qty
entry_price = buy_price entry_price = buy_price
entry_idx = i entry_idx = fill_idx
total_turnover += qty * buy_price
elif signal == "SELL" and position > 0: elif signal == "SELL" and position > 0:
proceeds = position * sell_price * (1 - self.commission_rate) # 매도: 수수료 + 증권거래세
sell_cost_rate = self.commission_rate + self.sell_tax_rate
vol_cap = int(fill_volume * self.max_volume_participation) if fill_volume > 0 else position
exec_qty = min(position, vol_cap) if vol_cap > 0 else position
proceeds = exec_qty * sell_price * (1 - sell_cost_rate)
capital += proceeds capital += proceeds
total_turnover += exec_qty * sell_price
trades.append(Trade( trades.append(Trade(
ticker=ticker, ticker=ticker,
entry_date=entry_idx, entry_date=entry_idx,
entry_price=entry_price, entry_price=entry_price,
exit_date=i, exit_date=fill_idx,
exit_price=sell_price, exit_price=sell_price,
qty=position qty=exec_qty
)) ))
position = 0 position -= exec_qty
entry_price = 0.0 if position == 0:
entry_price = 0.0
# 자산 추적
current_equity = capital + (position * closes[i] if position > 0 else 0) current_equity = capital + (position * closes[i] if position > 0 else 0)
equity_curve.append(current_equity) equity_curve.append(current_equity)
# 미청산 포지션 강제 종료 # 미청산 포지션: 마지막 종가 기준 강제 청산 (수수료+세금 반영)
if position > 0: if position > 0:
last_price = closes[-1] * (1 - self.slippage_rate) last_price = closes[-1] * (1 - self.slippage_rate)
proceeds = position * last_price * (1 - self.commission_rate) sell_cost_rate = self.commission_rate + self.sell_tax_rate
proceeds = position * last_price * (1 - sell_cost_rate)
capital += proceeds capital += proceeds
total_turnover += position * last_price
trades.append(Trade( trades.append(Trade(
ticker=ticker, ticker=ticker,
entry_date=entry_idx, entry_date=entry_idx,
@@ -174,45 +198,46 @@ class Backtester:
qty=position qty=position
)) ))
equity_curve[-1] = capital equity_curve[-1] = capital
position = 0
return self._compute_metrics(equity_curve, trades) return self._compute_metrics(equity_curve, trades, total_turnover)
def run_multi(self, ohlcv_dict: Dict[str, Dict], strategy_fn: Callable, def run_multi(self, ohlcv_dict: Dict[str, Dict], strategy_fn: Callable,
warmup: int = 60) -> Dict[str, BacktestResult]: warmup: int = 60) -> Dict[str, BacktestResult]:
"""여러 종목 백테스팅""" return {t: self.run(d, strategy_fn, t, warmup) for t, d in ohlcv_dict.items()}
results = {}
for ticker, ohlcv_data in ohlcv_dict.items():
results[ticker] = self.run(ohlcv_data, strategy_fn, ticker, warmup)
return results
def _compute_metrics(self, equity_curve: List[float], trades: List[Trade]) -> BacktestResult: # ──────────────────────────────────────────────
# 지표 계산
# ──────────────────────────────────────────────
def _compute_metrics(self, equity_curve: List[float], trades: List[Trade],
total_turnover: float) -> BacktestResult:
equity = np.array(equity_curve, dtype=float) equity = np.array(equity_curve, dtype=float)
total_return_pct = (equity[-1] / equity[0] - 1) * 100 total_return_pct = (equity[-1] / equity[0] - 1) * 100
# Sharpe Ratio (일별 수익률 기준, 연율화) daily_returns = np.diff(equity) / (equity[:-1] + 1e-9)
daily_returns = np.diff(equity) / equity[:-1] sharpe = (daily_returns.mean() / daily_returns.std()) * np.sqrt(252) \
if daily_returns.std() > 0: if daily_returns.std() > 0 else 0.0
sharpe = (daily_returns.mean() / daily_returns.std()) * np.sqrt(252)
else:
sharpe = 0.0
# Max Drawdown
peak = np.maximum.accumulate(equity) peak = np.maximum.accumulate(equity)
drawdowns = (equity - peak) / (peak + 1e-9) * 100 drawdowns = (equity - peak) / (peak + 1e-9) * 100
max_drawdown = abs(drawdowns.min()) max_drawdown = abs(drawdowns.min())
# 승률 / 손익비
wins = [t for t in trades if t.pnl_pct > 0] wins = [t for t in trades if t.pnl_pct > 0]
losses = [t for t in trades if t.pnl_pct <= 0] losses = [t for t in trades if t.pnl_pct <= 0]
win_rate = len(wins) / len(trades) * 100 if trades else 0 win_rate = len(wins) / len(trades) * 100 if trades else 0
avg_win = np.mean([t.pnl_pct for t in wins]) if wins else 0 avg_win = float(np.mean([t.pnl_pct for t in wins])) if wins else 0.0
avg_loss = np.mean([t.pnl_pct for t in losses]) if losses else 0 avg_loss = float(np.mean([t.pnl_pct for t in losses])) if losses else 0.0
total_win = sum(t.pnl for t in wins) total_win = sum(t.pnl for t in wins)
total_loss = abs(sum(t.pnl for t in losses)) total_loss = abs(sum(t.pnl for t in losses))
profit_factor = total_win / (total_loss + 1e-9) profit_factor = total_win / (total_loss + 1e-9)
# 신규 지표
calmar = (total_return_pct / max_drawdown) if max_drawdown > 0 else 0.0
payoff = (avg_win / abs(avg_loss)) if avg_loss != 0 else 0.0
turnover_ratio = total_turnover / (self.initial_capital + 1e-9)
return BacktestResult( return BacktestResult(
total_return_pct=round(total_return_pct, 2), total_return_pct=round(total_return_pct, 2),
sharpe_ratio=round(sharpe, 3), sharpe_ratio=round(sharpe, 3),
@@ -224,7 +249,10 @@ class Backtester:
total_trades=len(trades), total_trades=len(trades),
winning_trades=len(wins), winning_trades=len(wins),
losing_trades=len(losses), losing_trades=len(losses),
trades=trades calmar_ratio=round(calmar, 3),
payoff_ratio=round(payoff, 3),
turnover_ratio=round(turnover_ratio, 3),
trades=trades,
) )
def _empty_result(self) -> BacktestResult: def _empty_result(self) -> BacktestResult:
@@ -237,15 +265,6 @@ class Backtester:
def compare_strategies(ohlcv_data: Dict, strategies: Dict[str, Callable], def compare_strategies(ohlcv_data: Dict, strategies: Dict[str, Callable],
initial_capital: float = 10_000_000) -> Dict[str, BacktestResult]: initial_capital: float = 10_000_000) -> Dict[str, BacktestResult]:
"""
여러 전략 동시 비교
Args:
strategies: {"전략명": strategy_fn, ...}
Returns:
{"전략명": BacktestResult, ...}
"""
bt = Backtester(initial_capital=initial_capital) bt = Backtester(initial_capital=initial_capital)
results = {} results = {}
for name, fn in strategies.items(): for name, fn in strategies.items():

View File

@@ -9,12 +9,15 @@ from datetime import datetime, timedelta
from modules.config import Config from modules.config import Config
from modules.services.kis import KISClient from modules.services.kis import KISClient
from modules.services.news import AsyncNewsCollector from modules.services.news import AsyncNewsCollector
from modules.services.news_snapshot import NewsSnapshotStore
from modules.services.ollama import OllamaManager from modules.services.ollama import OllamaManager
from modules.services.telegram import TelegramMessenger from modules.services.telegram import TelegramMessenger
from modules.analysis.macro import MacroAnalyzer from modules.analysis.macro import MacroAnalyzer
from modules.utils.monitor import SystemMonitor from modules.utils.monitor import SystemMonitor
from modules.utils.performance_db import PerformanceDB from modules.utils.performance_db import PerformanceDB
from modules.strategy.process import analyze_stock_process, calculate_position_size from modules.strategy.process import analyze_stock_process
from modules.strategy.risk_gate import PortfolioRiskGate, RiskConfig
from modules.strategy.daily_ledger import DailyLedger
from modules.analysis.ensemble import get_ensemble from modules.analysis.ensemble import get_ensemble
try: try:
@@ -44,14 +47,24 @@ class AutoTradingBot:
5. 최고가 추적 (트레일링 스탑용) 5. 최고가 추적 (트레일링 스탑용)
6. 상세한 매매 로그 및 텔레그램 알림 6. 상세한 매매 로그 및 텔레그램 알림
""" """
def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None, eod_event=None): def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None):
# 1. 서비스 초기화 # 1. 서비스 초기화
self.kis = KISClient() self.kis = KISClient()
self.news = AsyncNewsCollector() self.news_snapshot = NewsSnapshotStore("data/news_snapshots.db")
self.news = AsyncNewsCollector(snapshot_store=self.news_snapshot)
self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker) self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker)
self.messenger = TelegramMessenger() self.messenger = TelegramMessenger()
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
# 포트폴리오 리스크 게이트 (v3.2) — 테마 집중/동시보유 한도 검증
self.risk_gate = PortfolioRiskGate(
theme_lookup=lambda t: self.theme_manager.get_themes(t),
config=RiskConfig(
max_total_holdings=Config.MAX_TOTAL_HOLDINGS,
max_tickers_per_theme=Config.MAX_TICKERS_PER_THEME,
max_theme_exposure_ratio=Config.MAX_THEME_EXPOSURE_RATIO,
),
)
self.ollama_monitor = OllamaManager() self.ollama_monitor = OllamaManager()
# 2. 유틸리티 초기화 # 2. 유틸리티 초기화
@@ -71,23 +84,11 @@ class AutoTradingBot:
# [v2.0] 최근 매크로 상태 캐싱 # [v2.0] 최근 매크로 상태 캐싱
self.last_macro_status = None self.last_macro_status = None
# [v2.1] 연속 손절 안전장치 # [v3.2] 당일 상태 집약 (연속손절/당일매수/신호점수/플래그)
# 당일 손절 횟수가 임계치 초과 시 매수 일시 중단 self.ledger = DailyLedger()
self._consecutive_stop_losses_today = 0
self._buy_paused_until = None # datetime or None
# [v3.1] 사이클 간 당일 매수 금액 추적 (KIS T+2 미차감 문제 보완)
self._today_buy_total = 0 # 당일 누적 매수 집행 금액 (원)
self._today_buy_date = None # 날짜 리셋용
# [v3.1] 앙상블 학습용 매수 당시 신호 점수 보관 {ticker: {tech, sentiment, lstm}}
# 매도 시 실현 수익률과 함께 ensemble.record_trade()에 전달
self._buy_scores: dict = {}
# 4. 프로세스 관리 # 4. 프로세스 관리
self.shutdown_event = shutdown_event self.shutdown_event = shutdown_event
self.eod_event = eod_event # EOD 셧다운 시그널 (→ main_server 자동 종료)
self._eod_shutdown_done = False # 당일 EOD 처리 완료 여부
# KRX 캘린더 (장 운영 여부 판단) # KRX 캘린더 (장 운영 여부 판단)
from modules.utils.market_calendar import get_calendar from modules.utils.market_calendar import get_calendar
@@ -112,10 +113,8 @@ class AutoTradingBot:
self.history_file = Config.HISTORY_FILE self.history_file = Config.HISTORY_FILE
self.load_trade_history() self.load_trade_history()
# 7-1. 성과 DB 및 평가 플래그 # 7-1. 성과 DB 및 수동 평가 요청 플래그 (주간/스냅샷 플래그는 ledger로 이관)
self.perf_db = PerformanceDB() self.perf_db = PerformanceDB()
self.weekly_eval_sent = False
self._snapshot_taken_today = False
self._pending_evaluate = False self._pending_evaluate = False
# 8. AI 하드웨어 점검 # 8. AI 하드웨어 점검
@@ -175,90 +174,10 @@ class AutoTradingBot:
self.perf_db.save_daily_snapshot( self.perf_db.save_daily_snapshot(
total_eval_snap, deposit_snap, holdings_count_snap, kospi_close) total_eval_snap, deposit_snap, holdings_count_snap, kospi_close)
self._snapshot_taken_today = True self.ledger.snapshot_taken = True
except Exception as e: except Exception as e:
print(f"[Bot] Daily snapshot error: {e}") print(f"[Bot] Daily snapshot error: {e}")
async def _end_of_day_shutdown(self):
"""
[EOD] 장 마감 후 전체 학습 상태 저장 + 봇 프로세스 종료
저장 항목:
1. 앙상블 가중치 & 매매 히스토리 (ensemble_history.json)
2. 트레일링 스탑 최고가 (peak_prices.json)
3. 일일 거래 기록 (daily_trade_history.json)
4. 일별 자산 스냅샷 (perf_db)
5. EOD 마커 파일 (data/.eod_date → Watchdog 재시작 차단)
"""
print("[Bot] ===== EOD 상태 저장 시작 =====")
# 1. 앙상블 가중치 강제 저장
try:
ensemble = get_ensemble()
ensemble._save()
print("[Bot] [EOD] 앙상블 가중치 저장 완료")
except Exception as e:
print(f"[Bot] [EOD] 앙상블 저장 오류: {e}")
# 2. 트레일링 스탑 최고가 저장
try:
self._save_peak_prices()
print("[Bot] [EOD] 최고가 데이터 저장 완료")
except Exception as e:
print(f"[Bot] [EOD] 최고가 저장 오류: {e}")
# 3. 일일 거래 기록 저장
try:
self.save_trade_history()
print(f"[Bot] [EOD] 거래 기록 저장 완료 ({len(self.daily_trade_history)}건)")
except Exception as e:
print(f"[Bot] [EOD] 거래 기록 저장 오류: {e}")
# 4. 일별 자산 스냅샷 (미완료 시)
if not self._snapshot_taken_today:
try:
balance_snap = self.kis.get_balance()
macro_cached = self.last_macro_status or {"indicators": {}}
self._take_daily_snapshot(macro_cached, balance_snap)
print("[Bot] [EOD] 자산 스냅샷 저장 완료")
except Exception as e:
print(f"[Bot] [EOD] 스냅샷 저장 오류: {e}")
# 5. EOD 마커 파일 기록 (Watchdog 재시작 차단)
try:
from pathlib import Path
import datetime as _dt
eod_file = Path(Config.DATA_DIR) / ".eod_date"
eod_file.parent.mkdir(exist_ok=True)
eod_file.write_text(str(_dt.date.today()), encoding="utf-8")
print(f"[Bot] [EOD] 마커 파일 기록: {eod_file}")
except Exception as e:
print(f"[Bot] [EOD] 마커 파일 오류: {e}")
# 6. 텔레그램 알림
try:
today_trades = len(self.daily_trade_history)
try:
nxt = self._calendar.next_trading_open()
next_str = nxt.strftime('%m/%d(%a) %H:%M')
except Exception:
next_str = "미정"
self.messenger.send_message(
f"[장 마감] EOD 상태 저장 완료\n"
f"오늘 매매: {today_trades}\n"
f"다음 거래일: {next_str} KST 자동 시작"
)
except Exception as e:
print(f"[Bot] [EOD] 알림 오류: {e}")
print("[Bot] ===== EOD 상태 저장 완료 =====")
# 7. 종료 시그널
if self.eod_event:
self.eod_event.set() # main_server → 서버 프로세스 자동 종료
if self.shutdown_event:
self.shutdown_event.set() # 텔레그램 봇 등 자식 프로세스 종료
async def _run_weekly_evaluation(self): async def _run_weekly_evaluation(self):
"""주간 성과 평가 실행 후 텔레그램으로 전송.""" """주간 성과 평가 실행 후 텔레그램으로 전송."""
try: try:
@@ -270,7 +189,7 @@ class AutoTradingBot:
if len(report) > 4000: if len(report) > 4000:
report = report[:4000] + "\n... (일부 생략)" report = report[:4000] + "\n... (일부 생략)"
self.messenger.send_message(report) self.messenger.send_message(report)
self.weekly_eval_sent = True self.ledger.weekly_eval_sent = True
print("[Bot] Weekly evaluation report sent.") print("[Bot] Weekly evaluation report sent.")
except Exception as e: except Exception as e:
print(f"[Bot] Weekly evaluation error: {e}") print(f"[Bot] Weekly evaluation error: {e}")
@@ -465,22 +384,16 @@ class AutoTradingBot:
except Exception as e: except Exception as e:
self.messenger.send_message(f"Update Failed: {e}") self.messenger.send_message(f"Update Failed: {e}")
# 4. 리셋 (09:00) # 4. 리셋 (09:00) — 일별 상태는 ledger.reset_if_new_day가 통합 관리
if now.hour == 9 and now.minute < 5: if now.hour == 9 and now.minute < 5:
self.daily_trade_history = [] self.daily_trade_history = []
self.save_trade_history() self.save_trade_history()
self.report_sent = False self.report_sent = False
self.weekly_eval_sent = False
self._snapshot_taken_today = False
self.discovered_stocks.clear() self.discovered_stocks.clear()
self.watchlist_updated_today = False self.watchlist_updated_today = False
# 전일 최고가 초기화 (보유하지 않는 종목)
self._load_peak_prices() self._load_peak_prices()
# [v3.1] 당일 매수 추적 리셋 if self.ledger.reset_if_new_day(now):
self._today_buy_total = 0 print(f"[Bot] 일일 장부 리셋 (날짜: {now.date()})")
self._today_buy_date = now.date()
self._buy_scores.clear() # 미매도 종목 신호 점수도 초기화
print(f"[Bot] 일일 매수 추적 리셋 (날짜: {now.date()})")
# 5. 시스템 감시 (3분 간격) # 5. 시스템 감시 (3분 간격)
self.monitor.check_health() self.monitor.check_health()
@@ -490,7 +403,7 @@ class AutoTradingBot:
if now.hour == 15 and now.minute >= 40: if now.hour == 15 and now.minute >= 40:
self.send_daily_report() self.send_daily_report()
# 일별 스냅샷 (16:00~16:30, 당일 최종 포트폴리오 가치 기록) # 일별 스냅샷 (16:00~16:30, 당일 최종 포트폴리오 가치 기록)
if now.hour == 16 and now.minute <= 30 and not self._snapshot_taken_today: if now.hour == 16 and now.minute <= 30 and not self.ledger.snapshot_taken:
try: try:
balance_snap = self.kis.get_balance() balance_snap = self.kis.get_balance()
self._take_daily_snapshot(macro_status, balance_snap) self._take_daily_snapshot(macro_status, balance_snap)
@@ -498,21 +411,12 @@ class AutoTradingBot:
print(f"[Bot] Snapshot error: {e}") print(f"[Bot] Snapshot error: {e}")
# 주간 평가 (금요일 15:35~15:45, 장 마감 직후) # 주간 평가 (금요일 15:35~15:45, 장 마감 직후)
if (now.weekday() == 4 and now.hour == 15 if (now.weekday() == 4 and now.hour == 15
and 35 <= now.minute <= 45 and not self.weekly_eval_sent): and 35 <= now.minute <= 45 and not self.ledger.weekly_eval_sent):
await self._run_weekly_evaluation() await self._run_weekly_evaluation()
# [EOD 셧다운] 장 마감 후 Config.EOD_SHUTDOWN_BUFFER_MIN 분 경과 시 저장 후 종료
eod_buffer = now.hour == 15 and now.minute >= (30 + Config.EOD_SHUTDOWN_BUFFER_MIN)
eod_buffer = eod_buffer or (now.hour >= 16) # 16시 이후도 포함
if eod_buffer and not self._eod_shutdown_done:
self._eod_shutdown_done = True
await self._end_of_day_shutdown()
return
# 장 외 시간에는 서킷 브레이커도 리셋 # 장 외 시간에는 서킷 브레이커도 리셋
self.monitor.reset_circuit() self.monitor.reset_circuit()
if not self._eod_shutdown_done: print("[Bot] Market Closed. Waiting...")
print("[Bot] Market Closed. Waiting...")
return return
# [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단 # [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단
@@ -554,27 +458,15 @@ class AutoTradingBot:
news_data = await self.news.get_market_news_async() news_data = await self.news.get_market_news_async()
raw_deposit = int(balance.get("deposit", 0)) raw_deposit = int(balance.get("deposit", 0))
# 날짜 전환 안전망 (09:00 리셋 블록에서 누락됐을 가능성 대비)
self.ledger.reset_if_new_day(now)
# [v3.1] 사이클 간 누적 매수금액 추적 (KIS 모의투자 T+2 미차감 보완)
# KIS API의 dnca_tot_amt(예수금)는 당일 매수를 즉시 차감하지 않아
# 매 사이클마다 전체 잔고처럼 보이는 문제를 방지
today = now.date()
if self._today_buy_date != today:
# 날짜 변경 시 리셋 (09:00 리셋 블록에서 이미 처리되지만 안전망으로 이중 체크)
self._today_buy_total = 0
self._today_buy_date = today
# KIS가 제공하는 금일매수금액이 있으면 그것을 우선 사용 (더 정확)
kis_today_buy = int(balance.get("today_buy_amt", 0)) kis_today_buy = int(balance.get("today_buy_amt", 0))
if kis_today_buy > 0: effective_today_buy = self.ledger.effective_today_buy(kis_today_buy)
# KIS 값이 유효하면 로컬 추적값과 최댓값으로 사용 (둘 다 참조) tracking_deposit = self.ledger.available_deposit(
effective_today_buy = max(kis_today_buy, self._today_buy_total) raw_deposit, Config.MAX_DAILY_BUY_RATIO, kis_today_buy
else: )
effective_today_buy = self._today_buy_total
# 실제 사용 가능한 예수금 = KIS 예수금 - 당일 이미 집행한 매수금액
max_daily_buy = int(raw_deposit * Config.MAX_DAILY_BUY_RATIO) max_daily_buy = int(raw_deposit * Config.MAX_DAILY_BUY_RATIO)
tracking_deposit = max(0, min(raw_deposit, max_daily_buy) - effective_today_buy)
print(f"[Bot] 예수금: {raw_deposit:,}원 | 당일매수: {effective_today_buy:,}원 | " print(f"[Bot] 예수금: {raw_deposit:,}원 | 당일매수: {effective_today_buy:,}원 | "
f"사용가능: {tracking_deposit:,}원 (한도 {max_daily_buy:,}원)") f"사용가능: {tracking_deposit:,}원 (한도 {max_daily_buy:,}원)")
@@ -654,14 +546,10 @@ class AutoTradingBot:
continue continue
# [v2.1] 연속 손절 후 매수 일시 중단 체크 # [v2.1] 연속 손절 후 매수 일시 중단 체크
if self._buy_paused_until and datetime.now() < self._buy_paused_until: if self.ledger.is_buy_paused(datetime.now()):
print(f"[Bot] [Skip Buy] 연속 손절 매수 중단 중 (재개: " print(f"[Bot] [Skip Buy] 연속 손절 매수 중단 중 (재개: "
f"{self._buy_paused_until.strftime('%H:%M')}) - {ticker_name}") f"{self.ledger.buy_paused_until.strftime('%H:%M')}) - {ticker_name}")
continue continue
elif self._buy_paused_until and datetime.now() >= self._buy_paused_until:
self._buy_paused_until = None
self._consecutive_stop_losses_today = 0
print("[Bot] 매수 일시 중단 해제")
current_price = float(res['current_price']) current_price = float(res['current_price'])
if current_price <= 0: if current_price <= 0:
@@ -676,6 +564,31 @@ class AutoTradingBot:
required_amount = current_price * qty required_amount = current_price * qty
# [v3.2] 포트폴리오 리스크 게이트 검증 (테마 집중/동시보유 상한)
risk_holdings = [
{"ticker": c, "eval_amount": int(float(h.get("current_price", 0))
* int(h.get("qty", 0)))}
for c, h in current_holdings.items()
]
risk_dec = self.risk_gate.evaluate_buy(
ticker=ticker,
candidate_amount=int(required_amount),
current_holdings=risk_holdings,
total_capital=max(total_eval, 1),
)
if not risk_dec.allowed:
print(f"[Bot] [Skip Buy] RiskGate: {risk_dec.reason} ({ticker_name})")
continue
if risk_dec.max_allowed_amount < required_amount:
new_qty = int(risk_dec.max_allowed_amount / current_price)
if new_qty <= 0:
print(f"[Bot] [Skip Buy] RiskGate 부분허용 금액 부족 ({ticker_name})")
continue
print(f"[Bot] RiskGate 부분허용: qty {qty}{new_qty} "
f"({risk_dec.reason})")
qty = new_qty
required_amount = current_price * qty
# 예수금 확인 (tracking_deposit는 당일 누적 매수 차감 후 가용액) # 예수금 확인 (tracking_deposit는 당일 누적 매수 차감 후 가용액)
if tracking_deposit < required_amount: if tracking_deposit < required_amount:
qty = int(tracking_deposit / current_price) qty = int(tracking_deposit / current_price)
@@ -727,19 +640,16 @@ class AutoTradingBot:
) )
tracking_deposit -= required_amount tracking_deposit -= required_amount
# [v3.1] 사이클 간 추적 (KIS T+2 미차감 보완) self.ledger.record_buy(
self._today_buy_total += required_amount ticker, int(required_amount),
{"tech": res.get("tech", 0.5),
"sentiment": res.get("sentiment", 0.5),
"lstm": res.get("lstm_score", 0.5)},
)
buys_this_cycle += 1 buys_this_cycle += 1
print(f"[Bot] 당일 누적 매수: {self._today_buy_total:,}" print(f"[Bot] 당일 누적 매수: {self.ledger.today_buy_total:,}"
f"(잔여 예수금: {tracking_deposit:,}원)") f"(잔여 예수금: {tracking_deposit:,}원)")
# [v3.1] 앙상블 학습용 매수 신호 점수 보관 (매도 시 record_trade에 활용)
self._buy_scores[ticker] = {
"tech": res.get("tech", 0.5),
"sentiment": res.get("sentiment", 0.5),
"lstm": res.get("lstm_score", 0.5),
}
# 최고가 초기 설정 # 최고가 초기 설정
self.peak_prices[ticker] = current_price self.peak_prices[ticker] = current_price
self._save_peak_prices() self._save_peak_prices()
@@ -777,7 +687,7 @@ class AutoTradingBot:
self.perf_db.close_trade(ticker, sell_price, yld) self.perf_db.close_trade(ticker, sell_price, yld)
# [v3.1] 앙상블 학습 데이터 기록 (매수 시 저장한 신호 점수 + 실현 수익률) # [v3.1] 앙상블 학습 데이터 기록 (매수 시 저장한 신호 점수 + 실현 수익률)
buy_sig = self._buy_scores.pop(ticker, None) buy_sig = self.ledger.pop_buy_scores(ticker)
if buy_sig is not None: if buy_sig is not None:
try: try:
get_ensemble().record_trade( get_ensemble().record_trade(
@@ -793,22 +703,17 @@ class AutoTradingBot:
except Exception as _ee: except Exception as _ee:
print(f"[Bot] [Ensemble] record_trade 실패: {_ee}") print(f"[Bot] [Ensemble] record_trade 실패: {_ee}")
# [v2.1] 손절 횟수 추적 → 연속 3회 손절 시 매수 30분 일시 중단 # [v2.1] 손절 횟수 추적 → 연속 N회 손절 시 매수 일시 중단
if yld < 0: triggered = self.ledger.record_sell_outcome(yld, datetime.now())
self._consecutive_stop_losses_today += 1 if triggered:
if self._consecutive_stop_losses_today >= 3: warn_msg = (
self._buy_paused_until = datetime.now() + timedelta(minutes=30) f"⛔ <b>[매수 일시 중단]</b> 당일 손절 "
warn_msg = ( f"{self.ledger.consecutive_stop_losses}회 → "
f"⛔ <b>[매수 일시 중단]</b> 당일 손절 " f"{self.ledger.stop_loss_pause_minutes}분간 매수 정지 "
f"{self._consecutive_stop_losses_today}회 → " f"(재개: {self.ledger.buy_paused_until.strftime('%H:%M')})"
f"30분간 매수 정지 (재개: " )
f"{self._buy_paused_until.strftime('%H:%M')})" self.messenger.send_message(warn_msg)
) print(f"[Bot] 연속 손절 {self.ledger.consecutive_stop_losses}회 → 매수 일시 중단")
self.messenger.send_message(warn_msg)
print(f"[Bot] 연속 손절 {self._consecutive_stop_losses_today}회 → 매수 30분 중단")
else:
# 수익 실현 시 연속 손절 카운터 리셋
self._consecutive_stop_losses_today = 0
# 최고가 기록 삭제 # 최고가 기록 삭제
if ticker in self.peak_prices: if ticker in self.peak_prices:
@@ -838,27 +743,6 @@ class AutoTradingBot:
def loop(self): def loop(self):
print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.1]") print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.1]")
# [캘린더 체크] 오늘이 휴장일이면 알림 후 즉시 EOD 종료
if not self._calendar.is_trading_day():
summary = self._calendar.status_summary()
print(f"[Bot] 오늘은 휴장일 ({summary}) — 봇을 시작하지 않습니다.")
self.messenger.send_message(
f"[Bot] 오늘은 휴장일입니다.\n{summary}"
)
# EOD 마커 기록 후 종료
try:
from pathlib import Path
import datetime as _dt
eod_file = Path(Config.DATA_DIR) / ".eod_date"
eod_file.write_text(str(_dt.date.today()), encoding="utf-8")
except Exception:
pass
if self.eod_event:
self.eod_event.set()
if self.shutdown_event:
self.shutdown_event.set()
return
_llm_label = ( _llm_label = (
f"Gemini ({Config.GEMINI_MODEL})" f"Gemini ({Config.GEMINI_MODEL})"
if Config.GEMINI_API_KEY if Config.GEMINI_API_KEY

View File

@@ -51,6 +51,11 @@ class Config:
EOD_SHUTDOWN_BUFFER_MIN = int(os.getenv("EOD_SHUTDOWN_BUFFER_MIN", "5")) # 장 마감 후 EOD 처리까지 대기 분 EOD_SHUTDOWN_BUFFER_MIN = int(os.getenv("EOD_SHUTDOWN_BUFFER_MIN", "5")) # 장 마감 후 EOD 처리까지 대기 분
MAX_DAILY_BUY_RATIO = float(os.getenv("MAX_DAILY_BUY_RATIO", "0.80")) # 예수금 대비 일일 최대 매수 비율 MAX_DAILY_BUY_RATIO = float(os.getenv("MAX_DAILY_BUY_RATIO", "0.80")) # 예수금 대비 일일 최대 매수 비율
# 포트폴리오 리스크 게이트 (v3.2)
MAX_TICKERS_PER_THEME = int(os.getenv("MAX_TICKERS_PER_THEME", "2")) # 테마당 최대 종목 수
MAX_THEME_EXPOSURE_RATIO = float(os.getenv("MAX_THEME_EXPOSURE_RATIO", "0.40")) # 테마당 최대 노출 비율 (총자산 대비)
MAX_TOTAL_HOLDINGS = int(os.getenv("MAX_TOTAL_HOLDINGS", "7")) # 총 보유 종목 수 상한
# 6. 데이터 경로 # 6. 데이터 경로
DATA_DIR = os.path.join(BASE_DIR, "data") DATA_DIR = os.path.join(BASE_DIR, "data")
if not os.path.exists(DATA_DIR): if not os.path.exists(DATA_DIR):

View File

@@ -1,6 +1,23 @@
import time import time
import requests import requests
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import Optional
def _parse_items(root, max_items):
"""RSS item → [{title, url, pub_date, source}]"""
out = []
for item in root.findall(".//item")[:max_items]:
t = item.find("title")
l = item.find("link")
p = item.find("pubDate")
title = (t.text or "").strip() if t is not None else ""
url = (l.text or "").strip() if l is not None else ""
pub = (p.text or "").strip() if p is not None else ""
if not title:
continue
out.append({"title": title, "url": url, "pub_date": pub, "source": "Google News"})
return out
class NewsCollector: class NewsCollector:
@@ -11,24 +28,29 @@ class NewsCollector:
try: try:
resp = requests.get(url, timeout=5) resp = requests.get(url, timeout=5)
root = ET.fromstring(resp.content) root = ET.fromstring(resp.content)
items = [] return _parse_items(root, 5)
for item in root.findall(".//item")[:5]:
title = item.find("title").text
items.append({"title": title, "source": "Google News"})
return items
except Exception as e: except Exception as e:
print(f"[News] Collection failed: {e}") print(f"[News] Collection failed: {e}")
return [] return []
class AsyncNewsCollector: class AsyncNewsCollector:
"""비동기 뉴스 수집 + 5분 캐싱""" """비동기 뉴스 수집 + 5분 캐싱 + (옵션) 스냅샷 저장"""
def __init__(self): def __init__(self, snapshot_store=None):
self._cache = None self._cache = None
self._cache_time = 0 self._cache_time = 0
self._cache_ttl = 300 # 5분 self._cache_ttl = 300 # 5분
self._stock_cache = {} # {stock_name: (items, timestamp)} self._stock_cache = {} # {stock_name: (items, timestamp)}
self._snap = snapshot_store # NewsSnapshotStore | None
def _save_snapshot(self, items, query: str, ticker: Optional[str] = None):
if not self._snap or not items:
return
try:
self._snap.save_many(items, query=query, ticker=ticker)
except Exception as e:
print(f"[News] snapshot 저장 실패: {e}")
def get_market_news(self, query="주식 시장"): def get_market_news(self, query="주식 시장"):
"""동기 인터페이스 (하위 호환)""" """동기 인터페이스 (하위 호환)"""
@@ -39,6 +61,7 @@ class AsyncNewsCollector:
result = NewsCollector.get_market_news(query) result = NewsCollector.get_market_news(query)
self._cache = result self._cache = result
self._cache_time = now self._cache_time = now
self._save_snapshot(result, query=query)
return result return result
async def get_market_news_async(self, query="주식 시장"): async def get_market_news_async(self, query="주식 시장"):
@@ -54,13 +77,10 @@ class AsyncNewsCollector:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
content = await resp.read() content = await resp.read()
root = ET.fromstring(content) root = ET.fromstring(content)
items = [] items = _parse_items(root, 5)
for item in root.findall(".//item")[:5]:
title = item.find("title").text
items.append({"title": title, "source": "Google News"})
self._cache = items self._cache = items
self._cache_time = now self._cache_time = now
self._save_snapshot(items, query=query)
return items return items
except ImportError: except ImportError:
return self.get_market_news(query) return self.get_market_news(query)
@@ -70,9 +90,10 @@ class AsyncNewsCollector:
return self._cache return self._cache
return self.get_market_news(query) return self.get_market_news(query)
async def get_stock_news_async(self, stock_name, max_items=3): async def get_stock_news_async(self, stock_name, max_items=3, ticker: Optional[str] = None):
"""종목별 뉴스 수집 (5분 캐싱) """종목별 뉴스 수집 (5분 캐싱)
stock_name: 종목 이름 (e.g. '삼성전자', 'SK하이닉스') stock_name: 종목 이름 (e.g. '삼성전자', 'SK하이닉스')
ticker: 스냅샷 저장 시 종목코드 (옵션)
""" """
now = time.time() now = time.time()
cached = self._stock_cache.get(stock_name) cached = self._stock_cache.get(stock_name)
@@ -88,13 +109,9 @@ class AsyncNewsCollector:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
content = await resp.read() content = await resp.read()
root = ET.fromstring(content) root = ET.fromstring(content)
items = [] items = _parse_items(root, max_items)
for item in root.findall(".//item")[:max_items]:
title_el = item.find("title")
if title_el is not None and title_el.text:
items.append({"title": title_el.text, "source": "Google News"})
self._stock_cache[stock_name] = (items, now) self._stock_cache[stock_name] = (items, now)
self._save_snapshot(items, query=f"{stock_name} 주가", ticker=ticker)
return items return items
except Exception as e: except Exception as e:
print(f"[News] 종목 뉴스 수집 실패 ({stock_name}): {e}") print(f"[News] 종목 뉴스 수집 실패 ({stock_name}): {e}")

View File

@@ -0,0 +1,189 @@
"""
뉴스 스냅샷 인프라 (v3.2)
목적:
- 수집한 뉴스를 SQLite에 타임스탬프와 함께 영구 저장
- 사후 감성 신호 재검증 (LLM 재호출 / 모델 비교) 가능하게
- 백테스트에서 '그 시점에 실제로 알 수 있던 뉴스'만 사용
스키마:
news_snapshots(
id INTEGER PK,
captured_at TEXT, # ISO8601 (KST) — 수집 시점
query TEXT, # 수집 쿼리 (예: '주식 시장', '삼성전자')
ticker TEXT, # 종목 코드 (종목 뉴스일 때, else NULL)
title TEXT,
url TEXT UNIQUE,
pub_date TEXT, # RSS pubDate 원본
source TEXT DEFAULT 'google_news'
)
sentiment_scores( # 야간 배치로 사후 생성
news_id INTEGER PK,
scored_at TEXT,
model TEXT,
sentiment REAL, # -1.0 ~ 1.0
confidence REAL,
raw_json TEXT,
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
)
순수 I/O 모듈 — 네트워크 의존성 없음 → unit 테스트 가능.
"""
import os
import sqlite3
from datetime import datetime, timezone, timedelta
from typing import Iterable, List, Optional, Dict
KST = timezone(timedelta(hours=9))
class NewsSnapshotStore:
"""
SQLite 기반 뉴스 스냅샷 저장소.
사용 예:
store = NewsSnapshotStore("data/news_snapshots.db")
store.save_many(items, query="삼성전자", ticker="005930")
rows = store.query_between(start, end, ticker="005930")
"""
def __init__(self, db_path: str):
self.db_path = db_path
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
self._init_schema()
# ──────────────────────────────────────────────
# 스키마
# ──────────────────────────────────────────────
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _init_schema(self):
with self._connect() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS news_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
captured_at TEXT NOT NULL,
query TEXT NOT NULL,
ticker TEXT,
title TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
pub_date TEXT,
source TEXT DEFAULT 'google_news'
);
CREATE INDEX IF NOT EXISTS idx_news_captured
ON news_snapshots(captured_at);
CREATE INDEX IF NOT EXISTS idx_news_ticker
ON news_snapshots(ticker, captured_at);
CREATE TABLE IF NOT EXISTS sentiment_scores (
news_id INTEGER PRIMARY KEY,
scored_at TEXT NOT NULL,
model TEXT NOT NULL,
sentiment REAL NOT NULL,
confidence REAL NOT NULL,
raw_json TEXT,
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
);
""")
# ──────────────────────────────────────────────
# 쓰기
# ──────────────────────────────────────────────
def save_many(self, items: Iterable[Dict], query: str,
ticker: Optional[str] = None,
captured_at: Optional[datetime] = None) -> int:
"""
뉴스 다건 저장. URL 기준 중복 자동 무시.
Args:
items: [{"title": str, "url": str, "pub_date": str?}, ...]
Returns:
실제로 삽입된 행 수
"""
if captured_at is None:
captured_at = datetime.now(KST)
ts = captured_at.isoformat()
rows = []
for it in items:
title = (it.get("title") or "").strip()
url = (it.get("url") or "").strip()
if not title or not url:
continue
rows.append((ts, query, ticker, title, url, it.get("pub_date")))
if not rows:
return 0
with self._connect() as conn:
before = conn.total_changes
conn.executemany(
"INSERT OR IGNORE INTO news_snapshots "
"(captured_at, query, ticker, title, url, pub_date) "
"VALUES (?, ?, ?, ?, ?, ?)",
rows,
)
inserted = conn.total_changes - before
return inserted
def save_sentiment(self, news_id: int, model: str,
sentiment: float, confidence: float,
raw_json: str = "",
scored_at: Optional[datetime] = None) -> None:
if scored_at is None:
scored_at = datetime.now(KST)
with self._connect() as conn:
conn.execute(
"INSERT OR REPLACE INTO sentiment_scores "
"(news_id, scored_at, model, sentiment, confidence, raw_json) "
"VALUES (?, ?, ?, ?, ?, ?)",
(news_id, scored_at.isoformat(), model,
float(sentiment), float(confidence), raw_json),
)
# ──────────────────────────────────────────────
# 읽기
# ──────────────────────────────────────────────
def query_between(self, start: datetime, end: datetime,
ticker: Optional[str] = None,
query: Optional[str] = None) -> List[sqlite3.Row]:
"""특정 기간 내 수집된 뉴스 조회."""
sql = "SELECT * FROM news_snapshots WHERE captured_at >= ? AND captured_at < ?"
args = [start.isoformat(), end.isoformat()]
if ticker is not None:
sql += " AND ticker = ?"
args.append(ticker)
if query is not None:
sql += " AND query = ?"
args.append(query)
sql += " ORDER BY captured_at ASC"
with self._connect() as conn:
return list(conn.execute(sql, args))
def pending_sentiment(self, limit: int = 100) -> List[sqlite3.Row]:
"""아직 감성 점수가 없는 뉴스 반환 (야간 배치용)."""
with self._connect() as conn:
return list(conn.execute(
"""SELECT n.* FROM news_snapshots n
LEFT JOIN sentiment_scores s ON s.news_id = n.id
WHERE s.news_id IS NULL
ORDER BY n.captured_at DESC
LIMIT ?""",
(limit,)
))
def stats(self) -> Dict:
"""DB 통계 (row 수, 감성 커버리지)."""
with self._connect() as conn:
total = conn.execute("SELECT COUNT(*) FROM news_snapshots").fetchone()[0]
scored = conn.execute("SELECT COUNT(*) FROM sentiment_scores").fetchone()[0]
return {
"total_news": total,
"scored": scored,
"pending": total - scored,
"coverage_pct": (scored / total * 100) if total else 0.0,
}

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)

View File

@@ -6,17 +6,12 @@
""" """
import os import os
import time import time
import datetime
import threading import threading
from pathlib import Path
from multiprocessing.shared_memory import SharedMemory from multiprocessing.shared_memory import SharedMemory
from modules.config import Config from modules.config import Config
# EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용 # EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용
_EOD_DATE_FILE = Path("data") / ".eod_date"
class ProcessTracker: class ProcessTracker:
"""메모리 기반 프로세스 추적기""" """메모리 기반 프로세스 추적기"""
@@ -141,17 +136,6 @@ class ProcessWatchdog:
entry = self._watched.get(name) entry = self._watched.get(name)
return entry['process'] if entry else None return entry['process'] if entry else None
@staticmethod
def is_eod_today() -> bool:
"""오늘 EOD 마커 파일이 존재하면 True (장 마감 셧다운 → 재시작 차단)"""
try:
if not _EOD_DATE_FILE.exists():
return False
eod_date = datetime.date.fromisoformat(_EOD_DATE_FILE.read_text().strip())
return eod_date >= datetime.date.today()
except Exception:
return False
def _watchdog_loop(self): def _watchdog_loop(self):
"""주기적으로 자식 프로세스 상태 확인""" """주기적으로 자식 프로세스 상태 확인"""
import multiprocessing import multiprocessing
@@ -170,11 +154,6 @@ class ProcessWatchdog:
exit_code = proc.exitcode exit_code = proc.exitcode
restart_count = entry['restart_count'] restart_count = entry['restart_count']
# [EOD 차단] 오늘 장 마감 셧다운이면 재시작하지 않음
if ProcessWatchdog.is_eod_today():
print(f"[Watchdog] {name}: EOD 셧다운 감지 — 재시작 건너뜀.")
continue
if restart_count >= Config.MAX_RESTART_COUNT: if restart_count >= Config.MAX_RESTART_COUNT:
print(f"[Watchdog] {name} crashed (exit={exit_code}). " print(f"[Watchdog] {name} crashed (exit={exit_code}). "
f"Max restarts ({Config.MAX_RESTART_COUNT}) reached. Giving up.") f"Max restarts ({Config.MAX_RESTART_COUNT}) reached. Giving up.")