feat(vol_breakout): 15m 현물 롱 라이브·모니터·cron 운영 추가
vol_breakout 멀티종목 tick, vol_live HTML 모니터, 마감 봉만 저장하는 캔들 다운로드, 텔레그램 체결 알림, cron/watch 감시 스크립트 및 테스트를 포함한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
229
scripts/3_audit_ops_safety.py
Normal file
229
scripts/3_audit_ops_safety.py
Normal file
@@ -0,0 +1,229 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user