Initial public release

This commit is contained in:
sinmb79
2026-03-30 13:19:11 +09:00
commit 92a692b63c
116 changed files with 5822 additions and 0 deletions
View File
+20
View File
@@ -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
+64
View File
@@ -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
+56
View File
@@ -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)
+175
View File
@@ -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
+26
View File
@@ -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"
+27
View File
@@ -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
+41
View File
@@ -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"]
+19
View File
@@ -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()
+57
View File
@@ -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)
+81
View File
@@ -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
+34
View File
@@ -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
+61
View File
@@ -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
+29
View File
@@ -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
+49
View File
@@ -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)
+27
View File
@@ -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"
+59
View File
@@ -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
+85
View File
@@ -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()
+70
View File
@@ -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
+63
View File
@@ -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"
+59
View File
@@ -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) == []
+80
View File
@@ -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
+70
View File
@@ -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
+33
View File
@@ -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
+101
View File
@@ -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()