""" kis_trader/database/paper_store.py — 페이퍼 매매 전용 테이블 (실매와 완전 분리) 실매 ``active_trades`` / ``orders`` / ``trade_history`` 와 스키마를 맞추되 테이블을 분리해 보유·매도·고아복구·브로커 대조에 절대 섞이지 않게 한다. - active_trades_paper - orders_paper - trade_history_paper """ from __future__ import annotations import datetime import logging import uuid from typing import Any, Dict, List, Optional logger = logging.getLogger("kis_trader.paper_store") def ensure_paper_tables(db) -> None: """TradeDB.conn 에 paper 3테이블 생성 (없으면).""" conn = db.conn with conn: conn.execute( """ CREATE TABLE IF NOT EXISTS active_trades_paper ( code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL', PRIMARY KEY (code, strategy), avg_buy_price DOUBLE NOT NULL, current_price DOUBLE, stop_price DOUBLE, target_price DOUBLE, max_price DOUBLE, atr_entry DOUBLE, target_qty INT NOT NULL, current_qty INT NOT NULL, total_invested DOUBLE, status VARCHAR(20) NOT NULL, buy_date VARCHAR(30) NOT NULL, updated_at VARCHAR(30) NOT NULL, size_class VARCHAR(20), fill_mode VARCHAR(16) NOT NULL DEFAULT 'paper', note VARCHAR(200) DEFAULT NULL ) CHARACTER SET utf8mb4 """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS orders_paper ( id BIGINT NOT NULL AUTO_INCREMENT, ord_no VARCHAR(40) NOT NULL, strategy_id VARCHAR(40) NOT NULL, code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL DEFAULT '', side VARCHAR(8) NOT NULL, qty INT NOT NULL, price DOUBLE DEFAULT 0, filled_qty INT NOT NULL DEFAULT 0, filled_avg_price DOUBLE DEFAULT 0, status VARCHAR(16) NOT NULL DEFAULT 'FILLED', msg_cd VARCHAR(20) DEFAULT NULL, msg1 VARCHAR(300) DEFAULT NULL, ord_date VARCHAR(10) NOT NULL, submitted_at VARCHAR(30) NOT NULL, filled_at VARCHAR(30) DEFAULT NULL, raw_json MEDIUMTEXT DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uq_paper_ord_no_ctx (ord_no, strategy_id, code, side, ord_date), INDEX idx_paper_strategy_date (strategy_id, ord_date), INDEX idx_paper_code_date (code, ord_date) ) CHARACTER SET utf8mb4 """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS trade_history_paper ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, strategy VARCHAR(50), buy_price DOUBLE NOT NULL, sell_price DOUBLE NOT NULL, qty INT NOT NULL, profit_rate DOUBLE NOT NULL, realized_pnl DOUBLE NOT NULL, hold_minutes INT, buy_date VARCHAR(30), sell_date VARCHAR(30) NOT NULL, sell_reason VARCHAR(200), env_snapshot TEXT, size_class VARCHAR(20), fill_mode VARCHAR(16) NOT NULL DEFAULT 'paper', INDEX idx_thp_strategy_sell (strategy, sell_date), INDEX idx_thp_code_sell (code, sell_date) ) CHARACTER SET utf8mb4 """ ) def _new_paper_ord_no(side: str) -> str: side_u = (side or "X").upper()[:1] ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S") return f"PAPER-{side_u}{ts}-{uuid.uuid4().hex[:8]}" def insert_order_paper( db, *, strategy_id: str, code: str, name: str, side: str, qty: int, price: float, filled_qty: Optional[int] = None, status: str = "FILLED", msg1: str = "paper", ) -> str: """즉시 체결 가정으로 orders_paper INSERT. 반환: ord_no.""" ensure_paper_tables(db) side_u = (side or "").upper() assert side_u in ("BUY", "SELL"), side_u now = datetime.datetime.now() ord_no = _new_paper_ord_no(side_u) fq = int(filled_qty if filled_qty is not None else qty) with db.conn: db.conn.execute( """ INSERT INTO orders_paper ( ord_no, strategy_id, code, name, side, qty, price, filled_qty, filled_avg_price, status, msg1, ord_date, submitted_at, filled_at ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) """, ( ord_no, strategy_id, code, name or code, side_u, int(qty), float(price or 0), fq, float(price or 0), status, msg1, now.strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d %H:%M:%S"), now.strftime("%Y-%m-%d %H:%M:%S"), ), ) return ord_no def upsert_active_trade_paper(db, trade_data: Dict[str, Any]) -> bool: """active_trades_paper UPSERT (실매 upsert_trade 와 동일 의미, ML컬럼 생략).""" ensure_paper_tables(db) code = str(trade_data.get("code") or "").strip() if not code: return False now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: from kis_trader.utils.strategy_ids import canonical_strategy_id strategy = canonical_strategy_id(trade_data.get("strategy", "MANUAL")) except Exception: strategy = trade_data.get("strategy", "MANUAL") or "MANUAL" avg = float(trade_data.get("avg_buy_price") or trade_data.get("buy_price") or 0) qty = int(trade_data.get("current_qty") or trade_data.get("qty") or 0) try: with db.conn: db.conn.execute( """ INSERT INTO active_trades_paper ( code, name, strategy, avg_buy_price, current_price, stop_price, target_price, max_price, atr_entry, target_qty, current_qty, total_invested, status, buy_date, updated_at, size_class, fill_mode, note ) VALUES ( %s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s ) ON DUPLICATE KEY UPDATE avg_buy_price = VALUES(avg_buy_price), current_price = VALUES(current_price), stop_price = COALESCE(VALUES(stop_price), stop_price), target_price = COALESCE(VALUES(target_price), target_price), atr_entry = COALESCE(VALUES(atr_entry), atr_entry), current_qty = VALUES(current_qty), target_qty = VALUES(target_qty), total_invested = VALUES(total_invested), max_price = GREATEST(COALESCE(max_price,0), COALESCE(VALUES(max_price),0)), status = VALUES(status), updated_at = VALUES(updated_at), size_class = COALESCE(VALUES(size_class), size_class), note = COALESCE(VALUES(note), note) """, ( code, trade_data.get("name") or code, strategy, avg, float(trade_data.get("current_price") or avg), float(trade_data.get("stop_price") or 0), float(trade_data.get("target_price") or 0), float(trade_data.get("max_price") or avg), float(trade_data.get("atr_entry") or trade_data.get("atr_at_entry") or 0), int(trade_data.get("target_qty") or qty), qty, float(trade_data.get("total_invested") or (avg * qty)), trade_data.get("status") or "HOLDING", trade_data.get("buy_date") or now, now, trade_data.get("size_class") or "", trade_data.get("fill_mode") or "paper", trade_data.get("note"), ), ) return True except Exception as e: logger.error("upsert_active_trade_paper 실패 (%s): %s", code, e) return False def get_active_trades_paper( db, *, strategy_id: Optional[str] = None ) -> Dict[str, Dict[str, Any]]: """strategy 정확 일치(또는 전체). 반환 {code: holding_dict} — Base holdings 호환.""" ensure_paper_tables(db) try: if strategy_id: rows = db.conn.execute( "SELECT * FROM active_trades_paper WHERE strategy=%s", (strategy_id,), ).fetchall() else: rows = db.conn.execute("SELECT * FROM active_trades_paper").fetchall() out: Dict[str, Dict[str, Any]] = {} for row in rows: code = row["code"] out[code] = { "code": code, "name": row["name"], "strategy": row["strategy"], "buy_price": row["avg_buy_price"], "avg_buy_price": row["avg_buy_price"], "current_price": row["current_price"], "stop_price": row["stop_price"], "target_price": row["target_price"], "max_price": row["max_price"], "atr_at_entry": row["atr_entry"], "qty": row["current_qty"], "target_qty": row["target_qty"], "current_qty": row["current_qty"], "total_invested": row["total_invested"], "status": row["status"], "buy_date": row["buy_date"], "updated_at": row["updated_at"], "size_class": row["size_class"] if "size_class" in row.keys() else None, "paper": True, } return out except Exception as e: logger.error("get_active_trades_paper 실패: %s", e) return {} def close_trade_paper( db, *, code: str, strategy: str, sell_price: float, sell_reason: str = "", realized_pnl_override: Optional[float] = None, ) -> bool: """active_trades_paper → trade_history_paper 이동 후 삭제.""" ensure_paper_tables(db) try: row = db.conn.execute( "SELECT * FROM active_trades_paper WHERE code=%s AND strategy=%s", (code, strategy), ).fetchone() if not row: logger.warning("close_trade_paper: %s/%s 없음", code, strategy) return False buy_price = float(row["avg_buy_price"] or 0) qty = int(row["current_qty"] or 0) if realized_pnl_override is not None: realized_pnl = float(realized_pnl_override) else: realized_pnl = (float(sell_price) - buy_price) * qty profit_rate = ( (realized_pnl / (buy_price * qty) * 100.0) if buy_price * qty > 0 else 0.0 ) try: buy_time = datetime.datetime.strptime( str(row["buy_date"])[:19], "%Y-%m-%d %H:%M:%S" ) hold_minutes = int( (datetime.datetime.now() - buy_time).total_seconds() / 60 ) except Exception: hold_minutes = 0 sell_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: from kis_trader.utils.strategy_ids import canonical_strategy_id hist_strategy = canonical_strategy_id(row.get("strategy") or strategy) except Exception: hist_strategy = row.get("strategy") or strategy with db.conn: db.conn.execute( """ INSERT INTO trade_history_paper ( code, name, strategy, buy_price, sell_price, qty, profit_rate, realized_pnl, hold_minutes, buy_date, sell_date, sell_reason, size_class, fill_mode ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) """, ( row["code"], row["name"], hist_strategy, buy_price, float(sell_price), qty, profit_rate, realized_pnl, hold_minutes, row["buy_date"], sell_time, sell_reason or "paper", row["size_class"] if "size_class" in row.keys() else None, "paper", ), ) db.conn.execute( "DELETE FROM active_trades_paper WHERE code=%s AND strategy=%s", (code, strategy), ) return True except Exception as e: logger.error("close_trade_paper 실패 (%s): %s", code, e) return False def count_paper_trades_today(db, *, strategy_id: str, code: str, ymd: str) -> int: """ymd=YYYYMMDD — 당일 paper 청산 건수 (일일 매수 한도용).""" ensure_paper_tables(db) day = f"{ymd[:4]}-{ymd[4:6]}-{ymd[6:8]}" if len(ymd) == 8 else ymd try: row = db.conn.execute( """ SELECT COUNT(*) AS n FROM trade_history_paper WHERE strategy=%s AND code=%s AND sell_date >= %s AND sell_date <= %s """, (strategy_id, code, day + " 00:00:00", day + " 23:59:59"), ).fetchone() return int(row["n"] if row else 0) except Exception: return 0 def list_trade_history_paper( db, *, strategy_like: str, start: str = "", end: str = "", ) -> List[Dict[str, Any]]: ensure_paper_tables(db) params: List[Any] = [strategy_like] sql = "SELECT * FROM trade_history_paper WHERE strategy LIKE %s" if start: sql += " AND sell_date >= %s" params.append(start + " 00:00:00") if end: sql += " AND sell_date <= %s" params.append(end + " 23:59:59") sql += " ORDER BY sell_date ASC" try: rows = db.conn.execute(sql, tuple(params)).fetchall() return [dict(r) for r in rows] except Exception as e: logger.error("list_trade_history_paper 실패: %s", e) return [] def open_paper_buy( db, *, strategy_id: str, code: str, name: str, price: float, qty: int, stop_price: float = 0.0, target_price: float = 0.0, atr_entry: float = 0.0, size_class: str = "", entry_features: Optional[Dict] = None, ) -> Optional[str]: """ 페이퍼 매수 1건: orders_paper + active_trades_paper. 성공 시 ord_no, 실패 시 None. """ if qty <= 0 or price <= 0: return None now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") ord_no = insert_order_paper( db, strategy_id=strategy_id, code=code, name=name, side="BUY", qty=qty, price=price, msg1="paper_buy", ) note = None if entry_features: try: import json note = json.dumps(entry_features, ensure_ascii=False)[:190] except Exception: note = "paper" ok = upsert_active_trade_paper( db, { "code": code, "name": name, "strategy": strategy_id, "avg_buy_price": price, "current_price": price, "stop_price": stop_price, "target_price": target_price, "max_price": price, "atr_entry": atr_entry, "target_qty": qty, "current_qty": qty, "total_invested": price * qty, "status": "HOLDING", "buy_date": now, "size_class": size_class or "", "fill_mode": "paper", "note": note, }, ) if not ok: return None logger.info( "📝 [PAPER BUY] %s %s @ %.4f × %d (ord=%s)", strategy_id, code, price, qty, ord_no, ) return ord_no def open_paper_sell( db, *, strategy_id: str, code: str, name: str, qty: int, sell_price: float, sell_reason: str = "", buy_price: float = 0.0, ) -> bool: """페이퍼 매도: orders_paper SELL + close_trade_paper.""" if qty <= 0 or sell_price <= 0: return False insert_order_paper( db, strategy_id=strategy_id, code=code, name=name, side="SELL", qty=qty, price=sell_price, msg1=sell_reason or "paper_sell", ) pnl_override = None if buy_price > 0: pnl_override = (sell_price - buy_price) * qty ok = close_trade_paper( db, code=code, strategy=strategy_id, sell_price=sell_price, sell_reason=sell_reason or "paper", realized_pnl_override=pnl_override, ) if ok: logger.info( "📝 [PAPER SELL] %s %s @ %.4f × %d (%s)", strategy_id, code, sell_price, qty, sell_reason or "", ) return ok