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:
73
tests/test_candle_bars.py
Normal file
73
tests/test_candle_bars.py
Normal 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()
|
||||
77
tests/test_exchange_reconcile.py
Normal file
77
tests/test_exchange_reconcile.py
Normal 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
143
tests/test_ops_ledger.py
Normal 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
|
||||
46
tests/test_vol_breakout.py
Normal file
46
tests/test_vol_breakout.py
Normal 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
|
||||
66
tests/test_vol_breakout_telegram.py
Normal file
66
tests/test_vol_breakout_telegram.py
Normal 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
|
||||
78
tests/test_vol_live_monitor.py
Normal file
78
tests/test_vol_live_monitor.py
Normal 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
42
tests/test_watch_ops.py
Normal 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 == []
|
||||
72
tests/test_watch_vol_ops.py
Normal file
72
tests/test_watch_vol_ops.py
Normal 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
|
||||
Reference in New Issue
Block a user