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

73
tests/test_candle_bars.py Normal file
View File

@@ -0,0 +1,73 @@
"""캔들 봉 마감·다운로드 필터."""
from __future__ import annotations
from datetime import datetime, timedelta
from bithumb.data.candle_bars import (
is_closed_candle,
last_closed_bar_open,
only_closed_candles,
)
def test_is_closed_candle_15m() -> None:
"""15분봉은 마감 시각 이후에만 closed."""
bar_open = datetime(2026, 6, 27, 16, 15, 0)
assert not is_closed_candle(
bar_open, 15, now=datetime(2026, 6, 27, 16, 29, 59)
)
assert is_closed_candle(
bar_open, 15, now=datetime(2026, 6, 27, 16, 30, 0)
)
def test_only_closed_candles_filters_forming_bar() -> None:
"""API 배치에서 진행 중 봉은 제외."""
rows = [
("2026-06-27 16:00:00", 100.0, 110.0, 95.0, 105.0, 1.0),
("2026-06-27 16:15:00", 105.0, 105.0, 105.0, 105.0, 0.1),
]
now = datetime(2026, 6, 27, 16, 20, 0)
closed = only_closed_candles(rows, 15, now=now)
assert len(closed) == 1
assert closed[0][0] == "2026-06-27 16:00:00"
def test_last_closed_bar_open() -> None:
"""진행 중인 16:15~16:30 봉 기준 최근 마감은 16:00."""
now = datetime(2026, 6, 27, 16, 20, 0)
assert last_closed_bar_open(now, 15) == datetime(2026, 6, 27, 16, 0, 0)
now = datetime(2026, 6, 27, 16, 30, 0)
assert last_closed_bar_open(now, 15) == datetime(2026, 6, 27, 16, 15, 0)
def test_only_closed_candles_keeps_all_historical() -> None:
"""과거 봉은 모두 마감으로 간주."""
rows = [
("2026-06-26 20:00:00", 1.0, 2.0, 0.5, 1.5, 10.0),
("2026-06-26 20:15:00", 1.5, 2.5, 1.0, 2.0, 8.0),
]
now = datetime(2026, 6, 27, 22, 0, 0)
assert len(only_closed_candles(rows, 15, now=now)) == 2
def test_delete_incomplete_tail(tmp_path) -> None:
"""미마감 최신 봉이 DB에서 제거된다."""
from bithumb.data.candle_store import CandleStore
db = tmp_path / "t.db"
store = CandleStore(db)
rows = [
("2026-06-27 16:00:00", 100.0, 110.0, 95.0, 105.0, 1.0),
("2026-06-27 16:15:00", 105.0, 105.0, 105.0, 105.0, 0.1),
]
store.upsert_rows("NEAR", "NEAR", 15, rows)
deleted = store.delete_incomplete_tail(
"NEAR", 15, now=datetime(2026, 6, 27, 16, 20, 0)
)
assert deleted == 1
_, _, db_max = store.get_range("NEAR", 15)
assert db_max == datetime(2026, 6, 27, 16, 0, 0)
store.close()

View File

@@ -0,0 +1,77 @@
"""exchange reconcile 단위 테스트."""
from __future__ import annotations
from datetime import datetime, timedelta
from bithumb.operations.exchange_reconcile import (
_match_orders_to_signals,
_trade_from_exchange_order,
)
def test_match_orders_to_signals_by_side_and_time() -> None:
"""같은 side·시간 창 안에서 주문-신호 1:1 매칭."""
sig_dt = datetime(2026, 6, 14, 10, 48, 0)
signals = [
{"datetime": sig_dt.strftime("%Y-%m-%d %H:%M:%S"), "side": "buy", "bar_index": 1},
]
order_dt = sig_dt + timedelta(minutes=30)
orders = [
{
"uuid": "order-1",
"side": "bid",
"created_at": order_dt.isoformat(),
"executed_volume": "0.001",
"executed_funds": "100000",
},
]
matches = _match_orders_to_signals(
orders,
signals,
match_window_min=720,
known_uuids=set(),
)
assert len(matches) == 1
assert matches[0][0]["side"] == "buy"
assert matches[0][1]["uuid"] == "order-1"
def test_match_skips_known_uuid() -> None:
"""이미 history에 있는 uuid는 재매칭하지 않는다."""
sig_dt = datetime(2026, 6, 14, 11, 0, 0)
signals = [
{"datetime": sig_dt.strftime("%Y-%m-%d %H:%M:%S"), "side": "sell", "bar_index": 2},
]
orders = [
{
"uuid": "already-used",
"side": "ask",
"created_at": (sig_dt + timedelta(minutes=5)).isoformat(),
"executed_volume": "0.001",
"executed_funds": "100000",
},
]
matches = _match_orders_to_signals(
orders,
signals,
match_window_min=720,
known_uuids={"already-used"},
)
assert matches == []
def test_trade_from_exchange_order_buy() -> None:
"""매수 체결 → executed TradeResult."""
sig = {"datetime": "2026-06-14 10:48:00", "side": "buy", "price": 140_000_000.0}
order = {
"uuid": "x",
"side": "bid",
"executed_volume": "0.002",
"executed_funds": "280000",
}
trade = _trade_from_exchange_order(sig, order)
assert trade.executed is True
assert trade.side == "buy"
assert trade.order_coin == 0.002
assert trade.api_response == order

