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