Changes: - Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically. - Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database. - Introduced a mechanism to handle master subscription states, improving the management of subscription statuses. - Updated the database schema to include new fields for managing subscription states and order book filtering. Impact: - These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies. 히스토리 align 제거 븅신같은 초기설계 아예 제거 진입모드에 구멍메움 호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
407 lines
14 KiB
Python
407 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""최근 실체결 매수/매도 시각 vs 호가 스냅·필터 판정 대조 (조회 전용)."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from database import TradeDB
|
|
from kis_trader.engine.orderbook_env import load_orderbook_threshold_cfg, orderbook_filter_enabled
|
|
from kis_trader.engine.orderbook_filter import _evaluate_orderbook_verdict
|
|
from kis_trader.utils.env import get_env_from_db, invalidate_merged_env_cache
|
|
from kis_trader.ws.orderbook_cache import orderbook_snapshot_from_storage
|
|
|
|
# 최근 거래일(금) 포함 며칠
|
|
LOOKBACK_PREFIX = "2026081" # 2026-08-10~14 장일 묶음 (LIKE 바인딩)
|
|
DAY_START = "20260810"
|
|
|
|
|
|
def ts14(raw: Any) -> str:
|
|
if raw is None:
|
|
return ""
|
|
s = str(raw).strip()
|
|
if not s:
|
|
return ""
|
|
s = s.replace("-", "").replace(":", "").replace(" ", "").replace("T", "").replace(".", "")
|
|
if len(s) < 8:
|
|
return ""
|
|
return (s + "000000")[:14]
|
|
|
|
|
|
def parse14(st: str) -> Optional[datetime]:
|
|
st = (st or "").strip()
|
|
if len(st) < 12:
|
|
return None
|
|
try:
|
|
return datetime.strptime(st[:14], "%Y%m%d%H%M%S")
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def strat_canon(s: str) -> str:
|
|
u = (s or "").strip().upper()
|
|
if u in ("SHORT", "TAIL_CATCH", "TAIL"):
|
|
return "TAIL"
|
|
if u in ("BO", "BREAKOUT"):
|
|
return "BREAKOUT"
|
|
if u in ("MOM", "MOMENTUM"):
|
|
return "MOMENTUM"
|
|
if u in ("SCALP", "SCALPING", "REVERSAL"):
|
|
return "SCALP"
|
|
return u
|
|
|
|
|
|
def cols(db: TradeDB, table: str) -> List[str]:
|
|
rows = db.conn.execute(f"SHOW COLUMNS FROM {table}").fetchall()
|
|
out = []
|
|
for r in rows:
|
|
d = dict(r) if not isinstance(r, dict) else r
|
|
out.append(d.get("Field") or d.get("field") or list(d.values())[0])
|
|
return out
|
|
|
|
|
|
def nearest_ob(
|
|
db: TradeDB,
|
|
table: str,
|
|
have: List[str],
|
|
code: str,
|
|
t: datetime,
|
|
window_sec: int,
|
|
source: Optional[str] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
t0 = (t - timedelta(seconds=window_sec)).strftime("%Y%m%d%H%M%S")
|
|
t1 = (t + timedelta(seconds=window_sec)).strftime("%Y%m%d%H%M%S")
|
|
extra = ""
|
|
params: List[Any] = [code, t0, t1]
|
|
if source and "source" in have:
|
|
extra = " AND source = %s"
|
|
params.append(source)
|
|
sel = [
|
|
c
|
|
for c in (
|
|
"id",
|
|
"code",
|
|
"snap_time",
|
|
"recv_ts",
|
|
"best_bid",
|
|
"best_ask",
|
|
"total_bid_qty",
|
|
"total_ask_qty",
|
|
"bid_qty_l3",
|
|
"ask_qty_l3",
|
|
"levels_json",
|
|
"source",
|
|
"strategy",
|
|
"reject_code",
|
|
"reject_msg",
|
|
"eval_price",
|
|
)
|
|
if c in have
|
|
]
|
|
sql = (
|
|
f"SELECT {', '.join(sel)} FROM {table} "
|
|
f"WHERE code = %s AND snap_time >= %s AND snap_time <= %s{extra} "
|
|
f"ORDER BY snap_time"
|
|
)
|
|
rows = [dict(r) for r in db.conn.execute(sql, tuple(params)).fetchall()]
|
|
if not rows:
|
|
return None
|
|
best = None
|
|
best_dt = None
|
|
for row in rows:
|
|
dt = parse14(ts14(row.get("snap_time")))
|
|
if dt is None:
|
|
continue
|
|
if best is None or abs((dt - t).total_seconds()) < abs((best_dt - t).total_seconds()):
|
|
best = row
|
|
best_dt = dt
|
|
if best is None:
|
|
return None
|
|
best["_delta_sec"] = (best_dt - t).total_seconds()
|
|
return best
|
|
|
|
|
|
def ratio_l3(row: Dict[str, Any]) -> Optional[float]:
|
|
b = float(row.get("bid_qty_l3") or 0)
|
|
a = float(row.get("ask_qty_l3") or 0)
|
|
if a <= 0:
|
|
return None
|
|
return b / a
|
|
|
|
|
|
def spread_pct(row: Dict[str, Any]) -> Optional[float]:
|
|
bb = float(row.get("best_bid") or 0)
|
|
ba = float(row.get("best_ask") or 0)
|
|
if bb <= 0 or ba <= 0:
|
|
return None
|
|
mid = (bb + ba) / 2.0
|
|
return (ba - bb) / mid * 100.0
|
|
|
|
|
|
def env_flag(snap_json: Any, key: str) -> str:
|
|
if not snap_json:
|
|
return ""
|
|
try:
|
|
d = json.loads(snap_json) if isinstance(snap_json, str) else snap_json
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
return ""
|
|
if not isinstance(d, dict):
|
|
return ""
|
|
for k in (key, key.lower()):
|
|
if k in d:
|
|
return str(d.get(k) or "")
|
|
# nested env
|
|
env = d.get("env") if isinstance(d.get("env"), dict) else {}
|
|
return str(env.get(key) or "")
|
|
|
|
|
|
def main() -> None:
|
|
invalidate_merged_env_cache()
|
|
db = TradeDB()
|
|
try:
|
|
print("=== SHOW COLUMNS ===")
|
|
th_cols = cols(db, "trade_history")
|
|
at_cols = cols(db, "active_trades")
|
|
wo_cols = cols(db, "ws_orderbook")
|
|
print("trade_history:", th_cols)
|
|
print("active_trades:", at_cols)
|
|
print("ws_orderbook:", wo_cols)
|
|
ls_cols: List[str] = []
|
|
try:
|
|
ls_cols = cols(db, "ls_ws_orderbook")
|
|
print("ls_ws_orderbook:", ls_cols)
|
|
except Exception as e:
|
|
print("ls_ws_orderbook SHOW failed:", e)
|
|
|
|
print("\n=== 현재 DB 호가 스위치 ===")
|
|
keys = []
|
|
for pfx in ("MOMENTUM", "BREAKOUT", "SCALP", "TAIL"):
|
|
keys.extend(
|
|
[
|
|
f"{pfx}_ORDERBOOK_FILTER_ENABLED",
|
|
f"{pfx}_ORDERBOOK_MAX_SPREAD_PCT",
|
|
f"{pfx}_ORDERBOOK_MIN_BID_ASK_RATIO",
|
|
f"{pfx}_ORDERBOOK_ENTRY_BID_LEVELS",
|
|
f"{pfx}_EXIT_OB_ENABLED",
|
|
f"{pfx}_STOP_OB_ENABLED",
|
|
]
|
|
)
|
|
keys.extend(
|
|
[
|
|
"ORDERBOOK_FILTER_ENABLED",
|
|
"WS_ORDERBOOK_COLLECT_ENABLED",
|
|
"WS_ORDERBOOK_SAVE_ENABLED",
|
|
"KIWOOM_WS_ORDERBOOK_ENABLED",
|
|
"LS_WS_ORDERBOOK_SAVE",
|
|
"SELL_USE_ORDERBOOK_ON_PROFIT",
|
|
]
|
|
)
|
|
for k in keys:
|
|
print(f" {k}={get_env_from_db(k, '')!r}")
|
|
for sid in ("MOMENTUM", "BREAKOUT", "SCALP", "TAIL"):
|
|
print(f" orderbook_filter_enabled({sid})={orderbook_filter_enabled(sid)}")
|
|
|
|
print("\n=== 호가 테이블 건수 (LIKE 바인딩) ===")
|
|
for tbl, have in (("ws_orderbook", wo_cols), ("ls_ws_orderbook", ls_cols)):
|
|
if not have:
|
|
continue
|
|
n = db.conn.execute(
|
|
f"SELECT COUNT(*) AS n FROM {tbl} WHERE snap_time LIKE %s",
|
|
(LOOKBACK_PREFIX + "%",),
|
|
).fetchone()
|
|
print(f" {tbl} snap_time LIKE {LOOKBACK_PREFIX}% : {dict(n)['n']}")
|
|
if "source" in have:
|
|
srcs = db.conn.execute(
|
|
f"SELECT source, COUNT(*) AS n FROM {tbl} WHERE snap_time LIKE %s GROUP BY source",
|
|
(LOOKBACK_PREFIX + "%",),
|
|
).fetchall()
|
|
for r in srcs:
|
|
d = dict(r)
|
|
print(f" source={d.get('source')!r} n={d.get('n')}")
|
|
|
|
print("\n=== 실체결 trade_history (buy_date >= 20260810) ===")
|
|
th_sel = [
|
|
c
|
|
for c in (
|
|
"id",
|
|
"code",
|
|
"name",
|
|
"strategy",
|
|
"buy_price",
|
|
"sell_price",
|
|
"qty",
|
|
"profit_rate",
|
|
"buy_date",
|
|
"sell_date",
|
|
"sell_reason",
|
|
"env_snapshot",
|
|
)
|
|
if c in th_cols
|
|
]
|
|
trades = [
|
|
dict(r)
|
|
for r in db.conn.execute(
|
|
f"SELECT {', '.join(th_sel)} FROM trade_history "
|
|
f"WHERE buy_date LIKE %s OR sell_date LIKE %s OR buy_date LIKE %s OR sell_date LIKE %s "
|
|
f"ORDER BY id DESC LIMIT 80",
|
|
("2026-08-1%", "2026-08-1%", "2026081%", "2026081%"),
|
|
).fetchall()
|
|
]
|
|
# 날짜 포맷이 다를 수 있어 추가 필터
|
|
filtered: List[Dict[str, Any]] = []
|
|
for t in trades:
|
|
b = ts14(t.get("buy_date"))
|
|
s = ts14(t.get("sell_date"))
|
|
if (b and b >= DAY_START) or (s and s >= DAY_START):
|
|
filtered.append(t)
|
|
print(f" rows matched query={len(trades)} after date filter={len(filtered)}")
|
|
|
|
print("\n=== 미청산 active_trades ===")
|
|
at_sel = [
|
|
c
|
|
for c in ("code", "name", "strategy", "avg_buy_price", "current_qty", "buy_date", "status")
|
|
if c in at_cols
|
|
]
|
|
actives = [dict(r) for r in db.conn.execute(f"SELECT {', '.join(at_sel)} FROM active_trades").fetchall()]
|
|
for a in actives:
|
|
print(
|
|
f" {a.get('strategy')} {a.get('code')} {a.get('name')} "
|
|
f"buy={a.get('buy_date')} qty={a.get('current_qty')} st={a.get('status')}"
|
|
)
|
|
|
|
events: List[Tuple[str, Dict[str, Any]]] = []
|
|
for t in filtered:
|
|
events.append(("BUY", t))
|
|
events.append(("SELL", t))
|
|
for a in actives:
|
|
events.append(("BUY_OPEN", a))
|
|
|
|
print("\n=== 체결↔호가 매칭 (매수 ±180s filter_eval 우선, 본체 ±15분) ===")
|
|
summary = {
|
|
"buy_n": 0,
|
|
"sell_n": 0,
|
|
"filter_on_would_reject": 0,
|
|
"filter_eval_reject_but_bought": 0,
|
|
"no_filter_eval": 0,
|
|
"timing_gt_3s": 0,
|
|
"timing_gt_30s": 0,
|
|
"snap_none_would_pass": 0,
|
|
}
|
|
details: List[str] = []
|
|
|
|
for side, trade in events:
|
|
code = str(trade.get("code") or "").strip()
|
|
sid = strat_canon(str(trade.get("strategy") or ""))
|
|
if side == "SELL":
|
|
t14 = ts14(trade.get("sell_date"))
|
|
else:
|
|
t14 = ts14(trade.get("buy_date"))
|
|
dt = parse14(t14)
|
|
if not dt:
|
|
continue
|
|
if t14 < DAY_START:
|
|
continue
|
|
if side.startswith("BUY"):
|
|
summary["buy_n"] += 1
|
|
else:
|
|
summary["sell_n"] += 1
|
|
|
|
filt_on = orderbook_filter_enabled(sid) if sid in ("MOMENTUM", "BREAKOUT", "SCALP", "TAIL") else False
|
|
env_k = f"{sid}_ORDERBOOK_FILTER_ENABLED"
|
|
snap_flag = env_flag(trade.get("env_snapshot"), env_k)
|
|
|
|
fe = nearest_ob(db, "ws_orderbook", wo_cols, code, dt, 180, "filter_eval")
|
|
body_kw = nearest_ob(db, "ws_orderbook", wo_cols, code, dt, 900, "kiwoom_0d")
|
|
if body_kw is None:
|
|
body_kw = nearest_ob(db, "ws_orderbook", wo_cols, code, dt, 900, None)
|
|
body_ls = None
|
|
if ls_cols:
|
|
body_ls = nearest_ob(db, "ls_ws_orderbook", ls_cols, code, dt, 900, None)
|
|
|
|
body = body_ls if sid == "BREAKOUT" and body_ls else (body_kw or body_ls)
|
|
judge = fe or body
|
|
|
|
recon = None
|
|
recon_msg = ""
|
|
if judge:
|
|
try:
|
|
snap = orderbook_snapshot_from_storage(judge)
|
|
recon, recon_msg = _evaluate_orderbook_verdict(
|
|
snap, sid, {}, current_price=float(trade.get("buy_price") or trade.get("avg_buy_price") or 0)
|
|
)
|
|
except Exception as e:
|
|
recon_msg = f"eval_err:{e}"
|
|
|
|
fe_rej = (fe or {}).get("reject_code") if fe else None
|
|
dlt_fe = fe.get("_delta_sec") if fe else None
|
|
dlt_body = body.get("_delta_sec") if body else None
|
|
if fe and abs(float(dlt_fe)) > 3:
|
|
summary["timing_gt_3s"] += 1
|
|
if (dlt_body is not None) and abs(float(dlt_body)) > 30:
|
|
summary["timing_gt_30s"] += 1
|
|
if side.startswith("BUY") and not fe:
|
|
summary["no_filter_eval"] += 1
|
|
if side.startswith("BUY") and recon:
|
|
summary["filter_on_would_reject"] += 1
|
|
if filt_on:
|
|
summary["filter_eval_reject_but_bought"] += 1
|
|
if side.startswith("BUY") and judge is None:
|
|
summary["snap_none_would_pass"] += 1
|
|
|
|
spr = spread_pct(judge) if judge else None
|
|
rat = ratio_l3(judge) if judge else None
|
|
line = (
|
|
f"[{side}] {sid} {code} {trade.get('name','')} t={t14} "
|
|
f"px={trade.get('buy_price') or trade.get('avg_buy_price') or trade.get('sell_price')} "
|
|
f"qty={trade.get('qty') or trade.get('current_qty')} "
|
|
f"reason={trade.get('sell_reason','') if side=='SELL' else ''} "
|
|
f"DB필터ON={filt_on} env_snap={snap_flag!r}\n"
|
|
f" filter_eval={'YES' if fe else 'NO'}"
|
|
f" Δ={dlt_fe}s rej={fe_rej!r} msg={(fe or {}).get('reject_msg')!r}\n"
|
|
f" body_src={(body or {}).get('source') if body else None} Δ={dlt_body}s "
|
|
f"bidL3={(judge or {}).get('bid_qty_l3')} askL3={(judge or {}).get('ask_qty_l3')} "
|
|
f"spread={spr} ratio={None if rat is None else round(rat,3)}\n"
|
|
f" 재계산판정={recon!r} {recon_msg}"
|
|
)
|
|
details.append(line)
|
|
print(line)
|
|
|
|
print("\n=== 요약 ===")
|
|
for k, v in summary.items():
|
|
print(f" {k}={v}")
|
|
|
|
print("\n=== filter_eval 탈락인데 같은 종목 매수가 있는지 (버그 후보) ===")
|
|
if "reject_code" in wo_cols:
|
|
rej_rows = [
|
|
dict(r)
|
|
for r in db.conn.execute(
|
|
"SELECT code, snap_time, strategy, reject_code, reject_msg, eval_price "
|
|
"FROM ws_orderbook WHERE source = %s AND snap_time LIKE %s "
|
|
"AND reject_code IS NOT NULL AND reject_code <> %s "
|
|
"ORDER BY snap_time DESC LIMIT 40",
|
|
("filter_eval", LOOKBACK_PREFIX + "%", ""),
|
|
).fetchall()
|
|
]
|
|
print(f" filter_eval reject rows (최근40)={len(rej_rows)}")
|
|
for r in rej_rows[:15]:
|
|
print(
|
|
f" {r.get('snap_time')} {r.get('strategy')} {r.get('code')} "
|
|
f"{r.get('reject_code')} {r.get('reject_msg')}"
|
|
)
|
|
else:
|
|
print(" reject_code 컬럼 없음")
|
|
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|