[2026-05-27] 포맷 후 복구 설치 스크립트 추가
This commit is contained in:
+352
-3
@@ -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}원)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user