Files
stonks-oracle/tests/test_pbt_stop_loss_manager.py
Celes Renata c85c0068a2 fix: clean up utcnow deprecation warnings, fix 12 failing tests, add CI/CD pipeline manifests
- Replace all datetime.utcnow() with datetime.now(tz=timezone.utc) across 8 files
- Fix 12 failing tests to match current implementation behavior
- Fix pytest_plugins in non-top-level conftest (moved to root conftest.py)
- Auto-fix 189 lint issues (import sorting, unused imports)
- Add CI/CD pipeline infrastructure (ARC, ArgoCD, Kargo manifests)
- Add values-beta.yaml and values-paper.yaml for staged deployments
- Update GitHub Actions workflow to use self-hosted-gremlin runners
- Add integration-test job to CI pipeline

Result: 1596 passed, 0 failed, 0 warnings
2026-04-18 03:59:28 +00:00

711 lines
30 KiB
Python

"""Property-based tests for the Stop-Loss Manager.
Feature: autonomous-trading-engine
Tests properties 9, 10, 11, 15, and 25 from the design specification,
covering initial stop/take-profit computation, price crossing triggers,
trailing stop activation, high-severity event tightening, and proactive
heat-based stop tightening.
"""
from __future__ import annotations
from datetime import datetime, timezone
from hypothesis import assume, given, settings
from hypothesis import strategies as st
from services.trading.models import (
OpenPosition,
RiskTierConfig,
StopLevels,
)
from services.trading.stop_loss_manager import StopLossManager
# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------
def _risk_tier_config_strategy() -> st.SearchStrategy[RiskTierConfig]:
"""Generate random RiskTierConfig objects with valid parameter ranges."""
return st.builds(
RiskTierConfig,
name=st.sampled_from(["conservative", "moderate", "aggressive"]),
min_confidence=st.floats(min_value=0.10, max_value=0.95, allow_nan=False, allow_infinity=False),
max_position_pct=st.floats(min_value=0.02, max_value=0.50, allow_nan=False, allow_infinity=False),
stop_loss_atr_multiplier=st.floats(min_value=0.5, max_value=5.0, allow_nan=False, allow_infinity=False),
reward_risk_ratio=st.floats(min_value=0.5, max_value=5.0, allow_nan=False, allow_infinity=False),
max_sector_pct=st.floats(min_value=0.05, max_value=0.60, allow_nan=False, allow_infinity=False),
max_portfolio_heat=st.floats(min_value=0.05, max_value=0.50, allow_nan=False, allow_infinity=False),
)
def _open_position_strategy(
ticker: st.SearchStrategy[str] | None = None,
entry_price: st.SearchStrategy[float] | None = None,
signal_confidence: st.SearchStrategy[float] | None = None,
) -> st.SearchStrategy[OpenPosition]:
"""Generate random OpenPosition objects."""
return st.builds(
OpenPosition,
ticker=ticker if ticker is not None else st.from_regex(r"[A-Z]{3,5}", fullmatch=True),
quantity=st.integers(min_value=1, max_value=100),
entry_price=entry_price if entry_price is not None else st.floats(
min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False,
),
current_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
unrealized_pnl=st.floats(min_value=-1000.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
market_value=st.floats(min_value=10.0, max_value=5000.0, allow_nan=False, allow_infinity=False),
sector=st.sampled_from(["Technology", "Healthcare", "Energy", "Financials", "Consumer"]),
stop_loss_price=st.floats(min_value=1.0, max_value=400.0, allow_nan=False, allow_infinity=False),
take_profit_price=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
signal_confidence=signal_confidence if signal_confidence is not None else st.floats(
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
),
is_micro_trade=st.just(False),
)
# ---------------------------------------------------------------------------
# Property 9: Stop-loss and take-profit initial computation
# **Validates: Requirements 4.1, 4.2**
# ---------------------------------------------------------------------------
class TestProperty9InitialComputation:
"""Property 9: Stop-loss and take-profit initial computation.
**Validates: Requirements 4.1, 4.2**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_stop_loss_equals_entry_minus_atr_times_multiplier(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Stop-loss = entry_price - (ATR * stop_loss_atr_multiplier)."""
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
expected_stop = entry_price - (atr * risk_tier.stop_loss_atr_multiplier)
assert abs(levels.stop_loss_price - expected_stop) < 1e-9, (
f"stop_loss {levels.stop_loss_price} != expected {expected_stop}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_stop_loss_always_below_entry(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Stop-loss is always below entry price (for positive ATR and multiplier)."""
assume(atr * risk_tier.stop_loss_atr_multiplier > 0)
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
assert levels.stop_loss_price < entry_price, (
f"stop_loss {levels.stop_loss_price} >= entry {entry_price}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_take_profit_equals_entry_plus_stop_distance_times_ratio(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Take-profit = entry_price + (stop_distance * reward_risk_ratio)."""
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
stop_distance = atr * risk_tier.stop_loss_atr_multiplier
expected_tp = entry_price + (stop_distance * risk_tier.reward_risk_ratio)
assert abs(levels.take_profit_price - expected_tp) < 1e-9, (
f"take_profit {levels.take_profit_price} != expected {expected_tp}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_take_profit_always_above_entry(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Take-profit is always above entry price."""
assume(atr * risk_tier.stop_loss_atr_multiplier * risk_tier.reward_risk_ratio > 0)
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
assert levels.take_profit_price > entry_price, (
f"take_profit {levels.take_profit_price} <= entry {entry_price}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=5.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.1, max_value=20.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_trailing_stop_initially_inactive(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Trailing stop is not active on initial computation."""
levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
assert levels.trailing_stop_active is False
# ---------------------------------------------------------------------------
# Property 10: Price crossing triggers immediate sell
# **Validates: Requirements 4.4, 4.5**
# ---------------------------------------------------------------------------
class TestProperty10PriceCrossingTriggers:
"""Property 10: Price crossing triggers immediate sell.
**Validates: Requirements 4.4, 4.5**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
stop_distance_pct=st.floats(min_value=0.02, max_value=0.20, allow_nan=False, allow_infinity=False),
tp_distance_pct=st.floats(min_value=0.02, max_value=0.40, allow_nan=False, allow_infinity=False),
)
def test_sell_triggered_when_price_at_or_below_stop_loss(
self, entry_price: float, stop_distance_pct: float, tp_distance_pct: float,
) -> None:
"""Sell triggered when current price <= stop_loss."""
stop_loss = entry_price * (1 - stop_distance_pct)
take_profit = entry_price * (1 + tp_distance_pct)
assume(stop_loss > 0)
assume(take_profit > stop_loss)
ticker = "TEST"
position = OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=stop_loss, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=0.7,
)
levels = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=1.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.now(tz=timezone.utc),
)
# Price at stop_loss
triggers = self.manager.check_price_crossings(
positions=[position],
prices={ticker: stop_loss},
stop_levels={ticker: levels},
)
assert len(triggers) == 1
assert triggers[0].trigger_type == "stop_loss"
assert triggers[0].ticker == ticker
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
stop_distance_pct=st.floats(min_value=0.02, max_value=0.20, allow_nan=False, allow_infinity=False),
tp_distance_pct=st.floats(min_value=0.02, max_value=0.40, allow_nan=False, allow_infinity=False),
below_pct=st.floats(min_value=0.001, max_value=0.10, allow_nan=False, allow_infinity=False),
)
def test_sell_triggered_when_price_below_stop_loss(
self, entry_price: float, stop_distance_pct: float,
tp_distance_pct: float, below_pct: float,
) -> None:
"""Sell triggered when current price is below stop_loss."""
stop_loss = entry_price * (1 - stop_distance_pct)
take_profit = entry_price * (1 + tp_distance_pct)
price_below = stop_loss * (1 - below_pct)
assume(stop_loss > 0)
assume(price_below > 0)
assume(take_profit > stop_loss)
ticker = "TEST"
position = OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=price_below, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=0.7,
)
levels = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=1.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.now(tz=timezone.utc),
)
triggers = self.manager.check_price_crossings(
positions=[position],
prices={ticker: price_below},
stop_levels={ticker: levels},
)
assert len(triggers) == 1
assert triggers[0].trigger_type == "stop_loss"
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
stop_distance_pct=st.floats(min_value=0.02, max_value=0.20, allow_nan=False, allow_infinity=False),
tp_distance_pct=st.floats(min_value=0.02, max_value=0.40, allow_nan=False, allow_infinity=False),
)
def test_sell_triggered_when_price_at_or_above_take_profit(
self, entry_price: float, stop_distance_pct: float, tp_distance_pct: float,
) -> None:
"""Sell triggered when current price >= take_profit."""
stop_loss = entry_price * (1 - stop_distance_pct)
take_profit = entry_price * (1 + tp_distance_pct)
assume(stop_loss > 0)
assume(take_profit > stop_loss)
ticker = "TEST"
position = OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=take_profit, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=0.7,
)
levels = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=1.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.now(tz=timezone.utc),
)
triggers = self.manager.check_price_crossings(
positions=[position],
prices={ticker: take_profit},
stop_levels={ticker: levels},
)
assert len(triggers) == 1
assert triggers[0].trigger_type == "take_profit"
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
stop_distance_pct=st.floats(min_value=0.05, max_value=0.20, allow_nan=False, allow_infinity=False),
tp_distance_pct=st.floats(min_value=0.05, max_value=0.40, allow_nan=False, allow_infinity=False),
between_pct=st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_no_trigger_when_price_between_levels(
self, entry_price: float, stop_distance_pct: float,
tp_distance_pct: float, between_pct: float,
) -> None:
"""No trigger when price is strictly between stop_loss and take_profit."""
stop_loss = entry_price * (1 - stop_distance_pct)
take_profit = entry_price * (1 + tp_distance_pct)
assume(stop_loss > 0)
assume(take_profit > stop_loss + 0.02)
# Price strictly between stop_loss and take_profit
price_between = stop_loss + (take_profit - stop_loss) * between_pct
# Ensure strictly between (not at boundaries)
assume(price_between > stop_loss)
assume(price_between < take_profit)
ticker = "TEST"
position = OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=price_between, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=0.7,
)
levels = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=1.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.now(tz=timezone.utc),
)
triggers = self.manager.check_price_crossings(
positions=[position],
prices={ticker: price_between},
stop_levels={ticker: levels},
)
assert len(triggers) == 0
# ---------------------------------------------------------------------------
# Property 11: Trailing stop activation at 50% of take-profit distance
# **Validates: Requirements 4.6**
# ---------------------------------------------------------------------------
class TestProperty11TrailingStopActivation:
"""Property 11: Trailing stop activation at 50% of take-profit distance.
**Validates: Requirements 4.6**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.5, max_value=10.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
move_fraction=st.floats(min_value=0.51, max_value=0.99, allow_nan=False, allow_infinity=False),
)
def test_trailing_stop_activates_when_move_exceeds_50_pct(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
move_fraction: float,
) -> None:
"""Trailing stop activates when favorable move > 50% of TP distance."""
# Compute initial levels to get the TP distance
initial_levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
tp_distance = initial_levels.take_profit_price - entry_price
assume(tp_distance > 0.01)
# Current price moved favorably by more than 50% of TP distance
current_price = entry_price + (tp_distance * move_fraction)
position = OpenPosition(
ticker="TEST", quantity=10, entry_price=entry_price,
current_price=current_price, unrealized_pnl=0.0,
market_value=current_price * 10, sector="Technology",
stop_loss_price=initial_levels.stop_loss_price,
take_profit_price=initial_levels.take_profit_price,
signal_confidence=0.7,
)
result = self.manager.re_evaluate_levels(
position=position,
current_price=current_price,
atr=atr,
risk_tier=risk_tier,
last_levels=initial_levels,
)
# re_evaluate_levels returns None when no material change, or StopLevels
# Since trailing stop activation IS a material change, we expect a result
assert result is not None, "Expected re_evaluate to return updated levels"
assert result.trailing_stop_active is True
# When trailing stop is active, stop should be at least at entry (breakeven)
assert result.stop_loss_price >= entry_price - 1e-9, (
f"Trailing stop {result.stop_loss_price} should be >= entry {entry_price}"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.5, max_value=10.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
move_fraction=st.floats(min_value=0.0, max_value=0.49, allow_nan=False, allow_infinity=False),
)
def test_trailing_stop_does_not_activate_when_move_at_or_below_50_pct(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
move_fraction: float,
) -> None:
"""Trailing stop does NOT activate when favorable move <= 50% of TP distance."""
initial_levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
tp_distance = initial_levels.take_profit_price - entry_price
assume(tp_distance > 0.01)
# Current price moved favorably by <= 50% of TP distance
current_price = entry_price + (tp_distance * move_fraction)
position = OpenPosition(
ticker="TEST", quantity=10, entry_price=entry_price,
current_price=current_price, unrealized_pnl=0.0,
market_value=current_price * 10, sector="Technology",
stop_loss_price=initial_levels.stop_loss_price,
take_profit_price=initial_levels.take_profit_price,
signal_confidence=0.7,
)
result = self.manager.re_evaluate_levels(
position=position,
current_price=current_price,
atr=atr,
risk_tier=risk_tier,
last_levels=initial_levels,
)
# Either None (no change) or result with trailing_stop_active=False
if result is not None:
assert result.trailing_stop_active is False, (
f"Trailing stop should not activate at {move_fraction*100:.1f}% move"
)
# ---------------------------------------------------------------------------
# Property 15: Stop tightening during high-severity events
# **Validates: Requirements 7.2**
# ---------------------------------------------------------------------------
class TestProperty15HighSeverityEventTightening:
"""Property 15: Stop tightening during high-severity events.
**Validates: Requirements 7.2**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.5, max_value=10.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_tightened_stop_uses_half_normal_multiplier(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""During high-severity events, stop uses 0.5x normal ATR multiplier."""
initial_levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
# Use a current price near entry so trailing stop doesn't activate
current_price = entry_price + 0.01
position = OpenPosition(
ticker="TEST", quantity=10, entry_price=entry_price,
current_price=current_price, unrealized_pnl=0.0,
market_value=current_price * 10, sector="Technology",
stop_loss_price=initial_levels.stop_loss_price,
take_profit_price=initial_levels.take_profit_price,
signal_confidence=0.7,
)
result = self.manager.re_evaluate_levels(
position=position,
current_price=current_price,
atr=atr,
risk_tier=risk_tier,
last_levels=initial_levels,
high_severity_event=True,
)
# High-severity event changes the multiplier, so we expect a result
assert result is not None, "Expected updated levels during high-severity event"
# The tightened stop should use 0.5x the normal multiplier
expected_tightened_multiplier = risk_tier.stop_loss_atr_multiplier * 0.5
expected_stop = entry_price - (atr * expected_tightened_multiplier)
assert abs(result.stop_loss_price - expected_stop) < 1e-9, (
f"Tightened stop {result.stop_loss_price} != expected {expected_stop} "
f"(0.5x multiplier = {expected_tightened_multiplier})"
)
@settings(max_examples=100)
@given(
entry_price=st.floats(min_value=50.0, max_value=500.0, allow_nan=False, allow_infinity=False),
atr=st.floats(min_value=0.5, max_value=10.0, allow_nan=False, allow_infinity=False),
risk_tier=_risk_tier_config_strategy(),
)
def test_tightened_stop_closer_to_entry_than_normal(
self, entry_price: float, atr: float, risk_tier: RiskTierConfig,
) -> None:
"""Tightened stop is closer to entry price (higher) than normal stop."""
initial_levels = self.manager.compute_initial_levels(
entry_price=entry_price, atr=atr, risk_tier=risk_tier,
)
current_price = entry_price + 0.01
position = OpenPosition(
ticker="TEST", quantity=10, entry_price=entry_price,
current_price=current_price, unrealized_pnl=0.0,
market_value=current_price * 10, sector="Technology",
stop_loss_price=initial_levels.stop_loss_price,
take_profit_price=initial_levels.take_profit_price,
signal_confidence=0.7,
)
result = self.manager.re_evaluate_levels(
position=position,
current_price=current_price,
atr=atr,
risk_tier=risk_tier,
last_levels=initial_levels,
high_severity_event=True,
)
assert result is not None
# Tightened stop should be closer to entry (higher value) than normal stop
normal_stop = entry_price - (atr * risk_tier.stop_loss_atr_multiplier)
assert result.stop_loss_price >= normal_stop - 1e-9, (
f"Tightened stop {result.stop_loss_price} should be >= normal stop {normal_stop}"
)
# ---------------------------------------------------------------------------
# Property 25: Proactive stop tightening at 80% heat threshold
# **Validates: Requirements 13.3**
# ---------------------------------------------------------------------------
class TestProperty25ProactiveHeatTightening:
"""Property 25: Proactive stop tightening at 80% heat threshold.
**Validates: Requirements 13.3**
"""
manager = StopLossManager()
@settings(max_examples=100)
@given(
num_positions=st.integers(min_value=2, max_value=5),
max_heat=st.floats(min_value=0.10, max_value=0.30, allow_nan=False, allow_infinity=False),
)
def test_lowest_confidence_positions_tightened_first(
self, num_positions: int, max_heat: float,
) -> None:
"""Lowest-confidence positions get stops tightened first when heat > 80% of max."""
# Create positions with distinct confidence levels
positions: list[OpenPosition] = []
stop_levels_dict: dict[str, StopLevels] = {}
for i in range(num_positions):
ticker = f"T{i}"
entry_price = 100.0
confidence = 0.3 + (i * 0.15) # ascending confidence: 0.3, 0.45, 0.6, ...
stop_loss = 90.0 # 10% below entry
take_profit = 115.0
positions.append(OpenPosition(
ticker=ticker, quantity=10, entry_price=entry_price,
current_price=entry_price, unrealized_pnl=0.0,
market_value=entry_price * 10, sector="Technology",
stop_loss_price=stop_loss, take_profit_price=take_profit,
signal_confidence=confidence,
))
stop_levels_dict[ticker] = StopLevels(
stop_loss_price=stop_loss,
take_profit_price=take_profit,
trailing_stop_active=False,
atr_value=5.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.now(tz=timezone.utc),
)
# Set heat above 80% of max to trigger tightening
portfolio_heat = max_heat * 0.85
active_pool = 10000.0
updated = self.manager.tighten_for_heat(
positions=positions,
stop_levels=stop_levels_dict,
portfolio_heat=portfolio_heat,
max_heat=max_heat,
active_pool=active_pool,
)
if updated:
# Verify that tightened positions are ordered by confidence (lowest first)
tightened_tickers = list(updated.keys())
tightened_confidences = [
next(p.signal_confidence for p in positions if p.ticker == t)
for t in tightened_tickers
]
# The first tightened position should have the lowest confidence
min_confidence_in_portfolio = min(p.signal_confidence for p in positions)
if tightened_tickers:
first_tightened_confidence = next(
p.signal_confidence for p in positions if p.ticker == tightened_tickers[0]
)
assert first_tightened_confidence == min_confidence_in_portfolio, (
f"First tightened position confidence {first_tightened_confidence} "
f"!= min confidence {min_confidence_in_portfolio}"
)
# All tightened stops should be >= original stops (moved closer to entry)
for ticker, new_levels in updated.items():
original_stop = stop_levels_dict[ticker].stop_loss_price
assert new_levels.stop_loss_price >= original_stop - 1e-9, (
f"{ticker}: tightened stop {new_levels.stop_loss_price} "
f"< original {original_stop}"
)
@settings(max_examples=100)
@given(
max_heat=st.floats(min_value=0.10, max_value=0.30, allow_nan=False, allow_infinity=False),
heat_fraction=st.floats(min_value=0.0, max_value=0.79, allow_nan=False, allow_infinity=False),
)
def test_no_tightening_when_heat_below_80_pct_threshold(
self, max_heat: float, heat_fraction: float,
) -> None:
"""No tightening when portfolio heat <= 80% of max."""
portfolio_heat = max_heat * heat_fraction
positions = [
OpenPosition(
ticker="T0", quantity=10, entry_price=100.0,
current_price=100.0, unrealized_pnl=0.0,
market_value=1000.0, sector="Technology",
stop_loss_price=90.0, take_profit_price=115.0,
signal_confidence=0.5,
),
]
stop_levels_dict = {
"T0": StopLevels(
stop_loss_price=90.0,
take_profit_price=115.0,
trailing_stop_active=False,
atr_value=5.0,
atr_multiplier=2.0,
reward_risk_ratio=1.5,
last_updated=datetime.now(tz=timezone.utc),
),
}
updated = self.manager.tighten_for_heat(
positions=positions,
stop_levels=stop_levels_dict,
portfolio_heat=portfolio_heat,
max_heat=max_heat,
active_pool=10000.0,
)
assert updated == {}, (
f"Expected no tightening at heat {portfolio_heat} "
f"(80% threshold = {max_heat * 0.8}), got {len(updated)} updates"
)