[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
+5
View File
@@ -15,6 +15,7 @@ import math
import os
import sqlite3
import sys
from collections import Counter
from datetime import datetime, timedelta
from pathlib import Path
@@ -184,6 +185,8 @@ def main(print_mode: bool = False):
wins = [t for t in closed if (t["pnl"] or 0) > 0]
losses = [t for t in closed if (t["pnl"] or 0) <= 0]
net_pnl = sum(t["pnl"] or 0 for t in closed)
exit_reason_counts = Counter(t.get("exit_reason") or "UNKNOWN" for t in closed)
forced_count = exit_reason_counts.get("FORCE", 0)
summary = {
"date": TODAY,
@@ -193,6 +196,8 @@ def main(print_mode: bool = False):
"losses": len(losses),
"win_rate": round(len(wins) / len(closed) * 100, 1) if closed else 0,
"net_pnl": round(net_pnl),
"exit_reason_counts": dict(exit_reason_counts),
"force_exit_ratio": round(forced_count / len(closed) * 100, 1) if closed else 0,
"trades": trades,
},
"last_30_days": {
+1 -1
View File
@@ -62,7 +62,7 @@ async def get_status():
if open_rows:
kis = KISClient()
await kis.ensure_token()
await kis.get_access_token()
for ticker, name, entry_price, qty in open_rows:
try:
info = await kis.get_price(ticker)
+8 -1
View File
@@ -5,7 +5,7 @@ Claude Code가 이 파일을 읽고 필요시 수정함
# ── 변동성 돌파 ──
STRATEGY_K = 0.5
ENTRY_START = "09:05"
ENTRY_START = "09:15"
ENTRY_END = "14:30"
FORCE_EXIT = "14:50" # 절대 변경 불가
TP1_PCT = 0.020 # 1차 익절 +2.0% → 70% 매도
@@ -18,6 +18,13 @@ TICKER_REENTRY_COOLDOWN_MIN = 60 # 동일 종목 재진입 금지 시간(분)
# ── 리스크 ──
POS_SIZE_PCT = 0.20
MAX_POSITIONS = 2
ENTRY_LIMIT_ENFORCE = False
MAX_DAILY_ENTRIES = 30
MAX_HOURLY_STOP_LOSS = 4
ENTRY_PAUSE_WINDOWS = (
("11:00", "11:20"),
("14:00", "15:30"),
)
DAILY_SL_PCT = 0.03
CONSEC_LOSS = 3
AI_RISK_SL_MAP = {"낮음": 0.015, "보통": 0.015, "높음": 0.010}
+2 -1
View File
@@ -14,7 +14,8 @@ class DataCollector:
self.on_vi = on_vi
async def start(self, tickers: list):
self.ws.on_price("*", self.on_price)
for ticker in tickers:
self.ws.on_price(ticker, self.on_price)
self.ws.on_vi(self.on_vi)
await self.ws.subscribe(tickers)
+72
View File
@@ -7,6 +7,12 @@ import os
DB_PATH = os.getenv("DB_PATH", "data/stockbot.db")
def _ensure_columns(cursor, table: str, columns: dict[str, str]):
existing = {row[1] for row in cursor.execute(f"PRAGMA table_info({table})").fetchall()}
for name, ddl in columns.items():
if name not in existing:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}")
def init_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
@@ -80,6 +86,72 @@ def init_db():
api_call_success INTEGER DEFAULT 1
)""")
c.execute("""
CREATE TABLE IF NOT EXISTS entry_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_id INTEGER,
date TEXT NOT NULL,
ticker TEXT NOT NULL,
name TEXT,
entry_time TEXT NOT NULL,
current_price REAL,
entry_price REAL,
target_price REAL,
stop_price REAL,
today_open REAL,
prev_high REAL,
prev_low REAL,
prev_amount REAL,
volume REAL,
change_pct REAL,
market_sentiment TEXT,
sentiment_score INTEGER,
risk_level TEXT,
trade_allowed INTEGER,
hot_sectors TEXT,
avoid_sectors TEXT,
boosted_tickers TEXT,
blacklist_tickers TEXT,
ai_boosted INTEGER DEFAULT 0,
ai_win_score REAL,
ai_stop_loss_score REAL,
ai_model_version TEXT,
position_size_multiplier REAL,
combined_multiplier REAL,
entry_reason TEXT,
strategy TEXT DEFAULT 'VB',
created_at TEXT NOT NULL
)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_entry_snapshots_trade_id ON entry_snapshots(trade_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_entry_snapshots_date_ticker ON entry_snapshots(date, ticker)")
_ensure_columns(c, "entry_snapshots", {
"ai_win_score": "REAL",
"ai_stop_loss_score": "REAL",
"ai_model_version": "TEXT",
})
c.execute("""
CREATE TABLE IF NOT EXISTS post_entry_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_id INTEGER,
date TEXT NOT NULL,
ticker TEXT NOT NULL,
sample_time TEXT NOT NULL,
elapsed_sec INTEGER NOT NULL,
entry_price REAL,
current_price REAL,
return_pct REAL,
mfe_pct REAL,
mae_pct REAL,
volume REAL,
change_pct REAL,
position_open INTEGER DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(trade_id, elapsed_sec)
)""")
c.execute("CREATE INDEX IF NOT EXISTS idx_post_entry_snapshots_trade_id ON post_entry_snapshots(trade_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_post_entry_snapshots_date_ticker ON post_entry_snapshots(date, ticker)")
conn.commit()
conn.close()
print(f"DB 초기화 완료: {DB_PATH}")
+39
View File
@@ -264,6 +264,45 @@ class KISClient:
# 주문
# ─────────────────────────────────────────
async def get_ohlcv_minute(self, ticker: str, hour: str = "153000") -> list:
"""Domestic stock intraday minute bars from KIS."""
data = await self._request(
method="GET",
path="/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
tr_id="FHKST03010200",
params={
"FID_ETC_CLS_CODE": "",
"FID_COND_MRKT_DIV_CODE": "J",
"FID_INPUT_ISCD": ticker,
"FID_INPUT_HOUR_1": hour,
"FID_PW_DATA_INCU_YN": "Y",
},
)
def _num(row: dict, *keys: str, default=0):
for key in keys:
value = row.get(key)
if value not in (None, ""):
try:
return int(float(str(value).replace(",", "")))
except (TypeError, ValueError):
return default
return default
result = []
for row in data.get("output2", []) or data.get("output", []):
result.append({
"date": row.get("stck_bsop_date") or row.get("bsop_date") or datetime.now().strftime("%Y%m%d"),
"time": row.get("stck_cntg_hour") or row.get("stck_bsop_hour") or row.get("cntg_hour") or "",
"ticker": ticker,
"open": _num(row, "stck_oprc", "oprc"),
"high": _num(row, "stck_hgpr", "hgpr"),
"low": _num(row, "stck_lwpr", "lwpr"),
"close": _num(row, "stck_prpr", "prpr", "stck_clpr", "clpr"),
"volume": _num(row, "cntg_vol", "acml_vol", "vol"),
})
return sorted(result, key=lambda r: (r["date"], r["time"]))
async def order_buy(
self,
ticker : str,
+109 -40
View File
@@ -1,108 +1,177 @@
"""
execution/order_executor.py
주문 실행 모듈
DRY_RUN=true 시 실제 주문 전송 없음
Order execution and trade persistence.
DRY_RUN=true means KISClient simulates order fills with the current quote.
"""
import os
import asyncio
import logging
from datetime import datetime
from app.execution.kis_client import KISClient
from app.db.models import get_conn
from app.config import FEE_RATE, TAX_RATE
from app.db.models import get_conn
from app.execution.kis_client import KISClient
logger = logging.getLogger(__name__)
class OrderExecutor:
def __init__(self, kis: KISClient):
self.kis = kis
self.kis = kis
self.dry_run = os.getenv("DRY_RUN", "true").lower() == "true"
def _calc_fee(self, price: float, qty: int, is_buy: bool) -> float:
amt = price * qty
return amt * (FEE_RATE + (0 if is_buy else TAX_RATE))
async def buy(self, ticker: str, name: str,
qty: int, reason: str = "",
ai_boosted: bool = False) -> dict:
"""시장가 매수"""
async def buy(
self,
ticker: str,
name: str,
qty: int,
reason: str = "",
ai_boosted: bool = False,
) -> dict:
"""Submit a market buy and save the opened trade."""
try:
result = await self.kis.order_buy(ticker, qty)
price = result.get("entry_price", 0)
price = result.get("entry_price", 0)
if not price:
price = (await self.kis.get_price(ticker))["current"]
# DB 저장
fee = self._calc_fee(price, qty, True)
self._save_trade(
ticker=ticker, name=name,
entry_price=price, qty=qty,
side="BUY", fee=fee,
trade_id = self._save_trade(
ticker=ticker,
name=name,
entry_price=price,
qty=qty,
side="BUY",
fee=fee,
ai_boosted=ai_boosted,
)
mode = "[DRY]" if self.dry_run else ""
logger.info(f"{mode} 매수 {name}({ticker}) {qty} @ {price:,}")
return {"success": True, "price": price, "qty": qty}
logger.info("%s BUY %s(%s) %s @ %s", mode, name, ticker, qty, price)
return {"success": True, "price": price, "qty": qty, "trade_id": trade_id}
except Exception as e:
logger.error(f"매수 실패 {ticker}: {e}")
logger.error("BUY failed %s: %s", ticker, e)
return {"success": False, "error": str(e)}
async def sell(self, ticker: str, name: str,
qty: int, reason: str = "") -> dict:
"""시장가 매도"""
async def sell(
self,
ticker: str,
name: str,
qty: int,
reason: str = "",
) -> dict:
"""Submit a market sell and save full or partial exit results."""
try:
result = await self.kis.order_sell(ticker, qty)
price = result.get("exit_price", 0)
price = result.get("exit_price", 0)
if not price:
price = (await self.kis.get_price(ticker))["current"]
fee = self._calc_fee(price, qty, False)
self._update_trade_exit(
ticker=ticker, exit_price=price,
qty=qty, reason=reason, fee=fee,
ticker=ticker,
exit_price=price,
qty=qty,
reason=reason,
fee=fee,
)
mode = "[DRY]" if self.dry_run else ""
logger.info(f"{mode} 매도 {name}({ticker}) {qty} @ {price:,} [{reason}]")
logger.info("%s SELL %s(%s) %s @ %s [%s]", mode, name, ticker, qty, price, reason)
return {"success": True, "price": price, "qty": qty}
except Exception as e:
logger.error(f"매도 실패 {ticker}: {e}")
logger.error("SELL failed %s: %s", ticker, e)
return {"success": False, "error": str(e)}
def _save_trade(self, ticker, name, entry_price,
qty, side, fee, ai_boosted=False):
def _save_trade(self, ticker, name, entry_price, qty, side, fee, ai_boosted=False):
with get_conn() as conn:
conn.execute("""
cur = conn.execute("""
INSERT INTO trades
(date, ticker, name, entry_time, entry_price,
quantity, side, fee, ai_boosted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
datetime.now().strftime("%Y-%m-%d"),
ticker, name,
ticker,
name,
datetime.now().strftime("%H:%M:%S"),
entry_price, qty, side, fee,
entry_price,
qty,
side,
fee,
1 if ai_boosted else 0,
))
return cur.lastrowid
def _update_trade_exit(self, ticker, exit_price,
qty, reason, fee):
def _update_trade_exit(self, ticker, exit_price, qty, reason, fee):
with get_conn() as conn:
row = conn.execute("""
SELECT id, entry_price, quantity FROM trades
SELECT id, date, name, entry_time, entry_price, quantity,
side, fee, ai_boosted
FROM trades
WHERE ticker=? AND exit_time IS NULL
ORDER BY id DESC LIMIT 1
""", (ticker,)).fetchone()
if not row:
logger.warning("No open trade row found for exit: %s", ticker)
return
trade_id, entry_price, trade_qty = row
(trade_id, trade_date, name, entry_time, entry_price, trade_qty,
side, entry_fee, ai_boosted) = row
actual_qty = qty if qty else trade_qty
pnl = (exit_price - entry_price) * actual_qty - fee
actual_qty = min(actual_qty, trade_qty)
entry_fee = entry_fee or 0
allocated_entry_fee = entry_fee * (actual_qty / trade_qty) if trade_qty else 0
total_fee = allocated_entry_fee + fee
pnl = (exit_price - entry_price) * actual_qty - total_fee
exit_time = datetime.now().strftime("%H:%M:%S")
if actual_qty < trade_qty:
remaining_qty = trade_qty - actual_qty
remaining_fee = entry_fee - allocated_entry_fee
conn.execute("""
UPDATE trades
SET quantity=?, fee=?
WHERE id=?
""", (remaining_qty, remaining_fee, trade_id))
conn.execute("""
INSERT INTO trades
(date, ticker, name, entry_time, exit_time, entry_price,
exit_price, quantity, side, exit_reason, pnl, fee,
ai_boosted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
trade_date,
ticker,
name,
entry_time,
exit_time,
entry_price,
exit_price,
actual_qty,
side,
reason,
pnl,
total_fee,
ai_boosted,
))
return
conn.execute("""
UPDATE trades
SET exit_time=?, exit_price=?, exit_reason=?, fee=fee+?, pnl=?
SET exit_time=?, exit_price=?, exit_reason=?, fee=?, pnl=?
WHERE id=?
""", (
datetime.now().strftime("%H:%M:%S"),
exit_price, reason, fee, pnl, trade_id,
exit_time,
exit_price,
reason,
total_fee,
pnl,
trade_id,
))
+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}원)")
+1
View File
@@ -0,0 +1 @@
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Iterable
import pandas as pd
LABEL_COLUMNS = {"label_win", "label_stop_loss"}
EXCLUDED_COLUMNS = {
"id",
"trade_id",
"date",
"ticker",
"name",
"entry_time",
"exit_time",
"sample_time",
"created_at",
"exit_reason",
"strategy",
"reason",
"source_file",
"ai_win_score",
"ai_stop_loss_score",
"ai_model_version",
}
def select_feature_columns(df: pd.DataFrame, targets: Iterable[str] = LABEL_COLUMNS) -> list[str]:
excluded = EXCLUDED_COLUMNS | set(targets)
numeric_columns = [
column
for column in df.columns
if column not in excluded and pd.api.types.is_numeric_dtype(df[column])
]
return sorted(numeric_columns)
def build_feature_matrix(
df: pd.DataFrame,
feature_columns: list[str],
medians: dict[str, float] | None = None,
) -> tuple[pd.DataFrame, dict[str, float]]:
features = df.reindex(columns=feature_columns)
features = features.apply(pd.to_numeric, errors="coerce")
if medians is None:
medians = {
column: float(value) if pd.notna(value) else 0.0
for column, value in features.median(numeric_only=True).items()
}
features = features.fillna(medians).fillna(0.0)
return features, medians
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import joblib
import pandas as pd
from app.ml.features import build_feature_matrix
DEFAULT_MODEL_PATH = Path("models/scalping_model.joblib")
class ScalpingModel:
def __init__(self, model_path: str | Path = DEFAULT_MODEL_PATH):
self.model_path = Path(model_path)
self.bundle: dict[str, Any] | None = None
@property
def available(self) -> bool:
return self.model_path.exists()
def load(self) -> bool:
if not self.available:
return False
self.bundle = joblib.load(self.model_path)
return True
@property
def version(self) -> str:
if self.bundle is None and not self.load():
return ""
assert self.bundle is not None
return str(self.bundle.get("created_at", ""))
def score(self, row: dict[str, Any]) -> dict[str, float]:
if self.bundle is None and not self.load():
return {}
assert self.bundle is not None
frame = pd.DataFrame([row])
features, _ = build_feature_matrix(
frame,
self.bundle["feature_columns"],
self.bundle.get("medians"),
)
scores: dict[str, float] = {}
for target, model in self.bundle.get("models", {}).items():
if hasattr(model, "predict_proba"):
scores[target] = float(model.predict_proba(features)[0][1])
else:
scores[target] = float(model.predict(features)[0])
return scores
+5
View File
@@ -6,3 +6,8 @@ redis==5.0.7
streamlit==1.36.0
pandas==2.2.2
numpy==1.26.4
holidays==0.48
pykrx==1.0.48
finance-datareader==0.9.94
scikit-learn==1.5.1
joblib==1.4.2