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:
dsyoon
2026-06-29 08:31:14 +09:00
parent 8413bbd536
commit 72de8d534e
58 changed files with 6008 additions and 1286 deletions

View 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())