vol_breakout 멀티종목 tick, vol_live HTML 모니터, 마감 봉만 저장하는 캔들 다운로드, 텔레그램 체결 알림, cron/watch 감시 스크립트 및 테스트를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
230 lines
7.7 KiB
Python
230 lines
7.7 KiB
Python
#!/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())
|