ls증권 히스토리 구독 넣음
This commit is contained in:
500
kis_trader/database/paper_store.py
Normal file
500
kis_trader/database/paper_store.py
Normal file
@@ -0,0 +1,500 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user