feat: autonomous trading engine — full implementation
- Database migration 018 with 13 tables for trading engine state - Trading engine service (services/trading/) with 12 pure computation modules: position sizer, stop-loss manager, reserve pool, circuit breaker, risk tier controller, correlation matrix, tax lots, trading window, gradual entry, notifications, micro-trading, backtester - Core TradingEngine with pre-trade evaluation pipeline and integration wiring - FastAPI HTTP service with 14 endpoints (health, config, decisions, metrics, backtest) - Performance tracker with Sharpe ratio, drawdown, profit factor computation - 194 Python tests (165 property-based + 29 integration) - Frontend: 13 TanStack Query hooks, 7 dashboard panels, tabbed Trading Engine page - Helm chart entry, network policy, nginx proxy, ingress for trading-engine - Shared infrastructure: enums, Redis keys, TradingConfig in AppConfig
This commit is contained in:
@@ -0,0 +1,728 @@
|
||||
"""Integration tests for the TradingEngine wiring and end-to-end decision flow.
|
||||
|
||||
Tests verify that the engine correctly delegates to sub-components and
|
||||
that the full recommendation → evaluation → sizing → decision pipeline
|
||||
works end-to-end with concrete values. No DB/Redis needed — all
|
||||
components are pure computation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from services.shared.config import TradingConfig
|
||||
from services.trading.circuit_breaker import CircuitBreaker
|
||||
from services.trading.correlation import CorrelationMatrix
|
||||
from services.trading.engine import TradingEngine
|
||||
from services.trading.models import (
|
||||
CircuitBreakerState,
|
||||
OpenPosition,
|
||||
PerformanceMetrics,
|
||||
PortfolioState,
|
||||
RiskTierConfig,
|
||||
StopLevels,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_engine(**overrides) -> TradingEngine:
|
||||
"""Create a TradingEngine with sensible test defaults."""
|
||||
config_kwargs = {
|
||||
"enabled": True,
|
||||
"micro_trading_enabled": False,
|
||||
}
|
||||
config_kwargs.update(overrides)
|
||||
config = TradingConfig(**config_kwargs)
|
||||
return TradingEngine(pool=None, redis=None, config=config)
|
||||
|
||||
|
||||
def _moderate_tier() -> RiskTierConfig:
|
||||
return RiskTierConfig(
|
||||
name="moderate",
|
||||
min_confidence=0.55,
|
||||
max_position_pct=0.10,
|
||||
stop_loss_atr_multiplier=2.0,
|
||||
reward_risk_ratio=1.5,
|
||||
max_sector_pct=0.30,
|
||||
max_portfolio_heat=0.20,
|
||||
)
|
||||
|
||||
|
||||
def _portfolio_state(**overrides) -> PortfolioState:
|
||||
defaults = {
|
||||
"positions": [],
|
||||
"total_value": 500.0,
|
||||
"cash": 400.0,
|
||||
"active_pool": 400.0,
|
||||
"reserve_pool": 100.0,
|
||||
"sector_exposure": {},
|
||||
"portfolio_heat": 0.0,
|
||||
"open_position_count": 0,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return PortfolioState(**defaults)
|
||||
|
||||
|
||||
def _inactive_cb() -> CircuitBreakerState:
|
||||
return CircuitBreakerState(active=False)
|
||||
|
||||
|
||||
def _within_trading_window() -> datetime:
|
||||
"""Return a datetime that is within the trading window (Wed 11:00 AM ET)."""
|
||||
from zoneinfo import ZoneInfo
|
||||
return datetime(2025, 1, 15, 11, 0, 0, tzinfo=ZoneInfo("America/New_York"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: Full cycle — recommendation → evaluation → position sizing → act
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFullDecisionCycle:
|
||||
"""Verify the complete recommendation-to-decision pipeline."""
|
||||
|
||||
def test_high_confidence_recommendation_produces_act_decision(self):
|
||||
engine = _make_engine(absolute_position_cap=100.0)
|
||||
tier = _moderate_tier()
|
||||
portfolio = _portfolio_state(active_pool=1000.0, total_value=1200.0)
|
||||
cb_state = _inactive_cb()
|
||||
corr = CorrelationMatrix()
|
||||
now = _within_trading_window()
|
||||
|
||||
rec = {
|
||||
"recommendation_id": "rec-001",
|
||||
"ticker": "AAPL",
|
||||
"confidence": 0.80,
|
||||
"sector": "Technology",
|
||||
"current_price": 25.0, # cheap enough to buy at least 1 share
|
||||
"action": "buy",
|
||||
}
|
||||
|
||||
decision = engine.evaluate_recommendation(
|
||||
rec=rec,
|
||||
portfolio_state=portfolio,
|
||||
risk_tier=tier,
|
||||
circuit_breaker_state=cb_state,
|
||||
correlation_matrix=corr,
|
||||
earnings_calendar={},
|
||||
now=now,
|
||||
)
|
||||
|
||||
assert decision.decision == "act"
|
||||
assert decision.ticker == "AAPL"
|
||||
assert decision.computed_position_size is not None
|
||||
assert decision.computed_position_size > 0
|
||||
assert decision.computed_share_quantity is not None
|
||||
assert decision.computed_share_quantity > 0
|
||||
assert decision.risk_tier_at_decision == "moderate"
|
||||
assert decision.circuit_breaker_status == "inactive"
|
||||
|
||||
def test_low_confidence_recommendation_produces_skip_decision(self):
|
||||
engine = _make_engine()
|
||||
tier = _moderate_tier()
|
||||
portfolio = _portfolio_state()
|
||||
cb_state = _inactive_cb()
|
||||
corr = CorrelationMatrix()
|
||||
now = _within_trading_window()
|
||||
|
||||
rec = {
|
||||
"recommendation_id": "rec-002",
|
||||
"ticker": "MSFT",
|
||||
"confidence": 0.30, # below moderate min_confidence of 0.55
|
||||
"sector": "Technology",
|
||||
"current_price": 400.0,
|
||||
"action": "buy",
|
||||
}
|
||||
|
||||
decision = engine.evaluate_recommendation(
|
||||
rec=rec,
|
||||
portfolio_state=portfolio,
|
||||
risk_tier=tier,
|
||||
circuit_breaker_state=cb_state,
|
||||
correlation_matrix=corr,
|
||||
earnings_calendar={},
|
||||
now=now,
|
||||
)
|
||||
|
||||
assert decision.decision == "skip"
|
||||
assert "insufficient_confidence" in decision.skip_reason
|
||||
|
||||
def test_duplicate_recommendation_is_skipped(self):
|
||||
engine = _make_engine(absolute_position_cap=100.0)
|
||||
tier = _moderate_tier()
|
||||
portfolio = _portfolio_state(active_pool=1000.0, total_value=1200.0)
|
||||
cb_state = _inactive_cb()
|
||||
corr = CorrelationMatrix()
|
||||
now = _within_trading_window()
|
||||
|
||||
rec = {
|
||||
"recommendation_id": "rec-dup",
|
||||
"ticker": "GOOG",
|
||||
"confidence": 0.80,
|
||||
"sector": "Technology",
|
||||
"current_price": 25.0,
|
||||
"action": "buy",
|
||||
}
|
||||
|
||||
# First evaluation should act
|
||||
d1 = engine.evaluate_recommendation(
|
||||
rec=rec,
|
||||
portfolio_state=portfolio,
|
||||
risk_tier=tier,
|
||||
circuit_breaker_state=cb_state,
|
||||
correlation_matrix=corr,
|
||||
earnings_calendar={},
|
||||
now=now,
|
||||
)
|
||||
assert d1.decision == "act"
|
||||
|
||||
# Second evaluation of same rec should skip as duplicate
|
||||
d2 = engine.evaluate_recommendation(
|
||||
rec=rec,
|
||||
portfolio_state=portfolio,
|
||||
risk_tier=tier,
|
||||
circuit_breaker_state=cb_state,
|
||||
correlation_matrix=corr,
|
||||
earnings_calendar={},
|
||||
now=now,
|
||||
)
|
||||
assert d2.decision == "skip"
|
||||
assert "duplicate" in d2.skip_reason
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: Stop-loss crossing → StopTrigger list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStopLossCrossings:
|
||||
"""Verify stop-loss crossing detection via engine wiring."""
|
||||
|
||||
def test_stop_loss_triggered(self):
|
||||
engine = _make_engine()
|
||||
|
||||
positions = [
|
||||
OpenPosition(
|
||||
ticker="AAPL",
|
||||
quantity=10,
|
||||
entry_price=150.0,
|
||||
current_price=140.0,
|
||||
unrealized_pnl=-100.0,
|
||||
market_value=1400.0,
|
||||
sector="Technology",
|
||||
stop_loss_price=145.0,
|
||||
take_profit_price=165.0,
|
||||
signal_confidence=0.75,
|
||||
),
|
||||
]
|
||||
prices = {"AAPL": 144.0} # below stop_loss_price of 145.0
|
||||
stop_levels = {
|
||||
"AAPL": StopLevels(
|
||||
stop_loss_price=145.0,
|
||||
take_profit_price=165.0,
|
||||
trailing_stop_active=False,
|
||||
atr_value=3.0,
|
||||
atr_multiplier=2.0,
|
||||
reward_risk_ratio=1.5,
|
||||
),
|
||||
}
|
||||
|
||||
triggers = engine.check_stop_loss_crossings(positions, prices, stop_levels)
|
||||
|
||||
assert len(triggers) == 1
|
||||
assert triggers[0].ticker == "AAPL"
|
||||
assert triggers[0].trigger_type == "stop_loss"
|
||||
assert triggers[0].current_price == 144.0
|
||||
|
||||
def test_take_profit_triggered(self):
|
||||
engine = _make_engine()
|
||||
|
||||
positions = [
|
||||
OpenPosition(
|
||||
ticker="MSFT",
|
||||
quantity=5,
|
||||
entry_price=400.0,
|
||||
current_price=420.0,
|
||||
unrealized_pnl=100.0,
|
||||
market_value=2100.0,
|
||||
sector="Technology",
|
||||
stop_loss_price=390.0,
|
||||
take_profit_price=415.0,
|
||||
signal_confidence=0.80,
|
||||
),
|
||||
]
|
||||
prices = {"MSFT": 416.0} # above take_profit_price of 415.0
|
||||
stop_levels = {
|
||||
"MSFT": StopLevels(
|
||||
stop_loss_price=390.0,
|
||||
take_profit_price=415.0,
|
||||
trailing_stop_active=False,
|
||||
atr_value=5.0,
|
||||
atr_multiplier=2.0,
|
||||
reward_risk_ratio=1.5,
|
||||
),
|
||||
}
|
||||
|
||||
triggers = engine.check_stop_loss_crossings(positions, prices, stop_levels)
|
||||
|
||||
assert len(triggers) == 1
|
||||
assert triggers[0].ticker == "MSFT"
|
||||
assert triggers[0].trigger_type == "take_profit"
|
||||
|
||||
def test_no_crossing_returns_empty(self):
|
||||
engine = _make_engine()
|
||||
|
||||
positions = [
|
||||
OpenPosition(
|
||||
ticker="GOOG",
|
||||
quantity=3,
|
||||
entry_price=170.0,
|
||||
current_price=172.0,
|
||||
unrealized_pnl=6.0,
|
||||
market_value=516.0,
|
||||
sector="Technology",
|
||||
stop_loss_price=165.0,
|
||||
take_profit_price=180.0,
|
||||
signal_confidence=0.70,
|
||||
),
|
||||
]
|
||||
prices = {"GOOG": 172.0} # between stop and take-profit
|
||||
stop_levels = {
|
||||
"GOOG": StopLevels(
|
||||
stop_loss_price=165.0,
|
||||
take_profit_price=180.0,
|
||||
trailing_stop_active=False,
|
||||
atr_value=3.0,
|
||||
atr_multiplier=2.0,
|
||||
reward_risk_ratio=1.5,
|
||||
),
|
||||
}
|
||||
|
||||
triggers = engine.check_stop_loss_crossings(positions, prices, stop_levels)
|
||||
assert len(triggers) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3: Reserve pool siphoning on profitable close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReservePoolSiphoning:
|
||||
"""Verify reserve pool siphoning via engine wiring."""
|
||||
|
||||
def test_profitable_close_siphons_20_percent(self):
|
||||
engine = _make_engine(reserve_siphon_pct=0.20)
|
||||
|
||||
transfer, new_balance = engine.handle_position_close(
|
||||
realized_profit=100.0,
|
||||
reserve_balance=50.0,
|
||||
)
|
||||
|
||||
assert transfer == pytest.approx(20.0) # 20% of $100
|
||||
assert new_balance == pytest.approx(70.0) # $50 + $20
|
||||
|
||||
def test_loss_does_not_siphon(self):
|
||||
engine = _make_engine(reserve_siphon_pct=0.20)
|
||||
|
||||
transfer, new_balance = engine.handle_position_close(
|
||||
realized_profit=-30.0,
|
||||
reserve_balance=50.0,
|
||||
)
|
||||
|
||||
assert transfer == 0.0
|
||||
assert new_balance == 50.0
|
||||
|
||||
def test_zero_profit_does_not_siphon(self):
|
||||
engine = _make_engine(reserve_siphon_pct=0.20)
|
||||
|
||||
transfer, new_balance = engine.handle_position_close(
|
||||
realized_profit=0.0,
|
||||
reserve_balance=50.0,
|
||||
)
|
||||
|
||||
assert transfer == 0.0
|
||||
assert new_balance == 50.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: Circuit breaker trigger → halt → cooldown → resume
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCircuitBreakerFlow:
|
||||
"""Verify circuit breaker integration with the decision loop."""
|
||||
|
||||
def test_active_circuit_breaker_skips_recommendation(self):
|
||||
engine = _make_engine()
|
||||
tier = _moderate_tier()
|
||||
portfolio = _portfolio_state()
|
||||
now = _within_trading_window()
|
||||
|
||||
# Circuit breaker active with future cooldown
|
||||
cb_state = CircuitBreakerState(
|
||||
active=True,
|
||||
trigger_type="daily_loss",
|
||||
triggered_at=now - timedelta(minutes=30),
|
||||
cooldown_expires=now + timedelta(hours=2),
|
||||
)
|
||||
|
||||
rec = {
|
||||
"recommendation_id": "rec-cb-1",
|
||||
"ticker": "AAPL",
|
||||
"confidence": 0.90,
|
||||
"sector": "Technology",
|
||||
"current_price": 150.0,
|
||||
"action": "buy",
|
||||
}
|
||||
|
||||
decision = engine.evaluate_recommendation(
|
||||
rec=rec,
|
||||
portfolio_state=portfolio,
|
||||
risk_tier=tier,
|
||||
circuit_breaker_state=cb_state,
|
||||
correlation_matrix=CorrelationMatrix(),
|
||||
earnings_calendar={},
|
||||
now=now,
|
||||
)
|
||||
|
||||
assert decision.decision == "skip"
|
||||
assert "circuit_breaker_active" in decision.skip_reason
|
||||
assert decision.circuit_breaker_status == "active"
|
||||
|
||||
def test_expired_circuit_breaker_allows_trading(self):
|
||||
engine = _make_engine(absolute_position_cap=100.0)
|
||||
tier = _moderate_tier()
|
||||
portfolio = _portfolio_state(active_pool=1000.0, total_value=1200.0)
|
||||
now = _within_trading_window()
|
||||
|
||||
# Circuit breaker was active but cooldown has expired
|
||||
cb_state = CircuitBreakerState(
|
||||
active=True,
|
||||
trigger_type="daily_loss",
|
||||
triggered_at=now - timedelta(hours=3),
|
||||
cooldown_expires=now - timedelta(hours=1), # expired
|
||||
)
|
||||
|
||||
rec = {
|
||||
"recommendation_id": "rec-cb-2",
|
||||
"ticker": "AAPL",
|
||||
"confidence": 0.80,
|
||||
"sector": "Technology",
|
||||
"current_price": 25.0,
|
||||
"action": "buy",
|
||||
}
|
||||
|
||||
decision = engine.evaluate_recommendation(
|
||||
rec=rec,
|
||||
portfolio_state=portfolio,
|
||||
risk_tier=tier,
|
||||
circuit_breaker_state=cb_state,
|
||||
correlation_matrix=CorrelationMatrix(),
|
||||
earnings_calendar={},
|
||||
now=now,
|
||||
)
|
||||
|
||||
# Should proceed since cooldown expired
|
||||
assert decision.decision == "act"
|
||||
|
||||
def test_circuit_breaker_daily_loss_detection(self):
|
||||
"""Verify the CircuitBreaker component detects daily loss threshold."""
|
||||
cb = CircuitBreaker(daily_loss_pct=0.05)
|
||||
|
||||
# 6% loss on $500 portfolio → should trigger
|
||||
assert cb.check_daily_loss(daily_pnl=-30.0, portfolio_value=500.0) is True
|
||||
|
||||
# 3% loss → should not trigger
|
||||
assert cb.check_daily_loss(daily_pnl=-15.0, portfolio_value=500.0) is False
|
||||
|
||||
def test_circuit_breaker_cooldown_computation(self):
|
||||
"""Verify cooldown expiry is computed correctly."""
|
||||
cb = CircuitBreaker(volatility_pause_hours=2, ticker_cooldown_hours=48)
|
||||
now = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
vol_expiry = cb.compute_cooldown_expiry("volatility", now)
|
||||
assert vol_expiry == now + timedelta(hours=2)
|
||||
|
||||
pos_expiry = cb.compute_cooldown_expiry("single_position", now)
|
||||
assert pos_expiry == now + timedelta(hours=48)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5: Engine startup state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEngineStartup:
|
||||
"""Verify engine startup state and lifecycle."""
|
||||
|
||||
def test_engine_starts_not_running(self):
|
||||
engine = _make_engine()
|
||||
assert engine.running is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_sets_running_flag(self):
|
||||
engine = _make_engine()
|
||||
await engine.start()
|
||||
assert engine.running is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_clears_running_flag(self):
|
||||
engine = _make_engine()
|
||||
await engine.start()
|
||||
assert engine.running is True
|
||||
await engine.stop()
|
||||
assert engine.running is False
|
||||
|
||||
def test_engine_initializes_all_sub_components(self):
|
||||
engine = _make_engine()
|
||||
assert engine.position_sizer is not None
|
||||
assert engine.stop_loss_manager is not None
|
||||
assert engine.circuit_breaker is not None
|
||||
assert engine.reserve_pool_controller is not None
|
||||
assert engine.risk_tier_controller is not None
|
||||
assert engine.correlation_matrix is not None
|
||||
assert engine.notification_service is not None
|
||||
assert engine.micro_trading_module is not None
|
||||
assert engine.rebalancer is not None
|
||||
|
||||
def test_engine_starts_with_empty_processed_ids(self):
|
||||
engine = _make_engine()
|
||||
assert len(engine.processed_recommendation_ids) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6: Risk tier evaluation wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRiskTierEvaluation:
|
||||
"""Verify risk tier evaluation via engine wiring."""
|
||||
|
||||
def test_downgrade_on_poor_performance(self):
|
||||
engine = _make_engine()
|
||||
metrics = PerformanceMetrics(
|
||||
total_portfolio_value=500.0,
|
||||
active_pool=400.0,
|
||||
reserve_pool=100.0,
|
||||
unrealized_pnl=-20.0,
|
||||
realized_pnl=-50.0,
|
||||
daily_pnl=-10.0,
|
||||
win_count=3,
|
||||
loss_count=7,
|
||||
win_rate=0.30, # below 40% → downgrade
|
||||
avg_win=10.0,
|
||||
avg_loss=-8.0,
|
||||
profit_factor=0.5,
|
||||
sharpe_ratio=-0.5,
|
||||
max_drawdown=0.20,
|
||||
current_drawdown_pct=0.18, # above 15% → also triggers downgrade
|
||||
portfolio_heat=0.10,
|
||||
)
|
||||
|
||||
new_tier = engine.evaluate_risk_tier("moderate", metrics, reserve_pct=0.20)
|
||||
assert new_tier == "conservative"
|
||||
|
||||
def test_upgrade_on_strong_performance(self):
|
||||
engine = _make_engine()
|
||||
metrics = PerformanceMetrics(
|
||||
total_portfolio_value=600.0,
|
||||
active_pool=400.0,
|
||||
reserve_pool=200.0,
|
||||
unrealized_pnl=30.0,
|
||||
realized_pnl=100.0,
|
||||
daily_pnl=5.0,
|
||||
win_count=7,
|
||||
loss_count=3,
|
||||
win_rate=0.70, # above 55%
|
||||
avg_win=15.0,
|
||||
avg_loss=-5.0,
|
||||
profit_factor=2.1,
|
||||
sharpe_ratio=1.5,
|
||||
max_drawdown=0.05,
|
||||
current_drawdown_pct=0.02, # below 5%
|
||||
portfolio_heat=0.08,
|
||||
)
|
||||
|
||||
# reserve_pct > 0.20 and win_rate > 0.55 and drawdown < 0.05
|
||||
new_tier = engine.evaluate_risk_tier("moderate", metrics, reserve_pct=0.33)
|
||||
assert new_tier == "aggressive"
|
||||
|
||||
def test_no_change_when_metrics_are_neutral(self):
|
||||
engine = _make_engine()
|
||||
metrics = PerformanceMetrics(
|
||||
total_portfolio_value=500.0,
|
||||
active_pool=400.0,
|
||||
reserve_pool=100.0,
|
||||
unrealized_pnl=5.0,
|
||||
realized_pnl=20.0,
|
||||
daily_pnl=2.0,
|
||||
win_count=5,
|
||||
loss_count=5,
|
||||
win_rate=0.50, # between 40% and 55%
|
||||
avg_win=10.0,
|
||||
avg_loss=-8.0,
|
||||
profit_factor=1.2,
|
||||
sharpe_ratio=0.5,
|
||||
max_drawdown=0.08,
|
||||
current_drawdown_pct=0.06,
|
||||
portfolio_heat=0.10,
|
||||
)
|
||||
|
||||
new_tier = engine.evaluate_risk_tier("moderate", metrics, reserve_pct=0.20)
|
||||
assert new_tier is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 7: Rebalancing wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRebalancingWiring:
|
||||
"""Verify portfolio rebalancing via engine wiring."""
|
||||
|
||||
def test_over_concentrated_position_generates_sell_order(self):
|
||||
engine = _make_engine()
|
||||
tier = _moderate_tier() # max_position_pct = 0.10
|
||||
|
||||
# market_value $200 exceeds 10% of $400 = $40 by $160
|
||||
# sell_qty = int(160 / 20) = 8 shares
|
||||
positions = [
|
||||
OpenPosition(
|
||||
ticker="AAPL",
|
||||
quantity=10,
|
||||
entry_price=18.0,
|
||||
current_price=20.0,
|
||||
unrealized_pnl=20.0,
|
||||
market_value=200.0,
|
||||
sector="Technology",
|
||||
stop_loss_price=16.0,
|
||||
take_profit_price=25.0,
|
||||
signal_confidence=0.80,
|
||||
),
|
||||
]
|
||||
|
||||
orders = engine.evaluate_rebalancing(positions, tier, active_pool=400.0)
|
||||
|
||||
assert len(orders) >= 1
|
||||
assert orders[0].ticker == "AAPL"
|
||||
assert orders[0].action == "sell"
|
||||
|
||||
def test_balanced_portfolio_generates_no_orders(self):
|
||||
engine = _make_engine()
|
||||
tier = _moderate_tier()
|
||||
|
||||
positions = [
|
||||
OpenPosition(
|
||||
ticker="AAPL",
|
||||
quantity=1,
|
||||
entry_price=30.0,
|
||||
current_price=30.0,
|
||||
unrealized_pnl=0.0,
|
||||
market_value=30.0, # $30 < 10% of $400 = $40
|
||||
sector="Technology",
|
||||
stop_loss_price=28.0,
|
||||
take_profit_price=35.0,
|
||||
signal_confidence=0.80,
|
||||
),
|
||||
]
|
||||
|
||||
orders = engine.evaluate_rebalancing(positions, tier, active_pool=400.0)
|
||||
assert len(orders) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 8: Notification wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNotificationWiring:
|
||||
"""Verify notification creation via engine wiring."""
|
||||
|
||||
def test_create_alert_returns_notification_record(self):
|
||||
engine = _make_engine()
|
||||
|
||||
record = engine.create_alert(
|
||||
event_type="circuit_breaker_triggered",
|
||||
details="Daily loss exceeded 5% threshold",
|
||||
)
|
||||
|
||||
assert record.event_type == "circuit_breaker_triggered"
|
||||
assert record.channel == "email"
|
||||
assert "Circuit Breaker Triggered" in record.message
|
||||
assert "Daily loss exceeded 5% threshold" in record.message
|
||||
assert record.delivery_status == "pending"
|
||||
|
||||
def test_create_alert_for_risk_tier_change(self):
|
||||
engine = _make_engine()
|
||||
|
||||
record = engine.create_alert(
|
||||
event_type="risk_tier_changed",
|
||||
details="moderate → conservative due to drawdown",
|
||||
)
|
||||
|
||||
assert record.event_type == "risk_tier_changed"
|
||||
assert "Risk Tier Changed" in record.message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 9: Micro-trading constraint wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMicroTradingConstraints:
|
||||
"""Verify micro-trading constraint checking via engine wiring."""
|
||||
|
||||
def test_disabled_micro_trading_rejects(self):
|
||||
engine = _make_engine(micro_trading_enabled=False)
|
||||
|
||||
allowed, reason = engine.check_micro_trade_constraints(
|
||||
daily_count=0,
|
||||
is_within_window=True,
|
||||
circuit_breaker_active=False,
|
||||
portfolio_heat_pct=0.05,
|
||||
max_heat=0.20,
|
||||
)
|
||||
|
||||
assert allowed is False
|
||||
assert reason == "micro_trading_disabled"
|
||||
|
||||
def test_enabled_micro_trading_allows(self):
|
||||
engine = _make_engine(micro_trading_enabled=True)
|
||||
|
||||
allowed, reason = engine.check_micro_trade_constraints(
|
||||
daily_count=0,
|
||||
is_within_window=True,
|
||||
circuit_breaker_active=False,
|
||||
portfolio_heat_pct=0.05,
|
||||
max_heat=0.20,
|
||||
)
|
||||
|
||||
assert allowed is True
|
||||
assert reason == "ok"
|
||||
|
||||
def test_circuit_breaker_blocks_micro_trade(self):
|
||||
engine = _make_engine(micro_trading_enabled=True)
|
||||
|
||||
allowed, reason = engine.check_micro_trade_constraints(
|
||||
daily_count=0,
|
||||
is_within_window=True,
|
||||
circuit_breaker_active=True,
|
||||
portfolio_heat_pct=0.05,
|
||||
max_heat=0.20,
|
||||
)
|
||||
|
||||
assert allowed is False
|
||||
assert reason == "circuit_breaker_active"
|
||||
|
||||
def test_daily_limit_blocks_micro_trade(self):
|
||||
engine = _make_engine(
|
||||
micro_trading_enabled=True,
|
||||
micro_trading_max_daily=10,
|
||||
)
|
||||
|
||||
allowed, reason = engine.check_micro_trade_constraints(
|
||||
daily_count=10,
|
||||
is_within_window=True,
|
||||
circuit_breaker_active=False,
|
||||
portfolio_heat_pct=0.05,
|
||||
max_heat=0.20,
|
||||
)
|
||||
|
||||
assert allowed is False
|
||||
assert reason == "daily_limit_reached"
|
||||
Reference in New Issue
Block a user