Initial public release
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
r = MagicMock()
|
||||
r.set.return_value = True
|
||||
r.get.return_value = None
|
||||
r.delete.return_value = True
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_exchange():
|
||||
ex = AsyncMock()
|
||||
ex.cancel_all.return_value = []
|
||||
ex.get_positions.return_value = []
|
||||
ex.cancel_order.return_value = {"status": "canceled"}
|
||||
return ex
|
||||
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
from hydra.data.models import Candle
|
||||
from hydra.data.backfill import Backfiller
|
||||
from hydra.data.storage.base import OhlcvStore
|
||||
|
||||
|
||||
def make_raw_ohlcv(base_time: int, count: int) -> list:
|
||||
"""Return ccxt fetch_ohlcv-style rows: [ts_ms, o, h, l, c, v]."""
|
||||
return [
|
||||
[base_time + i * 60_000, 50000.0, 50100.0, 49900.0, 50050.0, 1.5]
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
store = AsyncMock(spec=OhlcvStore)
|
||||
store.get_last_time.return_value = None
|
||||
store.save.return_value = None
|
||||
return store
|
||||
|
||||
|
||||
async def test_backfill_one_saves_candles(mock_store):
|
||||
fetch_fn = AsyncMock(return_value=make_raw_ohlcv(1_000_000, 5))
|
||||
filler = Backfiller(mock_store)
|
||||
await filler._backfill_one("binance", "BTC/USDT", "1m", fetch_fn=fetch_fn, limit=5)
|
||||
|
||||
mock_store.save.assert_awaited_once()
|
||||
saved: list[Candle] = mock_store.save.call_args[0][0]
|
||||
assert len(saved) == 5
|
||||
assert isinstance(saved[0], Candle)
|
||||
assert saved[0].open_time == 1_000_000
|
||||
assert saved[0].market == "binance"
|
||||
assert saved[0].symbol == "BTC/USDT"
|
||||
assert saved[0].timeframe == "1m"
|
||||
assert saved[0].close_time == 1_000_000 + 60_000 - 1
|
||||
|
||||
|
||||
async def test_backfill_one_skips_empty_response(mock_store):
|
||||
fetch_fn = AsyncMock(return_value=[])
|
||||
filler = Backfiller(mock_store)
|
||||
await filler._backfill_one("binance", "BTC/USDT", "1m", fetch_fn=fetch_fn)
|
||||
mock_store.save.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_gap_backfill_uses_last_time(mock_store):
|
||||
mock_store.get_last_time.return_value = 1_060_000
|
||||
fetch_fn = AsyncMock(return_value=make_raw_ohlcv(1_060_000, 3))
|
||||
filler = Backfiller(mock_store)
|
||||
await filler.gap_backfill("binance", "BTC/USDT", "1m", fetch_fn=fetch_fn)
|
||||
|
||||
kwargs = fetch_fn.call_args[1]
|
||||
assert kwargs.get("since") == 1_060_000
|
||||
|
||||
|
||||
async def test_gap_backfill_fetches_500_when_no_history(mock_store):
|
||||
mock_store.get_last_time.return_value = None
|
||||
fetch_fn = AsyncMock(return_value=make_raw_ohlcv(1_000_000, 500))
|
||||
filler = Backfiller(mock_store)
|
||||
await filler.gap_backfill("binance", "BTC/USDT", "1m", fetch_fn=fetch_fn)
|
||||
|
||||
kwargs = fetch_fn.call_args[1]
|
||||
assert kwargs.get("limit") == 500
|
||||
@@ -0,0 +1,56 @@
|
||||
# tests/test_backtest_broker.py
|
||||
import pytest
|
||||
from hydra.backtest.broker import BacktestBroker
|
||||
from hydra.backtest.result import Trade
|
||||
from hydra.data.models import Candle
|
||||
from hydra.strategy.signal import Signal
|
||||
|
||||
|
||||
def _make_candle(close: float, ts: int = 1000) -> Candle:
|
||||
return Candle(
|
||||
market="binance", symbol="BTC/USDT", timeframe="1h",
|
||||
open_time=ts, open=close, high=close, low=close,
|
||||
close=close, volume=1.0, close_time=ts + 3600000,
|
||||
)
|
||||
|
||||
|
||||
def _make_signal(sig: str, price: float, reason: str = "test") -> Signal:
|
||||
return Signal(signal=sig, reason=reason, price=price, ts=1000)
|
||||
|
||||
|
||||
def test_buy_opens_position():
|
||||
broker = BacktestBroker(initial_capital=10000.0, trade_amount_usd=100.0,
|
||||
commission_pct=0.0)
|
||||
broker.on_signal(_make_signal("BUY", 100.0), _make_candle(100.0, ts=1000))
|
||||
assert len(broker.trades) == 0 # trade recorded only on close
|
||||
assert broker.equity == pytest.approx(10000.0, abs=0.01)
|
||||
|
||||
|
||||
def test_sell_closes_position_and_records_trade():
|
||||
broker = BacktestBroker(initial_capital=10000.0, trade_amount_usd=100.0,
|
||||
commission_pct=0.0)
|
||||
broker.on_signal(_make_signal("BUY", 100.0), _make_candle(100.0, ts=1000))
|
||||
broker.on_signal(_make_signal("SELL", 110.0), _make_candle(110.0, ts=2000))
|
||||
assert len(broker.trades) == 1
|
||||
trade = broker.trades[0]
|
||||
assert trade.entry_price == 100.0
|
||||
assert trade.exit_price == 110.0
|
||||
assert trade.pnl_usd == pytest.approx(10.0, abs=0.01) # 1.0 qty * 10 price diff
|
||||
assert broker.equity == pytest.approx(10010.0, abs=0.01)
|
||||
|
||||
|
||||
def test_sell_without_position_is_ignored():
|
||||
broker = BacktestBroker(initial_capital=10000.0, trade_amount_usd=100.0)
|
||||
broker.on_signal(_make_signal("SELL", 110.0), _make_candle(110.0))
|
||||
assert len(broker.trades) == 0
|
||||
assert broker.equity == pytest.approx(10000.0, abs=0.01)
|
||||
|
||||
|
||||
def test_force_close_records_trade():
|
||||
broker = BacktestBroker(initial_capital=10000.0, trade_amount_usd=100.0,
|
||||
commission_pct=0.0)
|
||||
broker.on_signal(_make_signal("BUY", 100.0), _make_candle(100.0, ts=1000))
|
||||
broker.close_open_position(price=120.0, ts=9000, reason="backtest_end")
|
||||
assert len(broker.trades) == 1
|
||||
assert broker.trades[0].exit_price == 120.0
|
||||
assert broker.trades[0].pnl_usd == pytest.approx(20.0, abs=0.01)
|
||||
@@ -0,0 +1,175 @@
|
||||
# tests/test_backtest_runner.py
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from hydra.backtest.result import Trade, BacktestResult, compute_metrics
|
||||
|
||||
|
||||
def _make_trade(pnl_usd: float, entry_price: float = 100.0,
|
||||
exit_price: float = 110.0) -> Trade:
|
||||
return Trade(
|
||||
market="binance",
|
||||
symbol="BTC/USDT",
|
||||
entry_price=entry_price,
|
||||
exit_price=exit_price,
|
||||
qty=1.0,
|
||||
pnl_usd=pnl_usd,
|
||||
pnl_pct=(exit_price - entry_price) / entry_price * 100,
|
||||
entry_ts=1000,
|
||||
exit_ts=2000,
|
||||
entry_reason="trend_up: ema_cross+rsi",
|
||||
exit_reason="trend_down: ema_cross+rsi",
|
||||
)
|
||||
|
||||
|
||||
def test_compute_metrics_no_trades():
|
||||
metrics = compute_metrics(
|
||||
trades=[],
|
||||
equity_curve=[{"ts": 1000, "equity": 10000.0}],
|
||||
initial_capital=10000.0,
|
||||
final_equity=10000.0,
|
||||
)
|
||||
assert metrics["total_return_pct"] == 0.0
|
||||
assert metrics["total_trades"] == 0
|
||||
assert metrics["win_rate"] == 0.0
|
||||
assert metrics["max_drawdown_pct"] == 0.0
|
||||
assert metrics["sharpe_ratio"] == 0.0
|
||||
assert metrics["avg_pnl_usd"] == 0.0
|
||||
|
||||
|
||||
def test_compute_metrics_with_trades():
|
||||
trades = [_make_trade(50.0), _make_trade(-20.0), _make_trade(30.0)]
|
||||
equity_curve = [
|
||||
{"ts": 1000, "equity": 10000.0},
|
||||
{"ts": 2000, "equity": 10050.0},
|
||||
{"ts": 3000, "equity": 10030.0},
|
||||
{"ts": 4000, "equity": 10060.0},
|
||||
]
|
||||
metrics = compute_metrics(
|
||||
trades=trades,
|
||||
equity_curve=equity_curve,
|
||||
initial_capital=10000.0,
|
||||
final_equity=10060.0,
|
||||
)
|
||||
assert metrics["total_trades"] == 3
|
||||
assert metrics["total_return_pct"] == pytest.approx(0.6, abs=0.01)
|
||||
assert metrics["win_rate"] == pytest.approx(66.67, abs=0.1)
|
||||
assert metrics["avg_pnl_usd"] == pytest.approx(20.0, abs=0.01)
|
||||
assert metrics["max_drawdown_pct"] >= 0.0
|
||||
assert isinstance(metrics["sharpe_ratio"], float)
|
||||
|
||||
|
||||
from hydra.backtest.runner import BacktestRunner
|
||||
from hydra.data.models import Candle
|
||||
from hydra.indicator.calculator import IndicatorCalculator
|
||||
from hydra.regime.detector import RegimeDetector
|
||||
from hydra.strategy.signal import SignalGenerator
|
||||
|
||||
|
||||
def _make_candles(n: int, base_close: float = 100.0) -> list[Candle]:
|
||||
"""Generate n synthetic candles with monotonically increasing timestamps."""
|
||||
candles = []
|
||||
for i in range(n):
|
||||
close = base_close + i * 0.1
|
||||
candles.append(Candle(
|
||||
market="binance", symbol="BTC/USDT", timeframe="1h",
|
||||
open_time=1_000_000 + i * 3_600_000,
|
||||
open=close, high=close + 1, low=close - 1,
|
||||
close=close, volume=100.0,
|
||||
close_time=1_000_000 + i * 3_600_000 + 3_599_000,
|
||||
))
|
||||
return candles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_returns_backtest_result():
|
||||
candles = _make_candles(230)
|
||||
calculator = MagicMock(spec=IndicatorCalculator)
|
||||
calculator.compute = MagicMock(return_value={
|
||||
"EMA_9": 101.0, "EMA_20": 100.0, "RSI_14": 55.0,
|
||||
"BBB_5_2.0_2.0": 0.05, "ADX_14": 30.0, "EMA_50": 99.0,
|
||||
})
|
||||
|
||||
runner = BacktestRunner(
|
||||
store=MagicMock(query=AsyncMock(return_value=candles)),
|
||||
calculator=calculator,
|
||||
detector=RegimeDetector(),
|
||||
generator=SignalGenerator(),
|
||||
initial_capital=10000.0,
|
||||
trade_amount_usd=100.0,
|
||||
)
|
||||
since = candles[210].open_time
|
||||
until = candles[-1].open_time
|
||||
result = await runner.run("binance", "BTC/USDT", "1h", since=since, until=until)
|
||||
|
||||
from hydra.backtest.result import BacktestResult
|
||||
assert isinstance(result, BacktestResult)
|
||||
assert result.market == "binance"
|
||||
assert result.symbol == "BTC/USDT"
|
||||
assert result.initial_capital == 10000.0
|
||||
assert "total_return_pct" in result.metrics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_insufficient_data_returns_empty_result():
|
||||
candles = _make_candles(50) # less than 210 warmup
|
||||
runner = BacktestRunner(
|
||||
store=MagicMock(query=AsyncMock(return_value=candles)),
|
||||
calculator=IndicatorCalculator(),
|
||||
detector=RegimeDetector(),
|
||||
generator=SignalGenerator(),
|
||||
)
|
||||
since = candles[0].open_time
|
||||
until = candles[-1].open_time
|
||||
result = await runner.run("binance", "BTC/USDT", "1h", since=since, until=until)
|
||||
assert result.metrics["total_trades"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_validates_since_until():
|
||||
runner = BacktestRunner(
|
||||
store=MagicMock(),
|
||||
calculator=IndicatorCalculator(),
|
||||
detector=RegimeDetector(),
|
||||
generator=SignalGenerator(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="since"):
|
||||
await runner.run("binance", "BTC/USDT", "1h", since=2000, until=1000)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_applies_commission():
|
||||
candles = _make_candles(230)
|
||||
call_count = 0
|
||||
base_indicators = {
|
||||
"EMA_9": 101.0, "EMA_20": 100.0,
|
||||
"BBB_5_2.0_2.0": 0.05, "ADX_14": 30.0, "EMA_50": 99.0,
|
||||
}
|
||||
|
||||
def mock_compute(candles_window):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count % 2 == 1:
|
||||
return {**base_indicators, "RSI_14": 55.0} # trending_up BUY
|
||||
else:
|
||||
return {**base_indicators, "RSI_14": 25.0,
|
||||
"EMA_9": 99.0, "EMA_20": 100.0} # trending_down SELL
|
||||
|
||||
calculator = MagicMock(spec=IndicatorCalculator)
|
||||
calculator.compute = MagicMock(side_effect=mock_compute)
|
||||
|
||||
runner = BacktestRunner(
|
||||
store=MagicMock(query=AsyncMock(return_value=candles)),
|
||||
calculator=calculator,
|
||||
detector=RegimeDetector(),
|
||||
generator=SignalGenerator(),
|
||||
initial_capital=10000.0,
|
||||
trade_amount_usd=100.0,
|
||||
commission_pct=0.001,
|
||||
)
|
||||
since = candles[210].open_time
|
||||
until = candles[-1].open_time
|
||||
result = await runner.run("binance", "BTC/USDT", "1h", since=since, until=until)
|
||||
# With commission, final_equity should differ from initial if there were trades
|
||||
assert isinstance(result.final_equity, float)
|
||||
assert result.metrics["total_trades"] >= 0
|
||||
@@ -0,0 +1,26 @@
|
||||
import pytest
|
||||
from pybreaker import CircuitBreakerError
|
||||
from hydra.resilience.circuit_breaker import create_breaker
|
||||
|
||||
|
||||
def failing_fn():
|
||||
raise ConnectionError("exchange down")
|
||||
|
||||
|
||||
def test_breaker_open_after_failures():
|
||||
breaker = create_breaker("test", fail_max=3, reset_timeout=60)
|
||||
for _ in range(2):
|
||||
with pytest.raises(ConnectionError):
|
||||
breaker.call(failing_fn)
|
||||
# 3번째 실패에서 circuit이 open된다
|
||||
with pytest.raises(CircuitBreakerError):
|
||||
breaker.call(failing_fn)
|
||||
# circuit이 open되었으므로 이후 호출도 CircuitBreakerError
|
||||
with pytest.raises(CircuitBreakerError):
|
||||
breaker.call(failing_fn)
|
||||
|
||||
|
||||
def test_breaker_passes_success():
|
||||
breaker = create_breaker("test2", fail_max=5, reset_timeout=60)
|
||||
result = breaker.call(lambda: "ok")
|
||||
assert result == "ok"
|
||||
@@ -0,0 +1,27 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def _run_cli_help(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "cp949"
|
||||
env["PYTHONUTF8"] = "0"
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "hydra.cli.app", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def test_root_help_renders_without_unicode_error():
|
||||
result = _run_cli_help("--help")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Usage:" in result.stdout
|
||||
|
||||
|
||||
def test_kill_help_renders_without_unicode_error():
|
||||
result = _run_cli_help("kill", "--help")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "Usage:" in result.stdout
|
||||
@@ -0,0 +1,41 @@
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, AsyncMock
|
||||
from hydra.resilience.graceful import GracefulManager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_l1_graceful_shutdown_saves_state():
|
||||
"""프로세스 종료 전 상태 저장 확인 (systemd Restart=always가 L1 보장)."""
|
||||
order_queue = MagicMock()
|
||||
order_queue.block_new_orders = MagicMock()
|
||||
position_tracker = AsyncMock()
|
||||
position_tracker.snapshot.return_value = {"positions": [{"symbol": "005930"}]}
|
||||
redis_client = MagicMock()
|
||||
|
||||
manager = GracefulManager(order_queue, position_tracker, redis_client)
|
||||
await manager.shutdown("SIGTERM")
|
||||
|
||||
order_queue.block_new_orders.assert_called_once()
|
||||
redis_client.set.assert_called_once()
|
||||
args = redis_client.set.call_args[0]
|
||||
assert "last_snapshot" in args[0]
|
||||
|
||||
|
||||
def test_l2_watchdog_health_url_configurable():
|
||||
"""Lambda 워치독 환경변수 설정 확인."""
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
os.environ["HYDRA_HEALTH_URL"] = "http://192.168.1.100:8000/health"
|
||||
|
||||
# boto3/requests가 없을 경우 mock 처리
|
||||
mock_boto3 = MagicMock()
|
||||
mock_requests = MagicMock()
|
||||
sys.modules.setdefault("boto3", mock_boto3)
|
||||
sys.modules.setdefault("requests", mock_requests)
|
||||
|
||||
import scripts.dr_watchdog as wd
|
||||
importlib.reload(wd)
|
||||
assert "192.168.1.100" in wd.HEALTH_URL
|
||||
del os.environ["HYDRA_HEALTH_URL"]
|
||||
@@ -0,0 +1,19 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from hydra.resilience.graceful import GracefulManager
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_saves_state():
|
||||
order_queue = MagicMock()
|
||||
order_queue.block_new_orders = MagicMock()
|
||||
position_tracker = AsyncMock()
|
||||
position_tracker.snapshot.return_value = {"positions": []}
|
||||
redis_client = MagicMock()
|
||||
|
||||
manager = GracefulManager(order_queue, position_tracker, redis_client)
|
||||
await manager.shutdown("SIGTERM")
|
||||
|
||||
order_queue.block_new_orders.assert_called_once()
|
||||
position_tracker.snapshot.assert_called_once()
|
||||
@@ -0,0 +1,57 @@
|
||||
import math
|
||||
import pytest
|
||||
from hydra.data.models import Candle
|
||||
from hydra.indicator.calculator import IndicatorCalculator
|
||||
|
||||
|
||||
def _make_candles(n: int) -> list[Candle]:
|
||||
candles = []
|
||||
price = 50000.0
|
||||
for i in range(n):
|
||||
open_ = price
|
||||
high = price * 1.001
|
||||
low = price * 0.999
|
||||
close = price * (1 + (0.001 if i % 2 == 0 else -0.001))
|
||||
price = close
|
||||
candles.append(Candle(
|
||||
market="binance", symbol="BTC/USDT", timeframe="1m",
|
||||
open_time=1_000_000 + i * 60_000,
|
||||
open=open_, high=high, low=low, close=close,
|
||||
volume=100.0 + i,
|
||||
close_time=1_000_000 + i * 60_000 + 59_999,
|
||||
))
|
||||
return candles
|
||||
|
||||
|
||||
def test_compute_returns_dict_with_rsi():
|
||||
calc = IndicatorCalculator()
|
||||
candles = _make_candles(250)
|
||||
result = calc.compute(candles)
|
||||
assert isinstance(result, dict)
|
||||
assert "RSI_14" in result
|
||||
assert isinstance(result["RSI_14"], float), "RSI_14 must be a valid float with 250 candles"
|
||||
|
||||
|
||||
def test_compute_no_nan_values():
|
||||
calc = IndicatorCalculator()
|
||||
candles = _make_candles(250)
|
||||
result = calc.compute(candles)
|
||||
for key, val in result.items():
|
||||
if val is not None:
|
||||
assert not (isinstance(val, float) and math.isnan(val)), \
|
||||
f"NaN found for key {key}"
|
||||
|
||||
|
||||
def test_compute_returns_empty_for_insufficient_candles():
|
||||
calc = IndicatorCalculator()
|
||||
candles = _make_candles(100) # fewer than 210
|
||||
result = calc.compute(candles)
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_compute_includes_calculated_at():
|
||||
calc = IndicatorCalculator()
|
||||
candles = _make_candles(250)
|
||||
result = calc.compute(candles)
|
||||
assert "calculated_at" in result
|
||||
assert isinstance(result["calculated_at"], int)
|
||||
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from hydra.data.models import Candle
|
||||
from hydra.indicator.engine import IndicatorEngine
|
||||
from hydra.indicator.calculator import IndicatorCalculator
|
||||
|
||||
|
||||
def _make_candles(n: int = 250) -> list[Candle]:
|
||||
candles = []
|
||||
price = 50000.0
|
||||
for i in range(n):
|
||||
close = price * (1 + (0.001 if i % 2 == 0 else -0.001))
|
||||
price = close
|
||||
candles.append(Candle(
|
||||
market="binance", symbol="BTC/USDT", timeframe="1m",
|
||||
open_time=1_000_000 + i * 60_000,
|
||||
open=price, high=price * 1.001, low=price * 0.999, close=close,
|
||||
volume=100.0, close_time=1_000_000 + i * 60_000 + 59_999,
|
||||
))
|
||||
return candles
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
store = AsyncMock()
|
||||
store.query = AsyncMock(return_value=_make_candles(250))
|
||||
store.get_symbols = AsyncMock(return_value=[
|
||||
{"market": "binance", "symbol": "BTC/USDT", "timeframe": "1m"}
|
||||
])
|
||||
return store
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
r = MagicMock()
|
||||
r.set = AsyncMock()
|
||||
r.keys = MagicMock(return_value=[])
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(mock_store, mock_redis):
|
||||
calc = IndicatorCalculator()
|
||||
return IndicatorEngine(store=mock_store, redis_client=mock_redis, calculator=calc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_writes_to_redis(engine, mock_redis):
|
||||
await engine._handle_event("binance", "BTC/USDT", "1m")
|
||||
mock_redis.set.assert_called_once()
|
||||
key, value = mock_redis.set.call_args[0]
|
||||
assert key == "hydra:indicator:binance:BTC/USDT:1m"
|
||||
data = json.loads(value)
|
||||
assert "RSI_14" in data
|
||||
assert "calculated_at" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_skips_empty_result(engine, mock_redis, mock_store):
|
||||
# Only 5 candles → calculator returns {}
|
||||
mock_store.query = AsyncMock(return_value=_make_candles(5))
|
||||
await engine._handle_event("binance", "BTC/USDT", "1m")
|
||||
mock_redis.set.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_does_not_raise_on_exception(engine, mock_store):
|
||||
mock_store.query = AsyncMock(side_effect=RuntimeError("db error"))
|
||||
# Should not raise — failures must be isolated
|
||||
await engine._handle_event("binance", "BTC/USDT", "1m")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cold_start_processes_all_symbols(engine, mock_redis, mock_store):
|
||||
mock_store.get_symbols = AsyncMock(return_value=[
|
||||
{"market": "binance", "symbol": "BTC/USDT", "timeframe": "1m"},
|
||||
{"market": "upbit", "symbol": "BTC/KRW", "timeframe": "1h"},
|
||||
])
|
||||
await engine.cold_start()
|
||||
assert mock_redis.set.call_count == 2
|
||||
@@ -0,0 +1,34 @@
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from hydra.config.keys import KeyManager
|
||||
|
||||
|
||||
def test_gitignore_contains_env():
|
||||
content = Path(".gitignore").read_text()
|
||||
assert ".env" in content
|
||||
assert "config/keys/" in content
|
||||
assert "*.key" in content
|
||||
|
||||
|
||||
def test_key_roundtrip(tmp_path):
|
||||
km = KeyManager(master_key_path=str(tmp_path / "master.key"))
|
||||
km.store("binance", "my_api_key", "my_secret")
|
||||
api_key, secret = km.load("binance")
|
||||
assert api_key == "my_api_key"
|
||||
assert secret == "my_secret"
|
||||
|
||||
|
||||
def test_stored_file_is_not_plaintext(tmp_path):
|
||||
km = KeyManager(master_key_path=str(tmp_path / "master.key"))
|
||||
km.store("upbit", "plain_key", "plain_secret")
|
||||
key_file = tmp_path / "upbit.enc"
|
||||
assert key_file.exists()
|
||||
raw = key_file.read_bytes()
|
||||
assert b"plain_key" not in raw
|
||||
assert b"plain_secret" not in raw
|
||||
|
||||
|
||||
def test_withdrawal_check_no_permission(tmp_path):
|
||||
km = KeyManager(master_key_path=str(tmp_path / "master.key"))
|
||||
assert km.check_withdrawal_permission("binance") is False
|
||||
@@ -0,0 +1,61 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, call
|
||||
from hydra.core.kill_switch import KillSwitch, KillSwitchResult
|
||||
|
||||
KILL_BLOCKED_KEY = "hydra:kill_switch_active"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ks(mock_redis, mock_exchange):
|
||||
telegram = AsyncMock()
|
||||
telegram.send_message = AsyncMock()
|
||||
positions = MagicMock()
|
||||
positions.get_all.return_value = [
|
||||
{"market": "kr", "symbol": "005930", "qty": 10, "side": "buy"}
|
||||
]
|
||||
return KillSwitch(
|
||||
exchanges={"kr": mock_exchange},
|
||||
position_tracker=positions,
|
||||
telegram=telegram,
|
||||
redis_client=mock_redis,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kill_via_cli(ks, mock_exchange):
|
||||
result = await ks.execute(reason="test", source="cli")
|
||||
assert result.success
|
||||
mock_exchange.cancel_all.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kill_via_api(ks, mock_exchange):
|
||||
result = await ks.execute(reason="manual", source="api")
|
||||
assert result.success
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kill_blocks_new_orders(ks, mock_redis):
|
||||
await ks.execute(reason="test", source="cli")
|
||||
mock_redis.set.assert_any_call(KILL_BLOCKED_KEY, "1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kill_sends_telegram(ks):
|
||||
await ks.execute(reason="test", source="cli")
|
||||
ks._telegram.send_message.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_trigger_daily_loss(ks, mock_redis):
|
||||
mock_redis.get.return_value = "-0.06"
|
||||
triggered, reason = await ks.check_auto_triggers()
|
||||
assert triggered
|
||||
assert "daily_loss" in reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_trigger_no_trigger(ks, mock_redis):
|
||||
mock_redis.get.return_value = "-0.01"
|
||||
triggered, _ = await ks.check_auto_triggers()
|
||||
assert not triggered
|
||||
@@ -0,0 +1,29 @@
|
||||
import pytest
|
||||
from hydra.config.profiles import get_profile
|
||||
|
||||
|
||||
def test_lite_profile_has_memory_limits():
|
||||
profile = get_profile("lite")
|
||||
assert profile.core_mem_gb <= 4
|
||||
assert profile.redis_mem_gb <= 2
|
||||
|
||||
|
||||
def test_expert_profile_higher_than_lite():
|
||||
lite = get_profile("lite")
|
||||
expert = get_profile("expert")
|
||||
assert expert.core_mem_gb > lite.core_mem_gb
|
||||
|
||||
|
||||
def test_invalid_profile_raises():
|
||||
with pytest.raises(ValueError, match="Unknown profile"):
|
||||
get_profile("invalid")
|
||||
|
||||
|
||||
def test_lite_no_ai():
|
||||
profile = get_profile("lite")
|
||||
assert profile.ai_enabled is False
|
||||
|
||||
|
||||
def test_pro_expert_have_ai():
|
||||
for name in ("pro", "expert"):
|
||||
assert get_profile(name).ai_enabled is True
|
||||
@@ -0,0 +1,49 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from hydra.core.order_queue import OrderQueue, OrderRequest, OrderLockError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def queue(mock_redis, mock_exchange):
|
||||
risk = MagicMock()
|
||||
risk.check_order_allowed.return_value = (True, "ok")
|
||||
positions = MagicMock()
|
||||
exchanges = {"kr": mock_exchange}
|
||||
mock_exchange.create_order = AsyncMock(return_value={"order_id": "ORD123", "status": "filled"})
|
||||
return OrderQueue(redis_client=mock_redis, risk_engine=risk, position_tracker=positions, exchanges=exchanges)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_order_submitted_successfully(queue, mock_redis):
|
||||
mock_redis.set.return_value = True # lock acquired
|
||||
mock_redis.get.return_value = None # no idempotency hit
|
||||
order = OrderRequest(market="kr", symbol="005930", side="buy", qty=10)
|
||||
result = await queue.submit(order)
|
||||
assert result.order_id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_lock_raises(queue, mock_redis):
|
||||
mock_redis.set.return_value = False # lock already held
|
||||
mock_redis.get.return_value = None
|
||||
order = OrderRequest(market="kr", symbol="005930", side="buy", qty=10)
|
||||
with pytest.raises(OrderLockError):
|
||||
await queue.submit(order)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotency_returns_cached(queue, mock_redis):
|
||||
import json
|
||||
cached = json.dumps({"order_id": "CACHED_ID", "status": "filled"})
|
||||
mock_redis.get.return_value = cached
|
||||
order = OrderRequest(market="kr", symbol="005930", side="buy", qty=10, idempotency_key="fixed-key")
|
||||
result = await queue.submit(order)
|
||||
assert result.order_id == "CACHED_ID"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_when_kill_switch_active(queue, mock_redis):
|
||||
mock_redis.get.side_effect = lambda k: "1" if "kill_switch" in k else None
|
||||
order = OrderRequest(market="kr", symbol="005930", side="buy", qty=10)
|
||||
with pytest.raises(OrderLockError, match="Kill Switch"):
|
||||
await queue.submit(order)
|
||||
@@ -0,0 +1,27 @@
|
||||
# tests/test_regime_detector.py
|
||||
from hydra.regime.detector import RegimeDetector
|
||||
|
||||
def test_volatile_regime():
|
||||
det = RegimeDetector()
|
||||
indicators = {"BBB_5_2.0_2.0": 0.10, "ADX_14": 30.0, "EMA_50": 45000.0}
|
||||
assert det.detect(indicators, close=50000.0) == "volatile"
|
||||
|
||||
def test_trending_up():
|
||||
det = RegimeDetector()
|
||||
indicators = {"BBB_5_2.0_2.0": 0.02, "ADX_14": 30.0, "EMA_50": 45000.0}
|
||||
assert det.detect(indicators, close=50000.0) == "trending_up"
|
||||
|
||||
def test_trending_down():
|
||||
det = RegimeDetector()
|
||||
indicators = {"BBB_5_2.0_2.0": 0.02, "ADX_14": 30.0, "EMA_50": 55000.0}
|
||||
assert det.detect(indicators, close=50000.0) == "trending_down"
|
||||
|
||||
def test_ranging():
|
||||
det = RegimeDetector()
|
||||
indicators = {"BBB_5_2.0_2.0": 0.02, "ADX_14": 15.0, "EMA_50": 45000.0}
|
||||
assert det.detect(indicators, close=50000.0) == "ranging"
|
||||
|
||||
def test_none_indicators_return_ranging():
|
||||
det = RegimeDetector()
|
||||
indicators = {"BBB_5_2.0_2.0": None, "ADX_14": None, "EMA_50": None}
|
||||
assert det.detect(indicators, close=50000.0) == "ranging"
|
||||
@@ -0,0 +1,59 @@
|
||||
# tests/test_regime_engine.py
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from hydra.regime.engine import RegimeEngine
|
||||
from hydra.regime.detector import RegimeDetector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
r = MagicMock()
|
||||
r.get = MagicMock(return_value=json.dumps({
|
||||
"BBB_5_2.0_2.0": 0.02, "ADX_14": 30.0, "EMA_50": 45000.0,
|
||||
"close": 50000.0,
|
||||
"calculated_at": 1000000
|
||||
}))
|
||||
r.set = AsyncMock()
|
||||
r.keys = MagicMock(return_value=["hydra:indicator:binance:BTC/USDT:1m"])
|
||||
r.pubsub = MagicMock()
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(mock_redis):
|
||||
return RegimeEngine(redis_client=mock_redis, detector=RegimeDetector())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_writes_regime(engine, mock_redis):
|
||||
await engine._handle_event("binance", "BTC/USDT", "1m")
|
||||
mock_redis.set.assert_called_once()
|
||||
key, value = mock_redis.set.call_args[0]
|
||||
assert key == "hydra:regime:binance:BTC/USDT:1m"
|
||||
data = json.loads(value)
|
||||
assert data["regime"] == "trending_up"
|
||||
assert "detected_at" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_skips_when_no_indicator(engine, mock_redis):
|
||||
mock_redis.get = MagicMock(return_value=None)
|
||||
await engine._handle_event("binance", "BTC/USDT", "1m")
|
||||
mock_redis.set.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_does_not_raise(engine, mock_redis):
|
||||
mock_redis.get = MagicMock(side_effect=RuntimeError("redis error"))
|
||||
await engine._handle_event("binance", "BTC/USDT", "1m")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cold_start_processes_all_keys(engine, mock_redis):
|
||||
mock_redis.keys = MagicMock(return_value=[
|
||||
"hydra:indicator:binance:BTC/USDT:1m",
|
||||
"hydra:indicator:upbit:BTC/KRW:1h",
|
||||
])
|
||||
await engine.cold_start()
|
||||
assert mock_redis.set.call_count == 2
|
||||
@@ -0,0 +1,85 @@
|
||||
import pytest
|
||||
from hydra.data.models import Candle
|
||||
from hydra.data.storage.sqlite import SqliteStore
|
||||
|
||||
|
||||
def test_store_exposes_close():
|
||||
assert hasattr(SqliteStore, "close")
|
||||
|
||||
|
||||
def make_candle(open_time: int, market="binance", symbol="BTC/USDT", tf="1m") -> Candle:
|
||||
return Candle(
|
||||
market=market, symbol=symbol, timeframe=tf,
|
||||
open_time=open_time, open=50000.0, high=50100.0,
|
||||
low=49900.0, close=50050.0, volume=1.5,
|
||||
close_time=open_time + 59999,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def store(tmp_path):
|
||||
s = SqliteStore(db_path=str(tmp_path / "test.db"))
|
||||
await s.init()
|
||||
try:
|
||||
yield s
|
||||
finally:
|
||||
await s.close()
|
||||
|
||||
|
||||
async def test_save_and_query(store):
|
||||
candles = [make_candle(1_000_000 + i * 60_000) for i in range(5)]
|
||||
await store.save(candles)
|
||||
result = await store.query("binance", "BTC/USDT", "1m", limit=10)
|
||||
assert len(result) == 5
|
||||
assert result[0].open_time == 1_000_000
|
||||
assert result[-1].open_time == 1_000_000 + 4 * 60_000
|
||||
|
||||
|
||||
async def test_upsert_updates_close(store):
|
||||
await store.save([make_candle(1_000_000)])
|
||||
updated = Candle(
|
||||
market="binance", symbol="BTC/USDT", timeframe="1m",
|
||||
open_time=1_000_000, open=50000.0, high=50200.0,
|
||||
low=49800.0, close=50150.0, volume=2.0,
|
||||
close_time=1_059_999,
|
||||
)
|
||||
await store.save([updated])
|
||||
result = await store.query("binance", "BTC/USDT", "1m")
|
||||
assert len(result) == 1
|
||||
assert result[0].close == 50150.0
|
||||
|
||||
|
||||
async def test_get_last_time_empty(store):
|
||||
assert await store.get_last_time("binance", "BTC/USDT", "1m") is None
|
||||
|
||||
|
||||
async def test_get_last_time(store):
|
||||
candles = [make_candle(1_000_000 + i * 60_000) for i in range(3)]
|
||||
await store.save(candles)
|
||||
result = await store.get_last_time("binance", "BTC/USDT", "1m")
|
||||
assert result == 1_000_000 + 2 * 60_000
|
||||
|
||||
|
||||
async def test_query_with_since(store):
|
||||
candles = [make_candle(1_000_000 + i * 60_000) for i in range(5)]
|
||||
await store.save(candles)
|
||||
result = await store.query("binance", "BTC/USDT", "1m", since=1_000_000 + 2 * 60_000)
|
||||
assert len(result) == 3
|
||||
assert result[0].open_time == 1_000_000 + 2 * 60_000
|
||||
|
||||
|
||||
async def test_get_symbols(store):
|
||||
await store.save([make_candle(1_000_000, market="binance", symbol="BTC/USDT")])
|
||||
await store.save([make_candle(1_000_000, market="upbit", symbol="BTC/KRW")])
|
||||
symbols = await store.get_symbols()
|
||||
assert len(symbols) == 2
|
||||
markets = {s["market"] for s in symbols}
|
||||
assert "binance" in markets
|
||||
assert "upbit" in markets
|
||||
|
||||
|
||||
async def test_close_is_idempotent(tmp_path):
|
||||
store = SqliteStore(db_path=str(tmp_path / "test-close.db"))
|
||||
await store.init()
|
||||
await store.close()
|
||||
await store.close()
|
||||
@@ -0,0 +1,70 @@
|
||||
# tests/test_strategy_engine.py
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from hydra.strategy.engine import StrategyEngine
|
||||
from hydra.strategy.signal import SignalGenerator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
r = MagicMock()
|
||||
r.get = MagicMock(side_effect=lambda key: (
|
||||
json.dumps({"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 55.0, "close": 50000.0})
|
||||
if ":indicator:" in key
|
||||
else json.dumps({"regime": "trending_up"})
|
||||
))
|
||||
r.set = AsyncMock()
|
||||
r.keys = MagicMock(return_value=["hydra:indicator:binance:BTC/USDT:1m"])
|
||||
r.pubsub = MagicMock()
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(mock_redis):
|
||||
return StrategyEngine(
|
||||
redis_client=mock_redis,
|
||||
generator=SignalGenerator(),
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_writes_signal(engine, mock_redis):
|
||||
await engine._handle_event("binance", "BTC/USDT", "1m")
|
||||
mock_redis.set.assert_called_once()
|
||||
key, value = mock_redis.set.call_args[0]
|
||||
assert key == "hydra:signal:binance:BTC/USDT:1m"
|
||||
data = json.loads(value)
|
||||
assert data["signal"] == "BUY"
|
||||
assert "reason" in data
|
||||
assert "price" in data
|
||||
assert "ts" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_skips_when_no_indicator(engine, mock_redis):
|
||||
mock_redis.get = MagicMock(return_value=None)
|
||||
await engine._handle_event("binance", "BTC/USDT", "1m")
|
||||
mock_redis.set.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_event_does_not_raise(engine, mock_redis):
|
||||
mock_redis.get = MagicMock(side_effect=RuntimeError("redis error"))
|
||||
await engine._handle_event("binance", "BTC/USDT", "1m")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cold_start_processes_all_keys(engine, mock_redis):
|
||||
mock_redis.keys = MagicMock(return_value=[
|
||||
"hydra:indicator:binance:BTC/USDT:1m",
|
||||
"hydra:indicator:upbit:BTC/KRW:1h",
|
||||
])
|
||||
mock_redis.get = MagicMock(side_effect=lambda key: (
|
||||
json.dumps({"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 55.0, "close": 50000.0})
|
||||
if ":indicator:" in key
|
||||
else json.dumps({"regime": "trending_up"})
|
||||
))
|
||||
await engine.cold_start()
|
||||
assert mock_redis.set.call_count == 2
|
||||
@@ -0,0 +1,63 @@
|
||||
# tests/test_strategy_signal.py
|
||||
from hydra.strategy.signal import SignalGenerator
|
||||
|
||||
|
||||
def test_volatile_returns_hold():
|
||||
gen = SignalGenerator()
|
||||
indicators = {"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 60.0}
|
||||
sig = gen.generate(indicators, "volatile", 50000.0)
|
||||
assert sig.signal == "HOLD"
|
||||
assert sig.reason == "volatile: skip"
|
||||
|
||||
|
||||
def test_trending_up_buy():
|
||||
gen = SignalGenerator()
|
||||
indicators = {"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 55.0}
|
||||
sig = gen.generate(indicators, "trending_up", 50000.0)
|
||||
assert sig.signal == "BUY"
|
||||
assert sig.reason == "trend_up: ema_cross+rsi"
|
||||
|
||||
|
||||
def test_trending_up_hold_no_cross():
|
||||
gen = SignalGenerator()
|
||||
indicators = {"EMA_9": 0.9, "EMA_20": 1.0, "RSI_14": 55.0}
|
||||
sig = gen.generate(indicators, "trending_up", 50000.0)
|
||||
assert sig.signal == "HOLD"
|
||||
|
||||
|
||||
def test_trending_down_sell():
|
||||
gen = SignalGenerator()
|
||||
indicators = {"EMA_9": 0.9, "EMA_20": 1.0, "RSI_14": 40.0}
|
||||
sig = gen.generate(indicators, "trending_down", 50000.0)
|
||||
assert sig.signal == "SELL"
|
||||
assert sig.reason == "trend_down: ema_cross+rsi"
|
||||
|
||||
|
||||
def test_trending_down_hold_no_cross():
|
||||
gen = SignalGenerator()
|
||||
indicators = {"EMA_9": 1.1, "EMA_20": 1.0, "RSI_14": 40.0}
|
||||
sig = gen.generate(indicators, "trending_down", 50000.0)
|
||||
assert sig.signal == "HOLD"
|
||||
|
||||
|
||||
def test_ranging_buy_oversold():
|
||||
gen = SignalGenerator()
|
||||
indicators = {"EMA_9": 1.0, "EMA_20": 1.0, "RSI_14": 25.0}
|
||||
sig = gen.generate(indicators, "ranging", 50000.0)
|
||||
assert sig.signal == "BUY"
|
||||
assert sig.reason == "ranging: rsi_oversold"
|
||||
|
||||
|
||||
def test_ranging_sell_overbought():
|
||||
gen = SignalGenerator()
|
||||
indicators = {"EMA_9": 1.0, "EMA_20": 1.0, "RSI_14": 75.0}
|
||||
sig = gen.generate(indicators, "ranging", 50000.0)
|
||||
assert sig.signal == "SELL"
|
||||
assert sig.reason == "ranging: rsi_overbought"
|
||||
|
||||
|
||||
def test_none_indicators_return_hold():
|
||||
gen = SignalGenerator()
|
||||
indicators = {"EMA_9": None, "EMA_20": None, "RSI_14": None}
|
||||
sig = gen.generate(indicators, "trending_up", 50000.0)
|
||||
assert sig.signal == "HOLD"
|
||||
@@ -0,0 +1,59 @@
|
||||
# tests/test_supplemental_events.py
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from hydra.supplemental.events import EventCalendarPoller
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_returns_empty_without_api_key():
|
||||
r = MagicMock()
|
||||
poller = EventCalendarPoller(redis_client=r, api_key="")
|
||||
result = await poller._fetch()
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_parses_response():
|
||||
r = MagicMock()
|
||||
poller = EventCalendarPoller(redis_client=r, api_key="test_key")
|
||||
mock_response = {
|
||||
"body": [
|
||||
{
|
||||
"title": "Bitcoin Halving",
|
||||
"coins": [{"symbol": "BTC"}],
|
||||
"date_event": "2024-04-20T00:00:00Z",
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = mock_response
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_http = AsyncMock()
|
||||
mock_http.get = AsyncMock(return_value=mock_resp)
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
result = await poller._fetch()
|
||||
assert len(result) == 1
|
||||
assert result[0]["title"] == "Bitcoin Halving"
|
||||
assert result[0]["symbol"] == "BTC"
|
||||
assert result[0]["source"] == "coinmarketcal"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_writes_redis():
|
||||
r = MagicMock()
|
||||
r.set = AsyncMock()
|
||||
poller = EventCalendarPoller(redis_client=r, api_key="")
|
||||
with patch("hydra.supplemental.events.asyncio.sleep",
|
||||
side_effect=asyncio.CancelledError):
|
||||
try:
|
||||
await poller.run()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
r.set.assert_called_once()
|
||||
key, value = r.set.call_args[0]
|
||||
assert key == "hydra:events:upcoming"
|
||||
assert json.loads(value) == []
|
||||
@@ -0,0 +1,80 @@
|
||||
# tests/test_supplemental_orderbook.py
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from hydra.supplemental.orderbook import OrderBookPoller
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
r = MagicMock()
|
||||
r.keys = MagicMock(return_value=[
|
||||
"hydra:indicator:binance:BTC/USDT:1m",
|
||||
"hydra:indicator:binance:ETH/USDT:1h",
|
||||
])
|
||||
r.set = AsyncMock()
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def poller(mock_redis):
|
||||
return OrderBookPoller(redis_client=mock_redis, interval_sec=1)
|
||||
|
||||
|
||||
def test_get_active_symbols_deduplicates(poller, mock_redis):
|
||||
mock_redis.keys = MagicMock(return_value=[
|
||||
"hydra:indicator:binance:BTC/USDT:1m",
|
||||
"hydra:indicator:binance:BTC/USDT:1h",
|
||||
"hydra:indicator:binance:ETH/USDT:1m",
|
||||
])
|
||||
symbols = poller._get_active_symbols()
|
||||
assert ("binance", "BTC/USDT") in symbols
|
||||
assert ("binance", "ETH/USDT") in symbols
|
||||
assert len(symbols) == 2
|
||||
|
||||
|
||||
def test_fetch_one_returns_orderbook(poller):
|
||||
mock_ob = {
|
||||
"bids": [[49998.0, 1.5], [49997.0, 2.0]],
|
||||
"asks": [[50002.0, 1.2], [50003.0, 1.8]],
|
||||
}
|
||||
mock_exchange = MagicMock()
|
||||
mock_exchange.fetch_order_book.return_value = mock_ob
|
||||
poller._get_exchange = MagicMock(return_value=mock_exchange)
|
||||
result = poller._fetch_one("binance", "BTC/USDT")
|
||||
assert result is not None
|
||||
assert result["bid"] == 49998.0
|
||||
assert result["ask"] == 50002.0
|
||||
assert "spread_pct" in result
|
||||
assert "ts" in result
|
||||
|
||||
|
||||
def test_fetch_one_returns_none_on_error(poller):
|
||||
mock_exchange = MagicMock()
|
||||
mock_exchange.fetch_order_book.side_effect = Exception("connection error")
|
||||
poller._get_exchange = MagicMock(return_value=mock_exchange)
|
||||
result = poller._fetch_one("binance", "BTC/USDT")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_writes_redis(poller, mock_redis):
|
||||
mock_ob = {
|
||||
"bids": [[49998.0, 1.5]],
|
||||
"asks": [[50002.0, 1.2]],
|
||||
}
|
||||
mock_exchange = MagicMock()
|
||||
mock_exchange.fetch_order_book.return_value = mock_ob
|
||||
poller._get_exchange = MagicMock(return_value=mock_exchange)
|
||||
with patch("hydra.supplemental.orderbook.asyncio.sleep",
|
||||
side_effect=asyncio.CancelledError):
|
||||
try:
|
||||
await poller.run()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
assert mock_redis.set.call_count == 2 # BTC/USDT and ETH/USDT
|
||||
key = mock_redis.set.call_args_list[0][0][0]
|
||||
assert key.startswith("hydra:orderbook:binance:")
|
||||
data = json.loads(mock_redis.set.call_args_list[0][0][1])
|
||||
assert "bid" in data
|
||||
@@ -0,0 +1,70 @@
|
||||
# tests/test_supplemental_sentiment.py
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from hydra.supplemental.sentiment import SentimentPoller
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis():
|
||||
r = MagicMock()
|
||||
r.keys = MagicMock(return_value=["hydra:indicator:binance:BTC/USDT:1m"])
|
||||
r.set = AsyncMock()
|
||||
return r
|
||||
|
||||
|
||||
def test_score_positive_headline():
|
||||
r = MagicMock()
|
||||
poller = SentimentPoller(redis_client=r)
|
||||
score = poller._score(["Bitcoin is great and prices are rising strongly today!"])
|
||||
assert score > 0.0
|
||||
|
||||
|
||||
def test_score_empty_headlines_returns_zero():
|
||||
r = MagicMock()
|
||||
poller = SentimentPoller(redis_client=r)
|
||||
score = poller._score([])
|
||||
assert score == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_news_returns_headlines():
|
||||
r = MagicMock()
|
||||
poller = SentimentPoller(redis_client=r, api_key="test_key")
|
||||
mock_response = {
|
||||
"results": [
|
||||
{"title": "Bitcoin price rises strongly"},
|
||||
{"title": "Market looks very bullish today"},
|
||||
]
|
||||
}
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = mock_response
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_http = AsyncMock()
|
||||
mock_http.get = AsyncMock(return_value=mock_resp)
|
||||
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_http)
|
||||
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||
headlines = await poller._fetch_news("BTC")
|
||||
assert len(headlines) == 2
|
||||
assert "Bitcoin price rises strongly" in headlines
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_writes_redis(mock_redis):
|
||||
poller = SentimentPoller(redis_client=mock_redis)
|
||||
poller._fetch_news = AsyncMock(return_value=["Bitcoin is surging higher today!"])
|
||||
with patch("hydra.supplemental.sentiment.asyncio.sleep",
|
||||
side_effect=asyncio.CancelledError):
|
||||
try:
|
||||
await poller.run()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
assert mock_redis.set.call_count >= 1
|
||||
key, value = mock_redis.set.call_args_list[0][0]
|
||||
assert key == "hydra:sentiment:BTC"
|
||||
data = json.loads(value)
|
||||
assert "score" in data
|
||||
assert "article_count" in data
|
||||
assert data["article_count"] == 1
|
||||
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
from hydra.config.validation import StrategyConfig, RiskConfig
|
||||
|
||||
|
||||
def test_stop_loss_too_high():
|
||||
with pytest.raises(ValueError, match="너무 큽니다"):
|
||||
StrategyConfig(stop_loss_pct=0.99)
|
||||
|
||||
|
||||
def test_stop_loss_zero():
|
||||
with pytest.raises(ValueError):
|
||||
StrategyConfig(stop_loss_pct=0.0)
|
||||
|
||||
|
||||
def test_stop_loss_valid():
|
||||
cfg = StrategyConfig(stop_loss_pct=0.05)
|
||||
assert cfg.stop_loss_pct == 0.05
|
||||
|
||||
|
||||
def test_position_size_too_high():
|
||||
with pytest.raises(ValueError, match="너무 큽니다"):
|
||||
StrategyConfig(position_size_pct=0.99)
|
||||
|
||||
|
||||
def test_position_size_valid():
|
||||
cfg = StrategyConfig(position_size_pct=0.10)
|
||||
assert cfg.position_size_pct == 0.10
|
||||
|
||||
|
||||
def test_risk_config_defaults():
|
||||
cfg = RiskConfig()
|
||||
assert cfg.daily_loss_limit_pct == 0.03
|
||||
assert cfg.daily_loss_kill_pct == 0.05
|
||||
@@ -0,0 +1,101 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from hydra.data.models import Candle
|
||||
from hydra.data.collector import ExchangeCollector
|
||||
|
||||
|
||||
def make_candle() -> Candle:
|
||||
return Candle(
|
||||
market="binance", symbol="BTC/USDT", timeframe="1m",
|
||||
open_time=1_000_000, open=50000.0, high=50100.0,
|
||||
low=49900.0, close=50050.0, volume=1.5,
|
||||
close_time=1_059_999,
|
||||
)
|
||||
|
||||
|
||||
async def _gen_one(candle: Candle):
|
||||
yield candle
|
||||
|
||||
|
||||
async def _gen_raise(exc: Exception):
|
||||
raise exc
|
||||
yield # makes this an async generator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
s = AsyncMock()
|
||||
s.save = AsyncMock()
|
||||
s.get_last_time = AsyncMock(return_value=None)
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_telegram():
|
||||
t = AsyncMock()
|
||||
t.send_message = AsyncMock()
|
||||
return t
|
||||
|
||||
|
||||
async def test_saves_candle_on_receive(mock_store, mock_telegram):
|
||||
"""Received candle is forwarded to store.save."""
|
||||
candle = make_candle()
|
||||
handler = MagicMock()
|
||||
handler.listen = MagicMock(return_value=_gen_one(candle))
|
||||
|
||||
collector = ExchangeCollector(
|
||||
market="binance", handler=handler, store=mock_store,
|
||||
backfiller=AsyncMock(), telegram=mock_telegram,
|
||||
)
|
||||
await collector._run_once()
|
||||
mock_store.save.assert_awaited_once_with([candle])
|
||||
|
||||
|
||||
async def test_telegram_alert_after_3_consecutive_failures(mock_store, mock_telegram):
|
||||
"""Three consecutive disconnects trigger a Telegram alert."""
|
||||
handler = MagicMock()
|
||||
handler.listen = MagicMock(
|
||||
side_effect=lambda: _gen_raise(ConnectionError("down"))
|
||||
)
|
||||
backfiller = AsyncMock()
|
||||
|
||||
collector = ExchangeCollector(
|
||||
market="binance", handler=handler, store=mock_store,
|
||||
backfiller=backfiller, telegram=mock_telegram,
|
||||
max_delay=0.001,
|
||||
)
|
||||
await collector._run_with_retry(max_attempts=3)
|
||||
|
||||
mock_telegram.send_message.assert_awaited_once()
|
||||
assert "binance" in mock_telegram.send_message.call_args[0][0]
|
||||
|
||||
|
||||
async def test_gap_backfill_called_on_reconnect(mock_store, mock_telegram):
|
||||
"""After a disconnect, gap_backfill is called on the next connection."""
|
||||
candle = make_candle()
|
||||
call_count = 0
|
||||
|
||||
async def listen_impl():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise ConnectionError("first attempt fails")
|
||||
yield candle
|
||||
|
||||
handler = MagicMock()
|
||||
handler.listen = MagicMock(side_effect=listen_impl)
|
||||
|
||||
backfiller = AsyncMock()
|
||||
backfiller.gap_backfill = AsyncMock()
|
||||
fetch_fn_factory = MagicMock(return_value=AsyncMock(return_value=[]))
|
||||
|
||||
collector = ExchangeCollector(
|
||||
market="binance", handler=handler, store=mock_store,
|
||||
backfiller=backfiller, telegram=mock_telegram,
|
||||
symbols=["BTC/USDT"], timeframes=["1m"],
|
||||
fetch_fn_factory=fetch_fn_factory,
|
||||
max_delay=0.001,
|
||||
)
|
||||
await collector._run_with_retry(max_attempts=2)
|
||||
|
||||
backfiller.gap_backfill.assert_awaited()
|
||||
Reference in New Issue
Block a user