ls증권 히스토리 구독 넣음

This commit is contained in:
Your Name
2026-07-30 18:05:07 +09:00
parent 61bec4bd1d
commit 67eab24603
1593 changed files with 135733 additions and 1232 deletions

View File

@@ -581,19 +581,27 @@ class TradeDBExt:
# ------------------------------------------------------------------
# 백테스트 헬퍼: 특정 시점의 유니버스 복원
# ------------------------------------------------------------------
@staticmethod
def _universe_history_table(history_source: str = "kiwoom") -> str:
from kis_trader.backtest.universe_history_source import history_table_for_source
return history_table_for_source(history_source)
def get_universe_at(
self, *, strategy_id: str, at_time: str
self, *, strategy_id: str, at_time: str, history_source: str = "kiwoom",
) -> List[Dict]:
"""
``at_time`` ('YYYY-MM-DD HH:MM:SS') 시점에 봇이 보던 유니버스를 복원.
= 그 시각 이전의 가장 최근 event_time 스냅샷.
``history_source``: ``kiwoom``(target_candidates_history) | ``ls``(ls_candidates_history)
"""
self._ensure_history_columns()
table = self._universe_history_table(history_source)
try:
row = self.conn.execute(
"""
f"""
SELECT MAX(event_time) AS et
FROM target_candidates_history
FROM {table}
WHERE strategy_id=%s AND event_time <= %s
""",
(strategy_id, at_time),
@@ -602,8 +610,8 @@ class TradeDBExt:
if not et:
return []
rows = self.conn.execute(
"""
SELECT code, name FROM target_candidates_history
f"""
SELECT code, name FROM {table}
WHERE strategy_id=%s AND event_time=%s
ORDER BY code
""",
@@ -611,7 +619,7 @@ class TradeDBExt:
).fetchall()
return [{"code": r["code"], "name": r["name"]} for r in rows]
except Exception as e:
logger.error("get_universe_at 실패: %s", e)
logger.error("get_universe_at 실패(src=%s): %s", history_source, e)
return []
def iter_universe_events(
@@ -621,6 +629,7 @@ class TradeDBExt:
start_time: str,
end_time: str,
preserve_insert_order: bool = False,
history_source: str = "kiwoom",
) -> List[Dict]:
"""
``[start_time, end_time]`` 구간의 모든 스냅샷 이벤트를 시간순 반환.
@@ -629,14 +638,16 @@ class TradeDBExt:
``preserve_insert_order=True``: 스냅샷 내 종목 순서를 DB insert(id) 순으로
유지 (백테 매수 우선순위 정합). 기본 False 는 code 가나다순.
``history_source``: ``kiwoom`` | ``ls``
"""
self._ensure_history_columns()
table = self._universe_history_table(history_source)
order_clause = "event_time, id" if preserve_insert_order else "event_time, code"
try:
rows = self.conn.execute(
f"""
SELECT event_time, code, name
FROM target_candidates_history
FROM {table}
WHERE strategy_id=%s
AND event_time BETWEEN %s AND %s
ORDER BY {order_clause}
@@ -654,7 +665,7 @@ class TradeDBExt:
for et, items in sorted(grouped.items())
]
except Exception as e:
logger.error("iter_universe_events 실패: %s", e)
logger.error("iter_universe_events 실패(src=%s): %s", history_source, e)
return []
@staticmethod
@@ -673,6 +684,7 @@ class TradeDBExt:
strict: bool = False,
strict_lag_minutes: int = 1,
exit_debounce_sec: int = 0,
history_source: str = "kiwoom",
) -> Dict[str, List[str]]:
"""
백테스트 편의용 — 1분봉 캔들 시각(YYYYMMDDHHMM)을 키로 하는 유니버스 dict.
@@ -695,6 +707,7 @@ class TradeDBExt:
strict: 종목별 첫 event_time 기준 편입 지연 적용
strict_lag_minutes: 첫 편입 분 이후 추가 대기 분 (기본 1)
exit_debounce_sec: 짧은 EXIT→재편입 무시(초). 0=OFF.
history_source: ``kiwoom`` | ``ls``
Returns:
``{candle_time(YYYYMMDDHHMM): [code, ...]}`` —
@@ -703,12 +716,23 @@ class TradeDBExt:
"""
self._ensure_history_columns()
from kis_trader.backtest.universe_history_source import (
apply_ls_session_filter_to_start,
normalize_universe_history_source,
)
history_source = normalize_universe_history_source(history_source)
# 조회 범위: 전일 마지막 스냅샷도 포함하기 위해 시작일 00:00:00 이전 1건은
# 엔진 쪽에서 "이전에 유효했던 유니버스" 로 물려받는 게 자연스럽다.
# 단순화를 위해 start_ymd 00:00:00 ~ end_ymd 23:59:59 범위로 쿼리.
# LS + SESSION_ONLY: 장전 sticky 스냅 제외 → 당일 09:00 부터.
start_time = (
f"{start_ymd[:4]}-{start_ymd[4:6]}-{start_ymd[6:8]} 00:00:00"
)
start_time = apply_ls_session_filter_to_start(
start_time, source=history_source,
)
end_time = (
f"{end_ymd[:4]}-{end_ymd[4:6]}-{end_ymd[6:8]} 23:59:59"
)
@@ -718,6 +742,7 @@ class TradeDBExt:
start_time=start_time,
end_time=end_time,
preserve_insert_order=True,
history_source=history_source,
)
if not events:
return {}
@@ -768,6 +793,7 @@ class TradeDBExt:
try:
prev_items = self.get_universe_at(
strategy_id=strategy_id, at_time=start_time,
history_source=history_source,
)
prev_codes_set = {
str(it["code"]) for it in prev_items if it.get("code")

View 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