[2026-05-27] 포맷 후 복구 설치 스크립트 추가

This commit is contained in:
2026-05-27 16:53:52 +09:00
parent 04577c63f1
commit 29db1bfcab
135 changed files with 2909 additions and 251 deletions
+352 -3
View File
@@ -18,7 +18,7 @@ import os
import sys
import asyncio
import logging
from datetime import datetime, time
from datetime import datetime, time, timedelta
from pathlib import Path
# 한글 로그 깨짐 방지 — stdout을 UTF-8로 강제
@@ -76,9 +76,12 @@ from app.monitor.notifier import (
notify_ai_fallback, send
)
from app.db.models import init_db, get_conn
from app.ml.predictor import ScalpingModel
from app.config import (
MAX_UNIVERSE, FORCE_EXIT, MAX_POSITIONS,
MAX_HOLD_MIN, KOSPI_MIN_CHG
MAX_HOLD_MIN, KOSPI_MIN_CHG, MAX_DAILY_ENTRIES,
MAX_HOURLY_STOP_LOSS, ENTRY_PAUSE_WINDOWS,
ENTRY_LIMIT_ENFORCE
)
@@ -93,6 +96,7 @@ class StockBot:
self.sl_tickers = set() # 당일 SL 당한 종목 — 재진입 차단
self.risk = None # RiskManager (잔고 확인 후 초기화)
self.running = False
self.scalping_model = ScalpingModel()
# 장중 컨텍스트 (midday_context.json 갱신 감지용)
self._midday_ctx_mtime : float = 0.0
@@ -147,6 +151,296 @@ class StockBot:
except Exception as e:
logger.warning(f"midday_context 로드 실패: {e}")
def _today_entry_count(self) -> int:
today = datetime.now().strftime("%Y-%m-%d")
with get_conn() as conn:
row = conn.execute(
"SELECT COUNT(DISTINCT ticker || '|' || entry_time) FROM trades WHERE date=? AND side='BUY'",
(today,),
).fetchone()
return int(row[0] or 0)
def _recent_stop_loss_count(self, minutes: int = 60) -> int:
cutoff = (datetime.now() - timedelta(minutes=minutes)).strftime("%H:%M:%S")
today = datetime.now().strftime("%Y-%m-%d")
with get_conn() as conn:
row = conn.execute("""
SELECT COUNT(*) FROM trades
WHERE date=? AND exit_reason='SL' AND exit_time >= ?
""", (today, cutoff)).fetchone()
return int(row[0] or 0)
def _entry_gate_reason(self, now_str: str) -> str:
for start, end in ENTRY_PAUSE_WINDOWS:
if start <= now_str < end:
return f"entry pause window {start}-{end}"
entries = self._today_entry_count()
if ENTRY_LIMIT_ENFORCE and entries >= MAX_DAILY_ENTRIES:
return f"daily entry limit reached {entries}/{MAX_DAILY_ENTRIES}"
stop_losses = self._recent_stop_loss_count(60)
if ENTRY_LIMIT_ENFORCE and stop_losses >= MAX_HOURLY_STOP_LOSS:
return f"{stop_losses} stop losses in last 60 minutes"
return ""
def _entry_warning_reason(self) -> str:
warnings = []
entries = self._today_entry_count()
if entries >= MAX_DAILY_ENTRIES:
warnings.append(f"daily entries high {entries}/{MAX_DAILY_ENTRIES}")
stop_losses = self._recent_stop_loss_count(60)
if stop_losses >= MAX_HOURLY_STOP_LOSS:
warnings.append(f"{stop_losses} stop losses in last 60 minutes")
return "; ".join(warnings)
def _log_entry_acceptance(
self,
ticker: str,
name: str,
current: float,
target: float,
qty: int,
multiplier: float,
reason: str,
):
logger.info(
"ENTRY accepted %s(%s) current=%s target=%.0f qty=%s mult=%.2f reason=%s",
name,
ticker,
current,
target,
qty,
multiplier,
reason,
)
def _save_entry_snapshot(
self,
trade_id: int | None,
ticker: str,
name: str,
price_info: dict,
target: float,
entry_price: float,
stop_price: float,
qty: int,
signal: dict,
combined_mult: float,
model_scores: dict | None = None,
):
ctx = self.strategy.context
prev = self.strategy.prev_data.get(ticker, {})
now = datetime.now()
model_scores = model_scores or {}
with get_conn() as conn:
conn.execute("""
INSERT INTO entry_snapshots
(trade_id, date, ticker, name, entry_time, current_price,
entry_price, target_price, stop_price, today_open, prev_high,
prev_low, prev_amount, volume, change_pct, market_sentiment,
sentiment_score, risk_level, trade_allowed, hot_sectors,
avoid_sectors, boosted_tickers, blacklist_tickers, ai_boosted,
ai_win_score, ai_stop_loss_score, ai_model_version,
position_size_multiplier, combined_multiplier, entry_reason,
strategy, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
trade_id,
now.strftime("%Y-%m-%d"),
ticker,
name,
now.strftime("%H:%M:%S"),
price_info.get("current"),
entry_price,
target,
stop_price,
self.strategy.today_open.get(ticker),
prev.get("high"),
prev.get("low"),
prev.get("amount"),
price_info.get("volume"),
price_info.get("change_pct"),
ctx.get("market_sentiment"),
ctx.get("sentiment_score"),
ctx.get("risk_level"),
1 if ctx.get("trade_allowed", True) else 0,
json.dumps(ctx.get("hot_sectors", []), ensure_ascii=False),
json.dumps(ctx.get("avoid_sectors", []), ensure_ascii=False),
json.dumps(ctx.get("boosted_tickers", []), ensure_ascii=False),
json.dumps(ctx.get("blacklist_tickers", []), ensure_ascii=False),
1 if signal.get("boosted") else 0,
model_scores.get("label_win"),
model_scores.get("label_stop_loss"),
model_scores.get("model_version"),
signal.get("multiplier", 1.0),
combined_mult,
signal.get("reason", ""),
"VB",
now.isoformat(timespec="seconds"),
))
def _build_entry_feature_row(
self,
ticker: str,
price_info: dict,
target: float,
entry_price: float,
stop_price: float,
signal: dict,
combined_mult: float,
) -> dict:
ctx = self.strategy.context
prev = self.strategy.prev_data.get(ticker, {})
return {
"current_price": price_info.get("current"),
"entry_price": entry_price,
"target_price": target,
"stop_price": stop_price,
"today_open": self.strategy.today_open.get(ticker),
"prev_high": prev.get("high"),
"prev_low": prev.get("low"),
"prev_amount": prev.get("amount"),
"volume": price_info.get("volume"),
"change_pct": price_info.get("change_pct"),
"sentiment_score": ctx.get("sentiment_score"),
"trade_allowed": 1 if ctx.get("trade_allowed", True) else 0,
"ai_boosted": 1 if signal.get("boosted") else 0,
"position_size_multiplier": signal.get("multiplier", 1.0),
"combined_multiplier": combined_mult,
}
def _score_entry_candidate(
self,
ticker: str,
name: str,
price_info: dict,
target: float,
entry_price: float,
stop_price: float,
signal: dict,
combined_mult: float,
) -> dict:
if not self.scalping_model.available:
return {}
try:
row = self._build_entry_feature_row(
ticker=ticker,
price_info=price_info,
target=target,
entry_price=entry_price,
stop_price=stop_price,
signal=signal,
combined_mult=combined_mult,
)
scores = self.scalping_model.score(row)
if scores:
scores["model_version"] = self.scalping_model.version
logger.info(
"AI_SCORE %s(%s) win=%.3f stop_loss=%.3f",
name,
ticker,
scores.get("label_win", -1),
scores.get("label_stop_loss", -1),
)
return scores
except Exception as e:
logger.warning("AI score failed %s(%s): %s", name, ticker, e)
return {}
def _save_post_entry_snapshot(
self,
trade_id: int | None,
ticker: str,
elapsed_sec: int,
entry_price: float,
price_info: dict,
mfe_pct: float,
mae_pct: float,
position_open: bool,
):
if trade_id is None:
return
current = price_info.get("current")
if not entry_price or current is None:
return
now = datetime.now()
return_pct = (current - entry_price) / entry_price * 100
with get_conn() as conn:
conn.execute("""
INSERT OR REPLACE INTO post_entry_snapshots
(trade_id, date, ticker, sample_time, elapsed_sec, entry_price,
current_price, return_pct, mfe_pct, mae_pct, volume,
change_pct, position_open, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
trade_id,
now.strftime("%Y-%m-%d"),
ticker,
now.strftime("%H:%M:%S"),
elapsed_sec,
entry_price,
current,
return_pct,
mfe_pct,
mae_pct,
price_info.get("volume"),
price_info.get("change_pct"),
1 if position_open else 0,
now.isoformat(timespec="seconds"),
))
async def _track_post_entry(
self,
trade_id: int | None,
ticker: str,
name: str,
entry_price: float,
):
if trade_id is None:
return
checkpoints = (60, 180, 300, 600)
start_ts = datetime.now().timestamp()
high = entry_price
low = entry_price
for elapsed_sec in checkpoints:
delay = max(0, start_ts + elapsed_sec - datetime.now().timestamp())
await asyncio.sleep(delay)
try:
price_info = await self.kis.get_price(ticker)
current = price_info["current"]
high = max(high, current)
low = min(low, current)
mfe_pct = (high - entry_price) / entry_price * 100
mae_pct = (low - entry_price) / entry_price * 100
self._save_post_entry_snapshot(
trade_id=trade_id,
ticker=ticker,
elapsed_sec=elapsed_sec,
entry_price=entry_price,
price_info=price_info,
mfe_pct=mfe_pct,
mae_pct=mae_pct,
position_open=ticker in self.positions,
)
logger.info(
"POST_ENTRY %s(%s) t=%ss current=%s mfe=%.2f%% mae=%.2f%% open=%s",
name,
ticker,
elapsed_sec,
current,
mfe_pct,
mae_pct,
ticker in self.positions,
)
except Exception as e:
logger.warning("post-entry snapshot failed %s t=%ss: %s", ticker, elapsed_sec, e)
# ─────────────────────────────────────────
# 초기화
# ─────────────────────────────────────────
@@ -404,6 +698,13 @@ class StockBot:
# lunch_trade_allowed=false이면 점심 세션 진입 차단
if self._midday_loaded and not self.strategy.context.get("lunch_trade_allowed", True):
return
gate_reason = self._entry_gate_reason(now_str)
if gate_reason:
logger.info("ENTRY blocked: %s", gate_reason)
return
warning_reason = self._entry_warning_reason()
if warning_reason:
logger.warning("ENTRY warning: %s", warning_reason)
_now_ts = datetime.now().timestamp()
_do_diag = (_now_ts - self._last_diag) >= 300 # 5분마다 진단 로그
@@ -456,6 +757,15 @@ class StockBot:
)
invest = self.risk.get_pos_size(cash, combined_mult)
qty = max(1, int(invest // current))
self._log_entry_acceptance(
ticker=ticker,
name=name,
current=current,
target=target,
qty=qty,
multiplier=combined_mult,
reason=signal["reason"],
)
result = await self.executor.buy(
ticker=ticker, name=name,
@@ -466,6 +776,37 @@ class StockBot:
if result["success"]:
entry_price = result["price"] or current
sl_price = entry_price * (1 - self.risk.get_sl_pct())
model_scores = self._score_entry_candidate(
ticker=ticker,
name=name,
price_info=price_info,
target=target,
entry_price=entry_price,
stop_price=sl_price,
signal=signal,
combined_mult=combined_mult,
)
self._save_entry_snapshot(
trade_id=result.get("trade_id"),
ticker=ticker,
name=name,
price_info=price_info,
target=target,
entry_price=entry_price,
stop_price=sl_price,
qty=qty,
signal=signal,
combined_mult=combined_mult,
model_scores=model_scores,
)
asyncio.create_task(
self._track_post_entry(
trade_id=result.get("trade_id"),
ticker=ticker,
name=name,
entry_price=entry_price,
)
)
pos = {
"name" : name,
@@ -621,7 +962,7 @@ class StockBot:
today = datetime.now().strftime("%Y-%m-%d")
with get_conn() as conn:
rows = conn.execute("""
SELECT pnl, fee FROM trades
SELECT pnl, fee, exit_reason FROM trades
WHERE date=? AND exit_time IS NOT NULL
""", (today,)).fetchall()
@@ -635,6 +976,10 @@ class StockBot:
net = sum(pnls)
mdd = min(self.risk.daily_pnl / self.risk.init_cash * 100, 0.0)
stopped = 0 if self.risk.can_trade() else 1
exit_counts = {}
for _, _, reason in rows:
key = reason or "UNKNOWN"
exit_counts[key] = exit_counts.get(key, 0) + 1
# daily_summary 테이블 저장
with get_conn() as conn:
@@ -646,6 +991,10 @@ class StockBot:
""", (today, total, wins, losses, gross_pnl, total_fee, net, mdd, stopped))
await notify_daily_summary(total, wins, losses, net)
if exit_counts:
dist = " / ".join(f"{k}:{v}" for k, v in sorted(exit_counts.items()))
logger.info("Exit distribution: %s", dist)
await send(f"[청산분포] {dist}")
self.risk.reset_daily()
logger.info(f"결산: {total}회 / 승{wins}{losses} / {net:+,.0f}원 (fee {total_fee:,.0f}원)")