Files
kis_bot/kis_trader/database/paper_store.py
2026-07-30 18:05:07 +09:00

501 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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