Files
Vibe-Trading/agent/tests/test_crypto_engine.py
warren618 10a3027b7a test: add 236 unit tests + CI pytest integration
- 13 test files covering: market detection, loader registry/fallback,
  engine market rules (ChinaA T+1/涨跌停, Crypto funding/liquidation,
  US/HK commission), signal alignment, metrics, optimizer, provider mapping
- CI: fix branch master→main, add pytest step (e2e excluded)
- pyproject.toml: add pytest config + dev dependencies
- .gitignore: expose unit tests, keep e2e local-only
2026-04-10 14:36:11 +08:00

270 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for CryptoEngine market rules.
Validates:
- 24/7 execution (no direction/time restrictions)
- Fractional position sizing
- Maker/Taker fee separation
- Funding fee settlement (every 8 hours)
- Forced liquidation (maintenance margin check)
- Tiered maintenance margin rates
"""
from __future__ import annotations
import pandas as pd
import pytest
from backtest.engines.crypto import CryptoEngine, _FUNDING_HOURS, _TIER_TABLE
from backtest.models import Position
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_bar(close: float = 60000.0, open_: float | None = None) -> pd.Series:
return pd.Series({"close": close, "open": open_ or close})
def _make_engine(**overrides) -> CryptoEngine:
config = {
"initial_cash": 100_000,
"leverage": 10.0,
"maker_rate": 0.0002,
"taker_rate": 0.0005,
"funding_rate": 0.0001,
}
config.update(overrides)
return CryptoEngine(config)
# ---------------------------------------------------------------------------
# can_execute: no restrictions
# ---------------------------------------------------------------------------
class TestCanExecute:
def test_long_allowed(self) -> None:
engine = _make_engine()
assert engine.can_execute("BTC-USDT", 1, _make_bar()) is True
def test_short_allowed(self) -> None:
engine = _make_engine()
assert engine.can_execute("BTC-USDT", -1, _make_bar()) is True
def test_close_allowed(self) -> None:
engine = _make_engine()
assert engine.can_execute("BTC-USDT", 0, _make_bar()) is True
# ---------------------------------------------------------------------------
# round_size: fractional
# ---------------------------------------------------------------------------
class TestRoundSize:
def test_fractional_preserved(self) -> None:
engine = _make_engine()
assert engine.round_size(0.123456, 60000.0) == 0.123456
def test_six_decimal_precision(self) -> None:
engine = _make_engine()
assert engine.round_size(0.1234567890, 60000.0) == pytest.approx(0.123457, abs=1e-7)
def test_negative_clamps_to_zero(self) -> None:
engine = _make_engine()
assert engine.round_size(-0.5, 60000.0) == 0.0
# ---------------------------------------------------------------------------
# calc_commission: maker/taker
# ---------------------------------------------------------------------------
class TestCommission:
def test_open_uses_taker(self) -> None:
engine = _make_engine(taker_rate=0.0005, maker_rate=0.0002)
comm = engine.calc_commission(1.0, 60000.0, 1, is_open=True)
# 1 BTC × $60000 × 0.0005 = $30
assert comm == pytest.approx(30.0)
def test_close_uses_maker(self) -> None:
engine = _make_engine(taker_rate=0.0005, maker_rate=0.0002)
comm = engine.calc_commission(1.0, 60000.0, 1, is_open=False)
# 1 BTC × $60000 × 0.0002 = $12
assert comm == pytest.approx(12.0)
def test_taker_higher_than_maker(self) -> None:
engine = _make_engine()
open_comm = engine.calc_commission(1.0, 60000.0, 1, is_open=True)
close_comm = engine.calc_commission(1.0, 60000.0, 1, is_open=False)
assert open_comm > close_comm
# ---------------------------------------------------------------------------
# apply_slippage
# ---------------------------------------------------------------------------
class TestSlippage:
def test_long_slippage_increases_price(self) -> None:
engine = _make_engine(slippage=0.001)
assert engine.apply_slippage(60000.0, 1) == pytest.approx(60060.0)
def test_short_slippage_decreases_price(self) -> None:
engine = _make_engine(slippage=0.001)
assert engine.apply_slippage(60000.0, -1) == pytest.approx(59940.0)
# ---------------------------------------------------------------------------
# Funding fee
# ---------------------------------------------------------------------------
class TestFundingFee:
def test_funding_deducted_at_settlement_hour(self) -> None:
engine = _make_engine(funding_rate=0.0001)
engine.positions["BTC-USDT"] = Position(
"BTC-USDT", 1, 60000.0, pd.Timestamp("2025-01-01"), 1.0, leverage=10.0,
)
initial_capital = engine.capital
bar = _make_bar(close=60000.0)
ts = pd.Timestamp("2025-01-01 08:00:00") # settlement hour
engine._apply_funding_fee("BTC-USDT", bar, ts)
# Long pays: 1.0 × 60000 × 0.0001 × 1(long) = $6
assert engine.capital == pytest.approx(initial_capital - 6.0)
def test_no_funding_at_non_settlement_hour(self) -> None:
engine = _make_engine(funding_rate=0.0001)
engine.positions["BTC-USDT"] = Position(
"BTC-USDT", 1, 60000.0, pd.Timestamp("2025-01-01"), 1.0, leverage=10.0,
)
initial_capital = engine.capital
bar = _make_bar(close=60000.0)
ts = pd.Timestamp("2025-01-01 05:00:00") # not settlement hour
engine._apply_funding_fee("BTC-USDT", bar, ts)
assert engine.capital == initial_capital
def test_short_receives_funding(self) -> None:
engine = _make_engine(funding_rate=0.0001)
engine.positions["BTC-USDT"] = Position(
"BTC-USDT", -1, 60000.0, pd.Timestamp("2025-01-01"), 1.0, leverage=10.0,
)
initial_capital = engine.capital
bar = _make_bar(close=60000.0)
ts = pd.Timestamp("2025-01-01 08:00:00")
engine._apply_funding_fee("BTC-USDT", bar, ts)
# Short: direction=-1, fee = notional × rate × direction = negative → capital increases
assert engine.capital > initial_capital
def test_no_double_settlement(self) -> None:
engine = _make_engine(funding_rate=0.0001)
engine.positions["BTC-USDT"] = Position(
"BTC-USDT", 1, 60000.0, pd.Timestamp("2025-01-01"), 1.0, leverage=10.0,
)
bar = _make_bar(close=60000.0)
ts = pd.Timestamp("2025-01-01 08:00:00")
engine._apply_funding_fee("BTC-USDT", bar, ts)
capital_after_first = engine.capital
# Call again at same hour — should not deduct again
engine._apply_funding_fee("BTC-USDT", bar, ts)
assert engine.capital == capital_after_first
def test_no_funding_without_position(self) -> None:
engine = _make_engine()
initial_capital = engine.capital
bar = _make_bar()
ts = pd.Timestamp("2025-01-01 08:00:00")
engine._apply_funding_fee("BTC-USDT", bar, ts)
assert engine.capital == initial_capital
def test_funding_hours_correct(self) -> None:
assert _FUNDING_HOURS == {0, 8, 16}
# ---------------------------------------------------------------------------
# Liquidation
# ---------------------------------------------------------------------------
class TestLiquidation:
def test_liquidation_on_large_loss(self) -> None:
"""Position wiped when equity drops below maintenance margin."""
engine = _make_engine(leverage=10.0)
engine.positions["BTC-USDT"] = Position(
"BTC-USDT", 1, 60000.0, pd.Timestamp("2025-01-01"), 1.0, leverage=10.0,
)
# Margin = 1.0 × 60000 / 10 = $6000
# If price drops to 54500: unrealized = 1 × (54500 - 60000) = -$5500
# equity_in_pos = 6000 + (-5500) = $500
# Notional = 1 × 54500 = 54500, maint_rate(54500) = 0.004
# Maint margin = 54500 × 0.004 = $218
# $500 > $218 → no liquidation
# But if price drops to 54000:
# unrealized = -6000, equity = 0 → clearly liquidated
bar = _make_bar(close=54000.0)
ts = pd.Timestamp("2025-01-02")
engine._check_liquidation("BTC-USDT", bar, ts)
assert "BTC-USDT" not in engine.positions
assert len(engine.trades) == 1
assert engine.trades[0].exit_reason == "liquidation"
def test_no_liquidation_when_profitable(self) -> None:
engine = _make_engine(leverage=10.0)
engine.positions["BTC-USDT"] = Position(
"BTC-USDT", 1, 60000.0, pd.Timestamp("2025-01-01"), 1.0, leverage=10.0,
)
bar = _make_bar(close=65000.0)
ts = pd.Timestamp("2025-01-02")
engine._check_liquidation("BTC-USDT", bar, ts)
assert "BTC-USDT" in engine.positions
def test_no_liquidation_for_spot(self) -> None:
"""Spot (leverage=1) should never get liquidated."""
engine = _make_engine(leverage=1.0)
engine.positions["BTC-USDT"] = Position(
"BTC-USDT", 1, 60000.0, pd.Timestamp("2025-01-01"), 1.0, leverage=1.0,
)
bar = _make_bar(close=30000.0) # 50% drop
ts = pd.Timestamp("2025-01-02")
engine._check_liquidation("BTC-USDT", bar, ts)
assert "BTC-USDT" in engine.positions
def test_short_liquidation(self) -> None:
"""Short position liquidated when price rises sharply."""
engine = _make_engine(leverage=10.0)
engine.positions["BTC-USDT"] = Position(
"BTC-USDT", -1, 60000.0, pd.Timestamp("2025-01-01"), 1.0, leverage=10.0,
)
# Margin = $6000, unrealized = -1 × 1 × (66500 - 60000) = -$6500
# equity_in_pos = 6000 - 6500 = -$500 < 0 → liquidated
bar = _make_bar(close=66500.0)
ts = pd.Timestamp("2025-01-02")
engine._check_liquidation("BTC-USDT", bar, ts)
assert "BTC-USDT" not in engine.positions
# ---------------------------------------------------------------------------
# Tiered maintenance margin
# ---------------------------------------------------------------------------
class TestMaintenanceRate:
def test_small_position(self) -> None:
assert CryptoEngine._maintenance_rate(50_000) == 0.004
def test_medium_position(self) -> None:
assert CryptoEngine._maintenance_rate(300_000) == 0.006
def test_large_position(self) -> None:
assert CryptoEngine._maintenance_rate(2_000_000) == 0.02
def test_tier_boundaries(self) -> None:
assert CryptoEngine._maintenance_rate(100_000) == 0.004
assert CryptoEngine._maintenance_rate(100_001) == 0.006
def test_maximum_tier(self) -> None:
assert CryptoEngine._maintenance_rate(100_000_000) == 0.10