501 lines
17 KiB
Python
501 lines
17 KiB
Python
"""
|
||
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
|