From 2c915c9508f1b00a3325b989506f91403924d9d2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 28 Aug 2026 16:45:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(db):=20ENV=20=ED=82=A4=C2=B7=EC=8A=A4?= =?UTF-8?q?=ED=82=A4=EB=A7=88=20=ED=99=95=EC=9E=A5=20=EB=B0=8F=20=EA=B1=B0?= =?UTF-8?q?=EB=9E=98=EC=9D=BC/=ED=8F=AC=ED=8A=B8=ED=8F=B4=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20DB=20=ED=97=AC=ED=8D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrderWorker·수집통계·영구구독 알람 env 등록과 TradeDB/db_manager 조회·마이그레이션을 보강한다. kr_trading_day 헬퍼와 ERD 문서를 함께 갱신한다. Co-authored-by: Cursor --- database.py | 314 ++++++++++++++++++++++++++--- docs/like_mcp.md/db_erd.md | 30 +-- kis_trader/database/db_manager.py | 140 ++++++++++--- kis_trader/utils/kr_trading_day.py | 35 ++++ 4 files changed, 456 insertions(+), 63 deletions(-) diff --git a/database.py b/database.py index f35587e..ca4f44d 100644 --- a/database.py +++ b/database.py @@ -543,6 +543,10 @@ ENV_CONFIG_KEYS = ( "PERM_SUB_BASE_TF_MIN", # 영구구독 탭 현재가 자동새로고침 초 (웹 UI) "PERM_SUB_UI_REFRESH_SEC", + # 영구구독 목표가 MM 알람 (permanent 채널) + "PERM_ALERT_ENABLED", + "PERM_ALERT_POLL_SEC", + "KIS_PERM_SUB_MM_CHANNEL", # 해외 탭 표시용 USD→KRW (Frankfurter 등). 매매 체결환율 아님. "FX_USD_KRW_API_BASE", "FX_USD_KRW_CACHE_DIR", @@ -578,6 +582,10 @@ ENV_CONFIG_KEYS = ( "WS_PRICE_MAX_AGE_SEC", # 실매·옵투나 읽기 폴백 나이(초). 메인 체결시각 vs 지금. 넘기면 그 벤더 실패→2차→3차. "LIVE_FEED_FALLBACK_MAX_AGE_SEC", + # 수집통계 탭: 종목별 recv_ts 공백(끊김) 소프트/하드/상한(초) + "FEED_STATS_DISCONNECT_SOFT_SEC", + "FEED_STATS_DISCONNECT_HARD_SEC", + "FEED_STATS_DISCONNECT_CAP_SEC", # 실매 틱/호가 1차 벤더 (LS는 1차 아님 · 구독 spill 3차). 호가 kis=2키 OB 세션. "LIVE_TICK_PROVIDER", "LIVE_OB_PROVIDER", @@ -1479,6 +1487,25 @@ ENV_CONFIG_KEYS = ( # 키움 WS 재연결: 토큰거부(805004) 연타 후 영구 OFF 방지 (기본=쿨다운 리셋) "KIWOOM_WS_HARD_DISABLE_ON_MAX_RECONNECT", "KIWOOM_WS_MAX_RECONNECT_COOLDOWN_SEC", + # 키움 WS/CNSR return_code·스레드 진단 (기본 ON — SSL·매수체크 추적) + "KIWOOM_WS_DIAG_ENABLED", + # 전략 매도락 무한 대기 방지(초). 0=무제한(레거시). 틱매도·매도구간 기아 시 매수체크 복구 + "STRATEGY_SELL_LOCK_TIMEOUT_SEC", + # 매도 check_sell_signals hold 타임아웃(초). 0=무제한. sem/REST 대기 기아 방지 + "STRATEGY_SELL_CHECK_TIMEOUT_SEC", + # 루프 1바퀴가 이 시간(초) 넘으면 phase 로그 (기본 30) + "STRATEGY_LOOP_STALL_SEC", + # WS reconcile debounce(초) — 4전략 owner 갱신 합침 + "WS_RECONCILE_DEBOUNCE_SEC", + # 매도 scan 루프에서 ka10007 REST 허용(기본 OFF — sell_lock·sem 블록 방지) + "SELL_SCAN_ALLOW_KIWOOM_REST", + # 키움 ka10080/ka10007 공유 sem 대기(초). 0=무제한 + "KIWOOM_KA10080_SEM_TIMEOUT_SEC", + "KIWOOM_KA10007_SEM_TIMEOUT_SEC", + # 계좌 단일 OrderWorker 큐 (B-full — 4전략 BUY/SELL 직렬 place) + "ORDER_WORKER_MAX_QUEUE", + # 전략 루프 매수구간 스킵 사유(후보0·매수허용OFF) rate-limit 로그 + "STRATEGY_LOOP_DIAG_ENABLED", # ── Updow(직전 분봉 음봉·몸통 하락 → 다음 봉 시가 매수) — 백테·웹·CLI 공통 ── "UPDOW_BODY_DROP_MIN_PCT", "UPDOW_TP_PCT", @@ -1770,7 +1797,8 @@ class TradeDB: status TEXT NOT NULL, -- BUYING(매수중), HOLDING(보유중), SELLING(매도중) buy_date TEXT NOT NULL, -- 첫 매수 시작 시간 updated_at TEXT NOT NULL, -- 마지막 업데이트 시간 - size_class TEXT -- 대/중/소형 (매수 시점) + size_class TEXT, -- 대/중/소형 (매수 시점) + is_mock TINYINT(1) NULL -- 1=모의(KIS_MOCK) · 0=실전 · NULL=이관 전 미상 ) """) @@ -1791,7 +1819,8 @@ class TradeDB: sell_date VARCHAR(30) NOT NULL, sell_reason VARCHAR(200), env_snapshot TEXT, - size_class VARCHAR(20) + size_class VARCHAR(20), + is_mock TINYINT(1) NULL ) CHARACTER SET utf8mb4 """) @@ -1826,7 +1855,8 @@ class TradeDB: sell_tp VARCHAR(20), ord_dt VARCHAR(20), raw_json TEXT, - fetched_at VARCHAR(30) NOT NULL + fetched_at VARCHAR(30) NOT NULL, + is_mock TINYINT(1) NULL ) CHARACTER SET utf8mb4 """) @@ -1840,7 +1870,8 @@ class TradeDB: buy_date VARCHAR(10) NOT NULL, executed_at VARCHAR(30) NOT NULL, amount DOUBLE NOT NULL, - qty INT NOT NULL + qty INT NOT NULL, + is_mock TINYINT(1) NULL ) CHARACTER SET utf8mb4 """) @@ -2159,6 +2190,26 @@ class TradeDB: if "size_class" not in cols: self.conn.execute("ALTER TABLE trade_history ADD COLUMN size_class VARCHAR(20)") logger.info("📌 trade_history.size_class 컬럼 추가") + if "is_mock" not in cols: + self.conn.execute( + "ALTER TABLE trade_history ADD COLUMN is_mock TINYINT(1) NULL" + ) + logger.info("📌 trade_history.is_mock 컬럼 추가 (모의=1/실전=0)") + self._backfill_is_mock_from_env_snapshot("trade_history") + # snapshot에 KIS_MOCK 없으면 잔여 NULL → 현재 KIS_MOCK (이관 시점 운영값) + try: + cur_mock = self.resolve_kis_is_mock(None) + n_left = self.conn.execute( + "UPDATE trade_history SET is_mock=%s WHERE is_mock IS NULL", + (cur_mock,), + ).rowcount + if n_left: + logger.info( + "📌 trade_history.is_mock 잔여 NULL→현재KIS_MOCK(%s) %d건", + cur_mock, n_left, + ) + except Exception as e2: + logger.debug("trade_history is_mock 잔여 백필 스킵: %s", e2) except Exception as e: logger.debug(f"migrate trade_history: {e}") # ── active_trades PK 복합키 마이그레이션 (code → code+strategy) ────────── @@ -2190,12 +2241,51 @@ class TradeDB: if "size_class" not in cols: self.conn.execute("ALTER TABLE active_trades ADD COLUMN size_class VARCHAR(20)") logger.info("📌 active_trades.size_class 컬럼 추가") + if "is_mock" not in cols: + self.conn.execute( + "ALTER TABLE active_trades ADD COLUMN is_mock TINYINT(1) NULL" + ) + logger.info("📌 active_trades.is_mock 컬럼 추가 (모의=1/실전=0)") + # 보유 중 행은 현재 KIS_MOCK 로 채움 (탭 필터용 · 과거 청산은 snapshot 백필) + try: + from kis_trader.utils.env import get_env_bool + cur = 1 if get_env_bool("KIS_MOCK", True) else 0 + self.conn.execute( + "UPDATE active_trades SET is_mock=%s WHERE is_mock IS NULL", + (cur,), + ) + except Exception as e2: + logger.debug("active_trades is_mock 현재값 백필 스킵: %s", e2) for c in ML_ENTRY_FEATURE_COLUMNS: if c not in cols: self.conn.execute(f"ALTER TABLE active_trades ADD COLUMN `{c}` DOUBLE") logger.info(f"📌 active_trades.{c} 컬럼 추가 (ML 진입 피처)") except Exception as e: logger.debug(f"migrate active_trades: {e}") + # buy_execution_log / order_execution_history — 모의·실전 분리 + for _tbl in ("buy_execution_log", "order_execution_history"): + try: + cols = self.conn.get_columns(_tbl) + if "is_mock" not in cols: + self.conn.execute( + f"ALTER TABLE {_tbl} ADD COLUMN is_mock TINYINT(1) NULL" + ) + logger.info("📌 %s.is_mock 컬럼 추가 (모의=1/실전=0)", _tbl) + try: + cur_mock = self.resolve_kis_is_mock(None) + n_left = self.conn.execute( + f"UPDATE {_tbl} SET is_mock=%s WHERE is_mock IS NULL", + (cur_mock,), + ).rowcount + if n_left: + logger.info( + "📌 %s.is_mock 잔여 NULL→현재KIS_MOCK(%s) %d건", + _tbl, cur_mock, n_left, + ) + except Exception as e2: + logger.debug("%s is_mock 백필 스킵: %s", _tbl, e2) + except Exception as e: + logger.debug("migrate %s is_mock: %s", _tbl, e) try: self.migrate_trigger_eval_columns() except Exception as e: @@ -2562,6 +2652,65 @@ class TradeDB: # [CRUD] Active Trades (활성 트레이딩 관리) # ============================================================ + @staticmethod + def resolve_kis_is_mock(explicit: Any = None) -> int: + """모의=1 · 실전=0. explicit 없으면 현재 KIS_MOCK.""" + if explicit is not None: + if isinstance(explicit, str): + return 1 if explicit.strip().lower() in ("1", "true", "yes", "y", "on", "mock") else 0 + return 1 if bool(explicit) else 0 + try: + from kis_trader.utils.env import get_env_bool + return 1 if get_env_bool("KIS_MOCK", True) else 0 + except Exception: + return 1 + + def _backfill_is_mock_from_env_snapshot(self, table: str) -> None: + """env_snapshot JSON 의 KIS_MOCK 으로 is_mock NULL 행 보정 (한 번).""" + try: + rows = self.conn.execute( + f"SELECT id, env_snapshot FROM {table} " + "WHERE is_mock IS NULL AND env_snapshot IS NOT NULL AND env_snapshot<>''" + ).fetchall() or [] + except Exception: + # active_trades 는 PK 가 id 아님 + try: + rows = self.conn.execute( + f"SELECT code, strategy, env_snapshot FROM {table} " + "WHERE is_mock IS NULL AND env_snapshot IS NOT NULL AND env_snapshot<>''" + ).fetchall() or [] + except Exception as e: + logger.debug("%s is_mock snapshot 백필 조회 실패: %s", table, e) + return + n_ok = 0 + for r in rows: + d = dict(r) if not isinstance(r, dict) else r + raw = d.get("env_snapshot") or "" + try: + snap = json.loads(raw) if isinstance(raw, str) else (raw or {}) + except Exception: + continue + if not isinstance(snap, dict) or "KIS_MOCK" not in snap: + continue + flag = self.resolve_kis_is_mock(snap.get("KIS_MOCK")) + try: + if "id" in d and d.get("id") is not None and table == "trade_history": + self.conn.execute( + f"UPDATE {table} SET is_mock=%s WHERE id=%s AND is_mock IS NULL", + (flag, d["id"]), + ) + else: + self.conn.execute( + f"UPDATE {table} SET is_mock=%s " + "WHERE code=%s AND strategy=%s AND is_mock IS NULL", + (flag, d.get("code"), d.get("strategy")), + ) + n_ok += 1 + except Exception: + continue + if n_ok: + logger.info("📌 %s.is_mock env_snapshot 백필 %d건", table, n_ok) + def upsert_trade(self, trade_data: Dict): """ 신규 매수하거나 정보 업데이트 (평단가, 수량 등) @@ -2581,6 +2730,9 @@ class TradeDB: return False size_class = trade_data.get('size_class') + is_mock = self.resolve_kis_is_mock( + trade_data["is_mock"] if "is_mock" in trade_data else None + ) feats = trade_data.get('entry_features') or {} feat_vals = [] for k in ML_ENTRY_FEATURE_COLUMNS: @@ -2588,8 +2740,9 @@ class TradeDB: feat_vals.append(v if isinstance(v, (int, float)) else None) cols = ", ".join(["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", "env_snapshot"] + list(ML_ENTRY_FEATURE_COLUMNS)) - placeholders = ", ".join(["%s"] * (17 + len(ML_ENTRY_FEATURE_COLUMNS))) + "status", "buy_date", "updated_at", "size_class", "env_snapshot", "is_mock"] + + list(ML_ENTRY_FEATURE_COLUMNS)) + placeholders = ", ".join(["%s"] * (18 + len(ML_ENTRY_FEATURE_COLUMNS))) # MySQL: ON DUPLICATE KEY UPDATE (excluded. → VALUES()) updates = ( "avg_buy_price = VALUES(avg_buy_price), current_price = VALUES(current_price), " @@ -2600,7 +2753,8 @@ class TradeDB: "max_price = GREATEST(max_price, VALUES(max_price)), " "status = VALUES(status), updated_at = VALUES(updated_at), " "size_class = COALESCE(VALUES(size_class), size_class), " - "env_snapshot = COALESCE(VALUES(env_snapshot), env_snapshot)" + "env_snapshot = COALESCE(VALUES(env_snapshot), env_snapshot), " + "is_mock = COALESCE(is_mock, VALUES(is_mock))" ) for c in ML_ENTRY_FEATURE_COLUMNS: updates += f", `{c}` = COALESCE(VALUES(`{c}`), `{c}`)" @@ -2635,6 +2789,7 @@ class TradeDB: now, size_class, trade_data.get('env_snapshot'), + is_mock, ) + tuple(feat_vals) try: @@ -2648,22 +2803,28 @@ class TradeDB: def get_active_trades(self, strategy_prefix: Optional[str] = None): """ 활성 트레이딩 목록 조회 (봇 재시작 시 사용) - + Args: strategy_prefix: None이면 전부, 'LONG'이면 strategy LIKE 'LONG%'만, 'SHORT'면 'SHORT%'만 (늘림목/단타 섞임 방지) - + Returns: {종목코드: {trade_info}} 형태의 딕셔너리 + + 현재 KIS_MOCK 과 같은 is_mock 행만 반환 — 모의 보유가 실전 봇에 안 섞이게. """ try: + mock_flag = self.resolve_kis_is_mock(None) if strategy_prefix: cursor = self.conn.execute( - "SELECT * FROM active_trades WHERE strategy LIKE %s", - (strategy_prefix.strip().upper() + "%",) + "SELECT * FROM active_trades WHERE strategy LIKE %s AND is_mock=%s", + (strategy_prefix.strip().upper() + "%", mock_flag), ) else: - cursor = self.conn.execute("SELECT * FROM active_trades") + cursor = self.conn.execute( + "SELECT * FROM active_trades WHERE is_mock=%s", + (mock_flag,), + ) rows = cursor.fetchall() # 기존 JSON 포맷과 호환되도록 딕셔너리 변환 @@ -2689,9 +2850,14 @@ class TradeDB: 'buy_date': row['buy_date'], 'updated_at': row['updated_at'], 'size_class': row['size_class'] if 'size_class' in row.keys() else None, + 'is_mock': ( + int(row['is_mock']) + if 'is_mock' in row.keys() and row['is_mock'] is not None + else None + ), } - logger.debug(f"📂 활성 트레이드 로드: {len(result)}개") + logger.debug(f"📂 활성 트레이드 로드: {len(result)}개 (is_mock={mock_flag})") return result except Exception as e: @@ -2829,6 +2995,11 @@ class TradeDB: if size_class is None and 'size_class' in trade.keys() and trade['size_class']: size_class = trade['size_class'] + if 'is_mock' in trade.keys() and trade['is_mock'] is not None: + hist_is_mock = self.resolve_kis_is_mock(trade['is_mock']) + else: + hist_is_mock = self.resolve_kis_is_mock(None) + # 4. trade_history에 저장 — strategy 는 kis_trader canonical ID (실거래 탭·봇 ID 일치) try: from kis_trader.utils.strategy_ids import canonical_strategy_id @@ -2837,10 +3008,13 @@ class TradeDB: hist_strategy = trade.get("strategy") feat_vals = [trade[c] if c in trade.keys() else None for c in ML_ENTRY_FEATURE_COLUMNS] - cols_th = "code, name, strategy, buy_price, sell_price, qty, profit_rate, realized_pnl, hold_minutes, buy_date, sell_date, sell_reason, env_snapshot, size_class" + cols_th = ( + "code, name, strategy, buy_price, sell_price, qty, profit_rate, realized_pnl, " + "hold_minutes, buy_date, sell_date, sell_reason, env_snapshot, size_class, is_mock" + ) if ML_ENTRY_FEATURE_COLUMNS: cols_th += ", " + ", ".join(ML_ENTRY_FEATURE_COLUMNS) - placeholders = ", ".join(["?"] * (14 + len(ML_ENTRY_FEATURE_COLUMNS))) + placeholders = ", ".join(["?"] * (15 + len(ML_ENTRY_FEATURE_COLUMNS))) remain_qty = pos_qty - qty inv = float(trade["total_invested"] or 0) if "total_invested" in trade.keys() else 0.0 new_inv = (inv * remain_qty / pos_qty) if pos_qty > 0 and remain_qty > 0 else 0.0 @@ -2864,6 +3038,7 @@ class TradeDB: sell_reason, env_snapshot, size_class, + hist_is_mock, ) + tuple(feat_vals)) # 5. 전량이면 삭제, 부분이면 잔량·투입금 축소 @@ -2953,9 +3128,13 @@ class TradeDB: try: with self.conn: self.conn.execute(""" - INSERT INTO buy_execution_log (code, name, strategy, buy_date, executed_at, amount, qty) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, (code, name, strategy, buy_date, executed_at, amount, qty)) + INSERT INTO buy_execution_log + (code, name, strategy, buy_date, executed_at, amount, qty, is_mock) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, ( + code, name, strategy, buy_date, executed_at, amount, qty, + self.resolve_kis_is_mock(None), + )) return True except Exception as e: logger.error(f"❌ insert_buy_execution 실패 ({code}): {e}") @@ -2964,16 +3143,17 @@ class TradeDB: def get_daily_buy_amount(self, date_str: str, strategy_prefix: str = "LONG") -> Tuple[float, int]: """ 해당 날짜(산 시점 기준)에 strategy_prefix에 해당하는 매수 누적 금액·건수. - date_str: YYYY-MM-DD + date_str: YYYY-MM-DD · 현재 KIS_MOCK 계좌만 합산. Returns: (누적 금액, 건수) """ try: + mock_flag = self.resolve_kis_is_mock(None) cursor = self.conn.execute(""" SELECT COALESCE(SUM(amount), 0) AS total_amount, COUNT(*) AS cnt FROM buy_execution_log - WHERE buy_date = %s AND strategy LIKE %s - """, (date_str, strategy_prefix.strip().upper() + "%")) + WHERE buy_date = %s AND strategy LIKE %s AND is_mock = %s + """, (date_str, strategy_prefix.strip().upper() + "%", mock_flag)) row = cursor.fetchone() return (float(row["total_amount"]), int(row["cnt"])) except Exception as e: @@ -2993,8 +3173,8 @@ class TradeDB: self.conn.execute(""" INSERT INTO order_execution_history ( source, ord_no, stk_cd, stk_nm, trde_tp, ord_qty, ord_uv, - cntr_qty, cntr_uv, ord_tm, cnfm_tm, sell_tp, ord_dt, raw_json, fetched_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + cntr_qty, cntr_uv, ord_tm, cnfm_tm, sell_tp, ord_dt, raw_json, fetched_at, is_mock + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( source, row.get('ord_no') or row.get('orig_ord_no'), @@ -3011,6 +3191,7 @@ class TradeDB: ord_dt or '', raw_json, now, + self.resolve_kis_is_mock(None), )) self.conn.commit() return True @@ -4668,6 +4849,93 @@ class TradeDB: if classify_config_key(k) == tbl } + def get_strategy_env_change_history(self, limit: int = 3) -> List[Dict[str, Any]]: + """ + 전략별 config_* 최근 N개 **서로 다른** 스냅샷 (기본 3) + 변경 키. + 연속 동일 행(전역만 저장해도 config_* 가 복제 INSERT 되는 경우)은 건너뜀. + """ + try: + lim = int(limit) + except (TypeError, ValueError): + lim = 3 + if lim < 1: + lim = 1 + if lim > 10: + lim = 10 + labels = { + "SCALP": "스캘핑", + "SHORT": "꼬리잡기", + "MOMENTUM": "모멘텀", + "US_MOMENTUM": "해외모멘텀", + "BREAKOUT": "돌파", + "RANGE_BREAK": "박스권돌파", + "UPDOW": "업다운", + "DBBAND": "DBBAND", + "DART": "DART", + } + secret_bits = ("SECRET", "APP_KEY", "TOKEN", "PASSWORD", "PASSWD", "API_KEY") + scan_n = max(40, lim * 15) + out: List[Dict[str, Any]] = [] + for sid, tbl in STRATEGY_ID_TO_TABLE.items(): + try: + rows_raw = self.conn.execute( + f"SELECT * FROM `{tbl}` ORDER BY id DESC LIMIT %s", + (scan_n,), + ).fetchall() or [] + except Exception as e: + logger.debug("env history %s: %s", tbl, e) + rows_raw = [] + distinct: List[Dict[str, Any]] = [] + prev_vals: Optional[Dict[str, str]] = None + for r0 in rows_raw: + r = dict(r0) + snap: Dict[str, str] = {} + for k, v in r.items(): + if k in ("id", "created_at"): + continue + ku = str(k).upper() + if any(b in ku for b in secret_bits): + continue + if v is None: + continue + sv = str(v).strip() + if sv == "": + continue + snap[str(k)] = sv + if prev_vals is not None and snap == prev_vals: + continue + distinct.append({ + "id": int(r.get("id") or 0), + "created_at": str(r.get("created_at") or ""), + "values": snap, + }) + prev_vals = snap + if len(distinct) >= lim: + break + # 표시: 오래된 → 최신 + snaps = list(reversed(distinct)) + changed_keys: List[str] = [] + all_keys: set = set() + for s in snaps: + all_keys.update(s["values"].keys()) + for i in range(1, len(snaps)): + a = snaps[i - 1]["values"] + b = snaps[i]["values"] + for k in sorted(set(a) | set(b)): + if a.get(k, "") != b.get(k, ""): + if k not in changed_keys: + changed_keys.append(k) + out.append({ + "strategy_id": sid, + "label": labels.get(sid, sid), + "table": tbl, + "limit": lim, + "snapshots": snaps, + "changed_keys": changed_keys, + "key_count": len(all_keys), + }) + return out + # ============================================================ # [kv_store] 매터모스트 원격 조종용 키-값 (마지막 AI 추천, last_seen 등) # ============================================================ diff --git a/docs/like_mcp.md/db_erd.md b/docs/like_mcp.md/db_erd.md index 71ebf3c..4613e22 100644 --- a/docs/like_mcp.md/db_erd.md +++ b/docs/like_mcp.md/db_erd.md @@ -117,6 +117,7 @@ erDiagram | `foreign_net_buy` | `double` | NULL | NULL | 실보유: 외국인 순매수(기록 스냅샷) | | `institution_net_buy` | `double` | NULL | NULL | 실보유: 기관 순매수(기록 스냅샷) | | `market_hour` | `double` | NULL | NULL | 실보유: 장중 시각 메타 | +| `is_mock` | `tinyint(1)` | NULL | NULL | 1=모의(KIS_MOCK) · 0=실전 · NULL=이관 전 미상 |
DDL 원문 @@ -147,6 +148,7 @@ CREATE TABLE `active_trades` ( `foreign_net_buy` double DEFAULT NULL, `institution_net_buy` double DEFAULT NULL, `market_hour` double DEFAULT NULL, + `is_mock` tinyint(1) DEFAULT NULL, PRIMARY KEY (`code`,`strategy`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ``` @@ -230,11 +232,13 @@ CREATE TABLE `active_trades_paper` ( | `submitted_at` | `varchar(30)` | NOT NULL | | 실주문: 주문 제출 시각 | | `filled_at` | `varchar(30)` | NULL | NULL | 실주문: 체결 시각 | | `raw_json` | `mediumtext` | NULL | NULL | 실주문: 원본 응답 JSON | +| `is_mock` | `tinyint(1)` | NULL | NULL | 1=모의(KIS_MOCK) · 0=실전 · NULL=이관 전 미상 | **인덱스**: -- `uq_ord_no_ctx`: (`ord_no`, `strategy_id`, `code`, `side`, `ord_date`) +- `uq_ord_no_ctx`: (`ord_no`, `strategy_id`, `code`, `side`, `ord_date`, `is_mock`) - `idx_strategy_date`: (`strategy_id`, `ord_date`) - `idx_code_date`: (`code`, `ord_date`) +- `idx_orders_mock_date`: (`is_mock`, `ord_date`)
DDL 원문 @@ -258,11 +262,13 @@ CREATE TABLE `orders` ( `submitted_at` varchar(30) NOT NULL, `filled_at` varchar(30) DEFAULT NULL, `raw_json` mediumtext DEFAULT NULL, + `is_mock` tinyint(1) DEFAULT NULL, PRIMARY KEY (`id`), - UNIQUE KEY `uq_ord_no_ctx` (`ord_no`,`strategy_id`,`code`,`side`,`ord_date`), + UNIQUE KEY `uq_ord_no_ctx` (`ord_no`,`strategy_id`,`code`,`side`,`ord_date`,`is_mock`), KEY `idx_strategy_date` (`strategy_id`,`ord_date`), - KEY `idx_code_date` (`code`,`ord_date`) -) ENGINE=InnoDB AUTO_INCREMENT=7429 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + KEY `idx_code_date` (`code`,`ord_date`), + KEY `idx_orders_mock_date` (`is_mock`,`ord_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ```
@@ -356,6 +362,7 @@ CREATE TABLE `orders_paper` ( | `foreign_net_buy` | `double` | NULL | NULL | 실청산이력: 외국인 순매수(기록 스냅샷) | | `institution_net_buy` | `double` | NULL | NULL | 실청산이력: 기관 순매수(기록 스냅샷) | | `market_hour` | `double` | NULL | NULL | 실청산이력: 장중 시각 메타 | +| `is_mock` | `tinyint(1)` | NULL | NULL | 1=모의(KIS_MOCK) · 0=실전 · NULL=이관 전 미상 |
DDL 원문 @@ -377,16 +384,9 @@ CREATE TABLE `trade_history` ( `sell_reason` varchar(200) DEFAULT NULL, `env_snapshot` text DEFAULT NULL, `size_class` varchar(20) DEFAULT NULL, - `rsi` double DEFAULT NULL, - `volume_ratio` double DEFAULT NULL, - `tail_length_pct` double DEFAULT NULL, - `ma5_gap_pct` double DEFAULT NULL, - `ma20_gap_pct` double DEFAULT NULL, - `foreign_net_buy` double DEFAULT NULL, - `institution_net_buy` double DEFAULT NULL, - `market_hour` double DEFAULT NULL, + `is_mock` tinyint(1) DEFAULT NULL, PRIMARY KEY (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=4399 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; ```
@@ -4141,6 +4141,10 @@ CREATE TABLE `ls_universe_history` ( | `tf_min` | `int(11)` | NOT NULL | 60 | 영구구독: 분봉 주기(분) | | `enabled` | `tinyint(4)` | NOT NULL | 1 | 영구구독: 활성 여부 (1=ON) | | `note` | `varchar(100)` | NOT NULL | | 영구구독: 메모 | +| `alert_price` | `double` | NULL | | 목표가 알람 가격 (NULL=미설정) | +| `alert_side` | `varchar(8)` | NOT NULL | gte | gte=이상 · lte=이하 | +| `alert_armed` | `tinyint(4)` | NOT NULL | 1 | 1=감시중 · 0=발화 후 대기 | +| `alert_fired_at` | `datetime` | NULL | | 마지막 알람 시각 | | `created_at` | `datetime` | NULL | current_timestamp() | 영구구독: 생성 시각 | **인덱스**: diff --git a/kis_trader/database/db_manager.py b/kis_trader/database/db_manager.py index 236d03b..8179322 100644 --- a/kis_trader/database/db_manager.py +++ b/kis_trader/database/db_manager.py @@ -97,18 +97,25 @@ class TradeDBExt: submitted_at VARCHAR(30) NOT NULL, filled_at VARCHAR(30) DEFAULT NULL, raw_json MEDIUMTEXT DEFAULT NULL, + is_mock TINYINT(1) NULL, PRIMARY KEY (id), - UNIQUE KEY uq_ord_no_ctx (ord_no, strategy_id, code, side, ord_date), + UNIQUE KEY uq_ord_no_ctx (ord_no, strategy_id, code, side, ord_date, is_mock), INDEX idx_strategy_date (strategy_id, ord_date), - INDEX idx_code_date (code, ord_date) + INDEX idx_code_date (code, ord_date), + INDEX idx_orders_mock_date (is_mock, ord_date) ) CHARACTER SET utf8mb4 """) self._migrate_orders_drop_daily_side_unique() self._migrate_orders_pk_scope() + self._migrate_orders_is_mock() logger.info("📊 orders 테이블 확인/생성 완료") except Exception as e: logger.warning("orders 테이블 생성 실패(무시·폴백): %s", e) + def _resolve_is_mock(self, explicit=None) -> int: + """모의=1 · 실전=0 — TradeDB.resolve_kis_is_mock 과 동일.""" + return int(TradeDB.resolve_kis_is_mock(explicit)) + def _migrate_orders_drop_daily_side_unique(self) -> None: """당일 1회 매수 UNIQUE 제거 — 재진입·삼성전자 누적매수 DB 미기록 방지.""" try: @@ -162,6 +169,65 @@ class TradeDBExt: except Exception as e: logger.warning("orders PK 범위 마이그레이션 실패(무시·폴백): %s", e) + def _migrate_orders_is_mock(self) -> None: + """orders.is_mock — 모의/실전 분리. UNIQUE 에 is_mock 포함(모의·실전 동시 허용).""" + try: + cols = self.conn.get_columns("orders") + if "is_mock" not in cols: + self.conn.execute( + "ALTER TABLE orders ADD COLUMN is_mock TINYINT(1) NULL" + ) + logger.info("📌 orders.is_mock 컬럼 추가 (모의=1/실전=0)") + # 과거 이력은 모의 구간으로 1회 백필 (스냅샷 없음) + n_null = self.conn.execute( + "SELECT COUNT(*) AS c FROM orders WHERE is_mock IS NULL" + ).fetchone() + n_left = int((n_null or {}).get("c") or 0) + if n_left > 0: + cur_mock = self._resolve_is_mock(None) + self.conn.execute( + "UPDATE orders SET is_mock=%s WHERE is_mock IS NULL", + (cur_mock,), + ) + logger.info( + "📌 orders.is_mock 잔여 NULL→현재KIS_MOCK(%s) %d건", + cur_mock, n_left, + ) + # UNIQUE: (…, is_mock) — 모의 ODNO 와 실전 ODNO 가 같아도 공존 + rows = self.conn.execute( + "SHOW INDEX FROM orders WHERE Key_name = %s", + ("uq_ord_no_ctx",), + ).fetchall() + idx_cols = [] + for r in (rows or []): + col = r["Column_name"] if isinstance(r, dict) else r[4] + seq = int(r["Seq_in_index"] if isinstance(r, dict) else r[3]) + idx_cols.append((seq, str(col))) + idx_cols = [c for _, c in sorted(idx_cols)] + want = ["ord_no", "strategy_id", "code", "side", "ord_date", "is_mock"] + if idx_cols != want: + if rows: + self.conn.execute("ALTER TABLE orders DROP INDEX uq_ord_no_ctx") + self.conn.execute( + "ALTER TABLE orders ADD UNIQUE INDEX uq_ord_no_ctx " + "(ord_no, strategy_id, code, side, ord_date, is_mock)" + ) + logger.info( + "✅ orders.uq_ord_no_ctx → (ord_no,strategy_id,code,side,ord_date,is_mock)" + ) + # 조회용 인덱스 + mock_idx = self.conn.execute( + "SHOW INDEX FROM orders WHERE Key_name = %s", + ("idx_orders_mock_date",), + ).fetchall() + if not mock_idx: + self.conn.execute( + "ALTER TABLE orders ADD INDEX idx_orders_mock_date (is_mock, ord_date)" + ) + logger.info("✅ orders.idx_orders_mock_date 추가") + except Exception as e: + logger.warning("orders.is_mock 마이그레이션 실패(무시·폴백): %s", e) + # ------------------------------------------------------------------ # orders CRUD # ------------------------------------------------------------------ @@ -179,25 +245,26 @@ class TradeDBExt: msg_cd: Optional[str] = None, msg1: Optional[str] = None, raw_json: Optional[str] = None, + is_mock=None, ) -> bool: """ 주문 기록 INSERT. - 중복 판정은 (ord_no, strategy_id, code, side, ord_date) 복합 UNIQUE 로 - 한정 — 다른 날짜·다른 전략·다른 종목이 같은 ODNO 를 받아도(모의투자 - 서버 ODNO 재사용 버그) 오탐 차단하지 않는다. 실패 시 False 반환. + 중복 판정은 (ord_no, strategy_id, code, side, ord_date, is_mock) 복합 UNIQUE. + 다른 날짜·전략·종목·모의/실전이 같은 ODNO 를 받아도 오탐 차단하지 않는다. side: 'BUY' | 'SELL' """ side = (side or "").upper() assert side in ("BUY", "SELL"), f"side must be BUY|SELL, got {side}" now = datetime.datetime.now() + mock_flag = self._resolve_is_mock(is_mock) try: with self.conn: self.conn.execute( """ INSERT INTO orders ( ord_no, strategy_id, code, name, side, qty, price, - status, msg_cd, msg1, ord_date, submitted_at, raw_json - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + status, msg_cd, msg1, ord_date, submitted_at, raw_json, is_mock + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( ord_no, strategy_id, code, name, side, qty, price, @@ -205,6 +272,7 @@ class TradeDBExt: now.strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d %H:%M:%S"), raw_json, + mock_flag, ), ) return True @@ -213,9 +281,9 @@ class TradeDBExt: msg = str(e) if "1062" in msg or "Duplicate entry" in msg: logger.warning( - "⚠️ [주문중복차단] strategy=%s code=%s side=%s ord_no=%s " - "(같은 날짜·전략·종목·방향으로 이미 DB 존재)", - strategy_id, code, side, ord_no, + "⚠️ [주문중복차단] strategy=%s code=%s side=%s ord_no=%s is_mock=%s " + "(같은 날짜·전략·종목·방향·계좌모드로 이미 DB 존재)", + strategy_id, code, side, ord_no, mock_flag, ) return False logger.error("insert_order 실패 (ord_no=%s): %s", ord_no, e) @@ -240,6 +308,7 @@ class TradeDBExt: now_dt = datetime.datetime.now() now = now_dt.strftime("%Y-%m-%d %H:%M:%S") od = ord_date or now_dt.strftime("%Y-%m-%d") + mock_flag = self._resolve_is_mock(None) try: with self.conn: self.conn.execute( @@ -247,9 +316,10 @@ class TradeDBExt: UPDATE orders SET filled_qty=%s, filled_avg_price=%s, status=%s, filled_at=%s WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s + AND is_mock=%s """, (filled_qty, filled_avg_price, status, now, - ord_no, strategy_id, code, od), + ord_no, strategy_id, code, od, mock_flag), ) return True except Exception as e: @@ -266,16 +336,18 @@ class TradeDBExt: msg1: str = "", ord_date: Optional[str] = None, ) -> None: - """주문 실패/거부 시 상태 REJECTED 처리 (strategy_id·code·ord_date 로 행 한정).""" + """주문 실패/거부 시 상태 REJECTED 처리 (strategy_id·code·ord_date·is_mock 로 행 한정).""" od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d") + mock_flag = self._resolve_is_mock(None) try: with self.conn: self.conn.execute( """ UPDATE orders SET status='REJECTED', msg_cd=%s, msg1=%s WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s + AND is_mock=%s """, - (msg_cd, msg1, ord_no, strategy_id, code, od), + (msg_cd, msg1, ord_no, strategy_id, code, od, mock_flag), ) except Exception as e: logger.debug("mark_order_rejected 실패 (%s): %s", ord_no, e) @@ -289,14 +361,16 @@ class TradeDBExt: status: str, ord_date: Optional[str] = None, ) -> bool: - """체결 대기 등 — filled_qty 없이 status 만 갱신 (strategy_id·code·ord_date 로 행 한정).""" + """체결 대기 등 — filled_qty 없이 status 만 갱신 (strategy_id·code·ord_date·is_mock 로 행 한정).""" od = ord_date or datetime.datetime.now().strftime("%Y-%m-%d") + mock_flag = self._resolve_is_mock(None) try: with self.conn: self.conn.execute( "UPDATE orders SET status=%s " - "WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s", - (status, ord_no, strategy_id, code, od), + "WHERE ord_no=%s AND strategy_id=%s AND code=%s AND ord_date=%s " + "AND is_mock=%s", + (status, ord_no, strategy_id, code, od, mock_flag), ) return True except Exception as e: @@ -309,16 +383,18 @@ class TradeDBExt: heartbeat ``poll_pending_fills`` 재조회용. """ today = datetime.datetime.now().strftime("%Y-%m-%d") + mock_flag = self._resolve_is_mock(None) try: rows = self.conn.execute( """ SELECT * FROM orders WHERE ord_date=%s + AND is_mock=%s AND status IN ('SUBMITTED', 'PENDING_FILL', 'PARTIAL') AND COALESCE(filled_qty, 0) < qty ORDER BY submitted_at ASC """, - (today,), + (today, mock_flag), ).fetchall() return [dict(r) for r in rows] except Exception as e: @@ -330,11 +406,13 @@ class TradeDBExt: ) -> Optional[Dict]: """동일 전략·종목 미체결 매도 1건 (중복 주문 방지용).""" today = datetime.datetime.now().strftime("%Y-%m-%d") + mock_flag = self._resolve_is_mock(None) try: row = self.conn.execute( """ SELECT * FROM orders WHERE ord_date=%s + AND is_mock=%s AND strategy_id=%s AND code=%s AND side='SELL' @@ -343,7 +421,7 @@ class TradeDBExt: ORDER BY submitted_at DESC LIMIT 1 """, - (today, strategy_id, code), + (today, mock_flag, strategy_id, code), ).fetchone() return dict(row) if row else None except Exception as e: @@ -360,11 +438,13 @@ class TradeDBExt: 만료 시 poll_pending_fills 가 status 를 CANCELLED 로 바꿔 자동 해제된다. """ today = datetime.datetime.now().strftime("%Y-%m-%d") + mock_flag = self._resolve_is_mock(None) try: row = self.conn.execute( """ SELECT * FROM orders WHERE ord_date=%s + AND is_mock=%s AND strategy_id=%s AND code=%s AND side='BUY' @@ -373,7 +453,7 @@ class TradeDBExt: ORDER BY submitted_at DESC LIMIT 1 """, - (today, strategy_id, code), + (today, mock_flag, strategy_id, code), ).fetchone() return dict(row) if row else None except Exception as e: @@ -389,19 +469,21 @@ class TradeDBExt: ) -> Optional[Dict]: """ODNO 로 주문 조회. ODNO 는 더 이상 전역 유일하지 않으므로 (모의투자 ODNO 재사용 버그) strategy_id·code 를 함께 주면 정확히 - 그 행만, 안 주면 최신 순 1건을 반환한다.""" + 그 행만, 안 주면 최신 순 1건을 반환한다. 현재 KIS_MOCK 우선.""" + mock_flag = self._resolve_is_mock(None) try: if strategy_id and code: row = self.conn.execute( "SELECT * FROM orders WHERE ord_no=%s AND strategy_id=%s AND code=%s " + "AND is_mock=%s " "ORDER BY submitted_at DESC LIMIT 1", - (ord_no, strategy_id, code), + (ord_no, strategy_id, code, mock_flag), ).fetchone() else: row = self.conn.execute( - "SELECT * FROM orders WHERE ord_no=%s " + "SELECT * FROM orders WHERE ord_no=%s AND is_mock=%s " "ORDER BY submitted_at DESC LIMIT 1", - (ord_no,), + (ord_no, mock_flag), ).fetchone() return dict(row) if row else None except Exception as e: @@ -412,24 +494,25 @@ class TradeDBExt: self, strategy_id: Optional[str] = None ) -> List[Dict]: today = datetime.datetime.now().strftime("%Y-%m-%d") + mock_flag = self._resolve_is_mock(None) try: if strategy_id: rows = self.conn.execute( """ SELECT * FROM orders - WHERE ord_date=%s AND strategy_id=%s + WHERE ord_date=%s AND strategy_id=%s AND is_mock=%s ORDER BY submitted_at DESC """, - (today, strategy_id), + (today, strategy_id, mock_flag), ).fetchall() else: rows = self.conn.execute( """ SELECT * FROM orders - WHERE ord_date=%s + WHERE ord_date=%s AND is_mock=%s ORDER BY submitted_at DESC """, - (today,), + (today, mock_flag), ).fetchall() return [dict(r) for r in rows] except Exception as e: @@ -498,6 +581,9 @@ class TradeDBExt: "(strategy_id, event_time)"), ("idx_tch_slot_sid", "(slot_key, strategy_id)"), + # resolve_stock_display_name · 조건 REAL 편입 시 code→name (206만+행 풀스캔 방지) + ("idx_tch_code_id", + "(code, id)"), ): try: self.conn.execute( diff --git a/kis_trader/utils/kr_trading_day.py b/kis_trader/utils/kr_trading_day.py index e3a85c7..ad18116 100644 --- a/kis_trader/utils/kr_trading_day.py +++ b/kis_trader/utils/kr_trading_day.py @@ -145,6 +145,41 @@ def is_kr_trading_day(d: DateLike, *, holidays: Optional[Set[date]] = None) -> b return day not in hol +def iter_kr_trading_days( + start: DateLike, + end: DateLike, + *, + holidays: Optional[Set[date]] = None, +) -> List[date]: + """[start, end] 구간의 한국 거래일 목록 (주말·휴장 제외, 오름차순).""" + a = _parse_ymd(start) + b = _parse_ymd(end) + if a > b: + a, b = b, a + hol = holidays if holidays is not None else get_kr_market_holiday_set() + out: List[date] = [] + cur = a + while cur <= b: + if is_kr_trading_day(cur, holidays=hol): + out.append(cur) + cur += timedelta(days=1) + return out + + +def count_kr_trading_days( + start: DateLike, + end: DateLike, + *, + holidays: Optional[Set[date]] = None, +) -> int: + """[start, end] 한국 거래일 수. 잘못된 날짜면 1(최소 표본).""" + try: + n = len(iter_kr_trading_days(start, end, holidays=holidays)) + return max(1, int(n)) + except Exception: + return 1 + + def clamp_to_prev_kr_trading_day( d: DateLike, *,