#!/usr/bin/env python3 """운영 신호·체결·거래소 주문 최종 점검 (놓침/중복).""" from __future__ import annotations import json import sys from collections import Counter from datetime import datetime, timedelta from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) from bithumb.api.bithumb_private import BithumbPrivateClient from bithumb.config import load_settings from bithumb.operations.exchange_reconcile import ( _known_order_uuids, _match_orders_to_signals, _unsettled_signals_for_reconcile, reconcile_exchange_fills, ) from bithumb.operations.runner import ( _history_index, _is_settled, _is_signal_api_executable, _ledger_pending_signals, _settle_expired_backlog, ) from bithumb.operations.signal_pipeline import ( filter_signals_for_ops, generate_raw_signals, load_ops_candles, ) from bithumb.operations.state_store import load_state def _parse_dt(value: str) -> datetime: return datetime.strptime(value, "%Y-%m-%d %H:%M:%S") def main() -> int: settings = load_settings() state = load_state( settings.ops_state_json, initial_cash_krw=settings.gt_initial_cash_krw, ) df = load_ops_candles(settings) latest_bar = len(df) - 1 gen = generate_raw_signals( settings, df=df, use_cache=True, force_tail_refresh=( settings.ops_mode == "live" and settings.ops_live_force_tail_refresh ), ) all_kept = filter_signals_for_ops(settings, gen["raw_signals"])["kept"] trade_history = list(state.get("trade_history") or []) live_since = ( str(state["live_initialized_at"]) if settings.ops_mode == "live" and state.get("live_initialized_at") else None ) max_age = settings.ops_ledger_execute_max_age_minutes now = datetime.now() # tick과 동일 순서 시뮬레이션 (state 파일은 쓰지 않음) sim_history = list(trade_history) exchange_reconciled: list[dict] = [] stale_settled: list[dict] = [] if settings.ops_mode == "live" and settings.ops_exchange_reconcile: client = BithumbPrivateClient( access_key=settings.bithumb_access_key, secret_key=settings.bithumb_secret_key, base_url=settings.api_url, sleep_sec=settings.request_sleep_sec, retries=settings.request_retries, ) exchange_reconciled = reconcile_exchange_fills( all_kept, sim_history, client=client, market=settings.market, lookback_hours=settings.ops_exchange_order_lookback_hours, match_window_min=settings.ops_exchange_match_window_min, lookback_days=settings.ops_ledger_lookback_days, ) sim_history.extend(exchange_reconciled) stale_settled = _settle_expired_backlog( all_kept, sim_history, max_age_minutes=max_age, live_since=live_since, lookback_days=settings.ops_ledger_lookback_days, ) sim_history.extend(stale_settled) ledger_pending = _ledger_pending_signals( all_kept, sim_history, latest_bar_index=latest_bar, lookback_days=settings.ops_ledger_lookback_days, ) executable_pending: list[dict] = [] expired_pending: list[dict] = [] for sig in ledger_pending: ok, reason = _is_signal_api_executable( sig, max_age_minutes=max_age, live_since=live_since, now=now, ) if ok: executable_pending.append(sig) else: expired_pending.append({**sig, "skip_reason": reason}) # 중복 uuid uuids = [] for rec in sim_history: trade = rec.get("trade") or {} if trade.get("executed"): resp = trade.get("api_response") if isinstance(resp, dict) and resp.get("uuid"): uuids.append(str(resp["uuid"])) uuid_counts = Counter(uuids) dup_uuids = {u: c for u, c in uuid_counts.items() if c > 1} # history 내 동일 신호 executed 중복 idx = _history_index(sim_history) executed_keys = [ key for key, rec in idx.items() if (rec.get("trade") or {}).get("executed") ] raw_executed_count = sum( 1 for rec in sim_history if (rec.get("trade") or {}).get("executed") ) # 거래소 주문 vs 미정산 (reconcile 후에도 남는 orphan) orphan_orders = 0 unmatched_unsettled = len( _unsettled_signals_for_reconcile( all_kept, sim_history, lookback_days=settings.ops_ledger_lookback_days, ) ) if settings.ops_mode == "live": client = BithumbPrivateClient( access_key=settings.bithumb_access_key, secret_key=settings.bithumb_secret_key, base_url=settings.api_url, sleep_sec=settings.request_sleep_sec, retries=settings.request_retries, ) since = now - timedelta(hours=settings.ops_exchange_order_lookback_hours) orders = client.fetch_filled_orders_since(settings.market, since) known = _known_order_uuids(sim_history) unsettled = _unsettled_signals_for_reconcile( all_kept, sim_history, lookback_days=settings.ops_ledger_lookback_days, ) matches = _match_orders_to_signals( orders, unsettled, match_window_min=settings.ops_exchange_match_window_min, known_uuids=set(known), ) orphan_orders = len(orders) - len(matches) - len( [u for u in known if u in {str(o.get("uuid")) for o in orders}] ) print("=== 최종 점검 (놓침/중복) ===") print(f"mode: {settings.ops_mode}") print(f"now: {now.strftime('%Y-%m-%d %H:%M:%S')}") print(f"kept_signals: {len(all_kept)} | latest_bar: {latest_bar}") print(f"trade_history (raw): {len(trade_history)}") print() print("[tick 시뮬레이션 — exchange → stale 순]") print(f" exchange_reconciled (would add): {len(exchange_reconciled)}") print(f" stale_settled (would add): {len(stale_settled)}") print(f" ledger_pending (after sim): {len(ledger_pending)}") print(f" executable_pending (API 체결 대상): {len(executable_pending)}") print(f" expired_still_pending (버그 의심): {len(expired_pending)}") print() print("[중복]") print(f" executed records (raw): {raw_executed_count}") print(f" unique executed keys: {len(executed_keys)}") print(f" duplicate uuid in history: {len(dup_uuids)}") if dup_uuids: for u, c in list(dup_uuids.items())[:5]: print(f" uuid={u} count={c}") print() print("[놓침 위험]") print(f" unsettled after sim: {unmatched_unsettled}") print(f" orphan exchange orders (approx): {max(orphan_orders, 0)}") if executable_pending: print(" executable_pending 목록:") for sig in executable_pending[:15]: print(f" {sig['datetime']} {sig['side']}") if len(executable_pending) > 15: print(f" ... 외 {len(executable_pending) - 15}건") if expired_pending: print(" expired_still_pending (stale 미적용 의심):") for sig in expired_pending[:10]: print(f" {sig['datetime']} {sig['side']} — {sig.get('skip_reason')}") ok = ( len(dup_uuids) == 0 and len(expired_pending) == 0 and len(executable_pending) <= settings.ops_max_backlog_per_tick ) print() print("RESULT:", "PASS" if ok else "REVIEW_NEEDED") return 0 if ok else 1 if __name__ == "__main__": raise SystemExit(main())