143
tests/test_ops_ledger.py Normal file
View File

@@ -0,0 +1,143 @@
"""ledger pending 단위 테스트."""
from __future__ import annotations
from bithumb.operations.runner import (
_advance_cursor_from_ledger,
_apply_backlog_limit,
_is_settled,
_is_signal_api_executable,
_ledger_pending_signals,
_merge_pending_signals,
_settle_expired_backlog,
)
from datetime import datetime
def test_is_settled_executed_or_expected_skip() -> None:
"""executed 또는 expected_skip이면 settled."""
assert _is_settled({"executed": True, "expected_skip": False})
assert _is_settled({"executed": False, "expected_skip": True})
assert not _is_settled({"executed": False, "expected_skip": False})
def test_ledger_pending_skips_settled_and_includes_missing() -> None:
"""history settled 신호는 제외, 미기록 신호는 pending."""
kept = [
{"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 10},
{"datetime": "2026-06-14 14:15:00", "side": "sell", "bar_index": 11},
{"datetime": "2026-06-14 14:51:00", "side": "sell", "bar_index": 12},
]
history = [
{
"datetime": "2026-06-14 14:09:00",
"side": "buy",
"trade": {"executed": True, "expected_skip": False},
},
{
"datetime": "2026-06-14 14:51:00",
"side": "sell",
"trade": {"executed": False, "expected_skip": True},
},
]
pending = _ledger_pending_signals(
kept,
history,
latest_bar_index=12,
lookback_days=3,
)
assert len(pending) == 1
assert pending[0]["datetime"] == "2026-06-14 14:15:00"
def test_ledger_pending_includes_failed_api() -> None:
"""API 실패(expected_skip false)는 재시도 대상."""
kept = [
{"datetime": "2026-06-14 15:00:00", "side": "buy", "bar_index": 20},
]
history = [
{
"datetime": "2026-06-14 15:00:00",
"side": "buy",
"trade": {"executed": False, "expected_skip": False},
},
]
pending = _ledger_pending_signals(
kept,
history,
latest_bar_index=20,
lookback_days=3,
)
assert len(pending) == 1
def test_apply_backlog_limit() -> None:
"""tick당 backlog 상한."""
signals = [
{"datetime": f"2026-06-14 10:{i:02d}:00", "side": "buy", "bar_index": i}
for i in range(5)
]
limited, dropped = _apply_backlog_limit(signals, 2)
assert len(limited) == 2
assert dropped == 3
def test_advance_cursor_from_ledger() -> None:
"""settled 신호까지만 커서 전진."""
kept = [
{"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 10},
{"datetime": "2026-06-14 14:15:00", "side": "sell", "bar_index": 11},
]
history = [
{
"datetime": "2026-06-14 14:09:00",
"side": "buy",
"trade": {"executed": True},
},
]
state: dict = {
"last_processed_datetime": "2026-06-14 13:00:00",
"last_processed_bar_index": 5,
}
_advance_cursor_from_ledger(state, kept, history)
assert state["last_processed_datetime"] == "2026-06-14 14:09:00"
assert state["last_processed_bar_index"] == 10
def test_settle_expired_backlog() -> None:
"""만료 backlog는 API 없이 expected_skip 정산."""
from datetime import timedelta
now = datetime.now()
old_dt = (now - timedelta(hours=2)).strftime("%Y-%m-%d %H:%M:%S")
kept = [{"datetime": old_dt, "side": "buy", "bar_index": 1, "price": 1.0}]
settled = _settle_expired_backlog(
kept,
[],
max_age_minutes=45,
live_since=None,
)
assert len(settled) == 1
assert settled[0]["trade"]["expected_skip"] is True
assert "backlog 만료" in settled[0]["trade"]["skip_reason"]
def test_is_signal_api_executable_recent() -> None:
"""최근 신호는 API 체결 가능."""
from datetime import timedelta
recent = (datetime.now() - timedelta(minutes=10)).strftime("%Y-%m-%d %H:%M:%S")
ok, _ = _is_signal_api_executable(
{"datetime": recent, "side": "buy"},
max_age_minutes=45,
live_since=None,
)
assert ok
def test_merge_pending_dedupes() -> None:
"""ledger·catchup 병합 시 datetime·side 중복 제거."""
a = [{"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 1}]
b = [{"datetime": "2026-06-14 14:09:00", "side": "buy", "bar_index": 1}]
merged = _merge_pending_signals(a, b)
assert len(merged) == 1

View File

@@ -0,0 +1,46 @@
"""vol_breakout 현물 롱 단위 테스트."""
from __future__ import annotations
import pandas as pd
from bithumb.operations.multi_portfolio import count_empty_buy_slots, empty_multi_portfolio
from bithumb.simulation.vol_breakout import (
baseline_15m_signal_at,
spot_long_action,
)
def test_spot_long_action_buy_only_when_flat() -> None:
assert spot_long_action(1, False) == "buy"
assert spot_long_action(1, True) is None
def test_spot_long_action_sell_only_when_long() -> None:
assert spot_long_action(-1, True) == "sell"
assert spot_long_action(-1, False) is None
def test_baseline_signal_breakout() -> None:
n = 30
closes = [100.0] * n
closes[-1] = 120.0
df = pd.DataFrame({
"datetime": pd.date_range("2026-01-01", periods=n, freq="15min"),
"open": closes,
"high": [c + 1 for c in closes],
"low": [c - 1 for c in closes],
"close": closes,
"volume": [1.0] * n,
})
sig = baseline_15m_signal_at(df, n - 1, lookback=5, atr_mult=0.01)
assert sig == 1
def test_empty_buy_slots_dynamic_split() -> None:
pf = empty_multi_portfolio(["TRX", "NEAR", "WLD"], cash_krw=900_000)
assert count_empty_buy_slots(pf, ["TRX", "NEAR", "WLD"]) == 3
pf["positions"]["TRX"]["coin_qty"] = 100.0
assert count_empty_buy_slots(pf, ["TRX", "NEAR", "WLD"]) == 2
pf["positions"]["NEAR"]["coin_qty"] = 10.0
assert count_empty_buy_slots(pf, ["TRX", "NEAR", "WLD"]) == 1

View File

@@ -0,0 +1,66 @@
"""vol_breakout 텔레그램 알림 포맷."""
from bithumb.notifications.telegram import TelegramNotifier
def test_notify_vol_breakout_buy_message(monkeypatch) -> None:
"""매수 알림이 Binance 스타일 형식인지 확인."""
sent: list[str] = []
def _capture(text: str) -> bool:
sent.append(text)
return True
n = TelegramNotifier("token", "123", enabled=True)
monkeypatch.setattr(n, "send_message", _capture)
n.notify_vol_breakout_trade(
mode="live",
symbol="TRX",
side="buy",
price=489.24,
order_krw=1_187_000,
order_coin=2.426929,
equity_krw=500_000,
ts="2026-06-27 19:30:05",
)
assert len(sent) == 1
text = sent[0]
assert "[실거래] 롱 진입(매수)" in text
assert "TRXKRW @ 489.24" in text
assert "수량 2.426929" in text
assert "≈1,187,000원" in text
assert "사유 signal_vol_breakout" in text
assert "시각 2026-06-27 19:30:05" in text
def test_notify_vol_breakout_sell_message(monkeypatch) -> None:
"""매도 알림에 손익·자본이 포함되는지 확인."""
sent: list[str] = []
def _capture(text: str) -> bool:
sent.append(text)
return True
n = TelegramNotifier("token", "123", enabled=True)
monkeypatch.setattr(n, "send_message", _capture)
n.notify_vol_breakout_trade(
mode="live",
symbol="TRX",
side="sell",
price=486.76,
order_krw=1_180_000,
order_coin=2.426929,
equity_krw=499_691,
pnl_krw=-7_000,
pnl_pct=-0.590,
ts="2026-06-27 19:45:05",
)
text = sent[0]
assert "[실거래] 롱 청산(매도)" in text
assert "TRXKRW @ 486.76" in text
assert "손익 -7,000원 (-0.590%)" in text
assert "자본 499,691원" in text

View File

@@ -0,0 +1,78 @@
"""vol_live 모니터 에쿼티·B&H 테스트."""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from bithumb.operations.vol_live_monitor import (
build_multi_buyhold_series,
build_spot_strategy_equity_series,
)
def test_multi_buyhold_thirds() -> None:
"""3종목 1/3씩 B&H — 한 종목만 10% 상승 시 포트폴리오 +3.33% 근사."""
panel = pd.DataFrame({
"datetime": pd.to_datetime(["2026-06-01 10:00:00", "2026-06-01 10:15:00"]),
"TRX": [100.0, 110.0],
"NEAR": [200.0, 200.0],
"WLD": [300.0, 300.0],
})
bh = build_multi_buyhold_series(panel, ["TRX", "NEAR", "WLD"], seed_krw=300_000.0)
assert bh[0]["value"] == 0.0
assert bh[1]["value"] == pytest.approx(3.3333, rel=1e-3)
def test_strategy_replay_buy_sell() -> None:
panel = pd.DataFrame({
"datetime": pd.to_datetime(["2026-06-01 10:00:00", "2026-06-01 10:15:00"]),
"TRX": [100.0, 110.0],
"NEAR": [200.0, 200.0],
"WLD": [300.0, 300.0],
})
trades = [
{
"symbol": "TRX",
"side": "buy",
"ts": "2026-06-01 10:05:00",
"order_krw": 100_000.0,
"order_coin": 1000.0,
"price": 100.0,
},
]
curve = build_spot_strategy_equity_series(
panel,
["TRX", "NEAR", "WLD"],
trades,
seed_krw=300_000.0,
current_equity=310_000.0,
window_start=pd.Timestamp("2026-06-01 10:00:00"),
)
assert curve[0]["value"] == 0.0
assert curve[-1]["value"] == pytest.approx(3.3333, rel=1e-2)
def test_write_vol_monitor_html_no_format_error(tmp_path: Path) -> None:
import re
from bithumb.operations.vol_monitor_chart import write_vol_monitor_html
out = tmp_path / "vol_live_monitor.html"
write_vol_monitor_html(out)
text = out.read_text(encoding="utf-8")
assert "/api/chart" in text
assert "equityChart" in text
js = re.search(r"<script>(.*)</script>", text, re.S)
assert js is not None
js_path = tmp_path / "monitor.js"
js_path.write_text(js.group(1), encoding="utf-8")
import subprocess
subprocess.run(["node", "--check", str(js_path)], check=True, capture_output=True)

42
tests/test_watch_ops.py Normal file
View File

@@ -0,0 +1,42 @@
"""watch_ops 단위 테스트."""
from __future__ import annotations
from datetime import datetime, timedelta
from bithumb.operations.watch_ops import WatchIssue, WatchReport, remediate_ops_watch
class _FakeSettings:
ops_mode = "live"
ops_telegram_enabled = False
telegram_bot_token = ""
telegram_chat_id = ""
ops_watch_auto_remediate = True
ops_watch_auto_restart = False
ops_tick_lock_path = None
ops_loop_pid_file = None
def test_remediate_dry_run_no_actions() -> None:
"""dry-run은 tick/재시작 없음."""
report = WatchReport(
checked_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
issues=[
WatchIssue(
kind="miss_pending",
severity="critical",
message="test",
)
],
)
result = remediate_ops_watch(_FakeSettings(), report, dry_run=True)
assert "dry_run" in result.actions
assert result.tick_report is None
def test_remediate_ok_when_no_issues() -> None:
"""이슈 없으면 조치 없음."""
report = WatchReport(checked_at="2026-06-14 20:00:00")
result = remediate_ops_watch(_FakeSettings(), report, dry_run=False)
assert result.actions == []

View File

@@ -0,0 +1,72 @@
"""watch_ops vol_breakout 분기."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from bithumb.operations.watch_ops import (
WatchIssue,
WatchReport,
inspect_vol_watch,
is_vol_breakout_ops,
remediate_vol_watch,
)
def test_is_vol_breakout_ops(tmp_path: Path) -> None:
"""vol state 파일이 있으면 vol 감시 모드."""
state_path = tmp_path / "vol_breakout_state.json"
state_path.write_text(
json.dumps({"strategy": "vol_breakout_15m_spot_long", "symbols": {}}),
encoding="utf-8",
)
settings = SimpleNamespace(vol_state_json=state_path)
assert is_vol_breakout_ops(settings) is True
def test_is_vol_breakout_ops_missing_file(tmp_path: Path) -> None:
"""state 없으면 fractal 감시."""
settings = SimpleNamespace(vol_state_json=tmp_path / "missing.json")
assert is_vol_breakout_ops(settings) is False
def test_inspect_vol_watch_recent_tick(tmp_path: Path) -> None:
"""최근 tick이면 이슈 없음."""
state_path = tmp_path / "vol.json"
state_path.write_text(
json.dumps({
"strategy": "vol_breakout_15m_spot_long",
"symbols": {},
"last_run_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}),
encoding="utf-8",
)
settings = SimpleNamespace(
vol_state_json=state_path,
ops_watch_tick_stale_min=12,
)
report = inspect_vol_watch(settings)
assert report.issues == []
def test_remediate_vol_dry_run() -> None:
"""vol dry-run은 tick 없음."""
report = WatchReport(
checked_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
issues=[
WatchIssue(kind="tick_stale", severity="critical", message="stale"),
],
)
settings = SimpleNamespace(
ops_telegram_enabled=False,
telegram_bot_token="",
telegram_chat_id="",
ops_watch_auto_remediate=True,
ops_mode="live",
)
result = remediate_vol_watch(settings, report, dry_run=True)
assert "dry_run" in result.actions
assert result.tick_report is None