feat: Enhance trading system with new permanent subscription features and order book management

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 제거 븅신같은 초기설계 아예 제거
진입모드에 구멍메움
호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
This commit is contained in:
Your Name
2026-08-15 23:01:14 +09:00
parent 4a18ce2697
commit 36a3e2b4a1
94 changed files with 6368 additions and 1639 deletions

View File

@@ -9,12 +9,18 @@ kis_trader/engine/orderbook_filter.py — TRIGGER 진입 호가 필터
- ``WS_ORDERBOOK_COLLECT_ENABLED`` — TRIGGER 판정 스냅 저장 (필터 OFF여도 가능)
모든 임계값 env/DB — 하드코딩 금지 (기본값은 orderbook_env 상수).
필터 호가 나이(``WS_ORDERBOOK_FILTER_MAX_AGE_SEC``, 기본 0=마지막 RAM)와
저장 TTL(``WS_ORDERBOOK_TICK_MAX_AGE_SEC``)은 분리한다.
필터 ON 인데 호가 사진이 한 장도 없으면
``WS_ORDERBOOK_FILTER_REJECT_IF_EMPTY``(기본 true) 일 때 거절한다. REST 없음.
"""
from __future__ import annotations
import logging
import time
from typing import Any, Dict, Optional, Tuple
from kis_trader.utils.env import get_env_float, get_env_int
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int
from kis_trader.ws.orderbook_cache import OrderbookSnapshot
from kis_trader.ws.trigger_eval_recorder import get_trigger_eval_recorder
@@ -25,6 +31,8 @@ from .orderbook_env import (
)
from .trigger_eval_collect import orderbook_collect_enabled
logger = logging.getLogger(__name__)
LOG_BACKFILL_SOURCE = "log_backfill"
@@ -66,30 +74,41 @@ def _fetch_snapshot(params: Dict[str, Any]) -> Optional[OrderbookSnapshot]:
if not ws or not code:
return None
# 소형주는 0D 업데이트 주기가 10~60초 이상 → 3초 기본값이면 항상 None
# WS_ORDERBOOK_TICK_MAX_AGE_SEC 공유해서 일관된 허용 나이 적용
max_age = float(get_env_float("WS_ORDERBOOK_TICK_MAX_AGE_SEC", 30.0) or 30.0)
# 필터: 기본 0 → 캐시 get 이 나이를 무시하고 마지막 호가로 검사 (REST 없음).
# 저장(0D 덤프) TTL 은 WS_ORDERBOOK_TICK_MAX_AGE_SEC 를 그대로 씀.
max_age = float(get_env_float("WS_ORDERBOOK_FILTER_MAX_AGE_SEC", 0.0) or 0.0)
getter = getattr(ws, "get_orderbook_snapshot", None)
snap = None
if callable(getter):
try:
snap = getter(code, max_age_sec=max_age)
if snap is not None:
return snap
except Exception:
pass
getter2 = getattr(ws, "get_orderbook", None)
if not callable(getter2):
return None
snap = None
if snap is None:
getter2 = getattr(ws, "get_orderbook", None)
if not callable(getter2):
return None
try:
raw = getter2(code, max_age_sec=max_age)
except Exception:
return None
if raw is None:
return None
if not isinstance(raw, OrderbookSnapshot):
return None
snap = raw
save_ttl = float(get_env_float("WS_ORDERBOOK_TICK_MAX_AGE_SEC", 30.0) or 30.0)
try:
raw = getter2(code, max_age_sec=max_age)
except Exception:
return None
if raw is None:
return None
if isinstance(raw, OrderbookSnapshot):
return raw
return None
age = time.time() - float(snap.ts or 0.0)
except (TypeError, ValueError):
age = 0.0
if save_ttl > 0 and age > save_ttl:
logger.debug(
"호가필터: 저장TTL(%.0fs) 초과 age=%.1fs 이지만 마지막 스냅으로 평가 code=%s",
save_ttl, age, code,
)
return snap
def _estimate_entry_qty(params: Dict[str, Any], price: float) -> int:
@@ -237,7 +256,13 @@ def orderbook_reject_for_entry(
snap = _fetch_snapshot(params)
if snap is None:
# 실매 WS 미구독·만료 — 필터·수집 모두 스킵 (REST 부하 없음)
# 한 번도 호가 없음(미구독). 만료만으로는 여기 오지 않음(FILTER_MAX_AGE 기본 0).
# 필터 ON + REJECT_IF_EMPTY(기본 true) → 이번 루프 안 삼. REST 없음.
if filter_on and get_env_bool("WS_ORDERBOOK_FILTER_REJECT_IF_EMPTY", True):
return (
"탈락-호가없음",
"호가창 사진이 없어 이번엔 안 삼 (다음 검사에서 사진 있으면 다시 봄)",
)
return (None, None)
verdict = _evaluate_orderbook_verdict(

View File

@@ -27,24 +27,25 @@ logger = get_logger("kis_trader.post_sell_candle_backfill")
_INSERT_SQL_OVERWRITE = """
INSERT INTO ws_candles
(code, timeframe, candle_time, `open`, high, low, close,
volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at)
volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, channel, updated_at)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
`open`=VALUES(`open`), high=VALUES(high), low=VALUES(low),
close=VALUES(close),
volume=IF(VALUES(volume) > volume, VALUES(volume), volume),
is_confirmed=1, updated_at=VALUES(updated_at),
source=IF(VALUES(volume) > volume, VALUES(source), source)
source=IF(VALUES(volume) > volume, VALUES(source), source),
channel=IF(VALUES(volume) > volume, VALUES(channel), channel)
"""
# freeze ON: 없는 분만 INSERT. 확정·미확정 행이 있으면 OHLCV 유지 (구멍 메우기 전용)
_INSERT_SQL_FREEZE = """
INSERT INTO ws_candles
(code, timeframe, candle_time, `open`, high, low, close,
volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at)
volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, channel, updated_at)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
candle_time=candle_time
"""
@@ -150,6 +151,7 @@ def load_kiwoom_credentials(db: Any = None) -> Tuple[str, str, bool]:
def _upsert_df_rows(db: Any, code: str, tf_min: int, rows: List[Dict[str, Any]]) -> int:
if not rows:
return 0
from kis_trader.ws.candle_series import normalize_source_channel
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
payload = []
for rec in rows:
@@ -157,6 +159,10 @@ def _upsert_df_rows(db: Any, code: str, tf_min: int, rows: List[Dict[str, Any]])
ct = str(rec.get("candle_time") or rec.get("time") or "")[:12]
if len(ct) < 12:
continue
src, ch = normalize_source_channel(
str(rec.get("source") or "kiwoom"),
str(rec.get("channel") or "rest"),
)
payload.append((
code,
int(tf_min),
@@ -168,7 +174,8 @@ def _upsert_df_rows(db: Any, code: str, tf_min: int, rows: List[Dict[str, Any]])
int(float(rec.get("volume") or 0)),
None, None, None,
1,
str(rec.get("source") or "kw_rest")[:10],
src,
ch,
now_str,
))
except Exception:
@@ -275,7 +282,8 @@ def backfill_hold_window(
"low": float(rec.get("low") or cl),
"close": cl,
"volume": int(float(rec.get("volume") or 0)),
"source": "kw_rest",
"source": "kiwoom",
"channel": "rest",
})
try:
@@ -293,7 +301,8 @@ def backfill_hold_window(
bars3 = rollup_1m_bars_to_tf(rows_1m, 3)
for b in bars3:
b["source"] = "rollup_1m"
b["source"] = "kiwoom"
b["channel"] = "rollup"
out["rollup3m"] = _upsert_df_rows(db, code, 3, bars3)
except Exception as e:
logger.debug("3M 롤업 스킵 %s: %s", code, e)