451 lines
17 KiB
Python
451 lines
17 KiB
Python
"""Property-based tests for v3 Kelly sizing and regime-aware stops.
|
||
|
||
Validates:
|
||
- Property 11: Fractional Kelly sizing is bounded and respects negative edge
|
||
- Property 18: Stop loss is below entry price and take profit is above
|
||
- Property 19: Trailing stop never decreases
|
||
|
||
Requirements: 14.4, 14.7, 16.2, 16.3, 16.5, 21.8, 21.9
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from hypothesis import given, settings
|
||
from hypothesis import strategies as st
|
||
|
||
from services.trading.position_sizer import (
|
||
compute_kelly_sizing,
|
||
)
|
||
from services.trading.stop_loss_manager import (
|
||
compute_trailing_stop,
|
||
compute_v3_stops,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Strategies
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Kelly sizing inputs
|
||
p_up_values = st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False)
|
||
b_values = st.floats(min_value=1.2, max_value=3.0, allow_nan=False, allow_infinity=False)
|
||
confidence_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
|
||
data_quality_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
|
||
contradiction_values = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
|
||
max_position_pct_values = st.floats(min_value=0.001, max_value=0.50, allow_nan=False, allow_infinity=False)
|
||
|
||
# Stop loss inputs
|
||
entry_price_values = st.floats(min_value=0.01, max_value=100000.0, allow_nan=False, allow_infinity=False)
|
||
stop_distance_pct_values = st.floats(min_value=0.005, max_value=0.999, allow_nan=False, allow_infinity=False)
|
||
reward_ratio_values = st.floats(min_value=1.2, max_value=5.0, allow_nan=False, allow_infinity=False)
|
||
|
||
# Trailing stop price sequences
|
||
price_values = st.floats(min_value=1.0, max_value=10000.0, allow_nan=False, allow_infinity=False)
|
||
atr_pct_values = st.floats(min_value=0.005, max_value=0.20, allow_nan=False, allow_infinity=False)
|
||
trailing_atr_mult_values = st.floats(min_value=0.5, max_value=3.0, allow_nan=False, allow_infinity=False)
|
||
sigma_h_values = st.floats(min_value=0.005, max_value=0.50, allow_nan=False, allow_infinity=False)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Feature: math-core-v3-engine, Property 11: Fractional Kelly sizing is
|
||
# bounded and respects negative edge
|
||
# ---------------------------------------------------------------------------
|
||
# **Validates: Requirements 14.4, 14.7, 21.8, 21.9**
|
||
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
p_up=p_up_values,
|
||
b=b_values,
|
||
confidence=confidence_values,
|
||
data_quality=data_quality_values,
|
||
contradiction=contradiction_values,
|
||
max_position_pct=max_position_pct_values,
|
||
)
|
||
def test_property_11_kelly_sizing_bounded_and_respects_negative_edge(
|
||
p_up: float,
|
||
b: float,
|
||
confidence: float,
|
||
data_quality: float,
|
||
contradiction: float,
|
||
max_position_pct: float,
|
||
) -> None:
|
||
"""Property 11: Fractional Kelly sizing is bounded and respects negative edge.
|
||
|
||
For any valid inputs (P_up in (0,1), b in [1.2, 3.0], confidence in [0,1],
|
||
data_quality in [0,1], contradiction in [0,1], max_position_pct > 0),
|
||
the computed portfolio_pct SHALL be in [0, max_position_pct].
|
||
When f_kelly = (P_up * b - (1 - P_up)) / b <= 0, portfolio_pct SHALL be exactly 0.
|
||
"""
|
||
# Use generous capacity caps that don't constrain
|
||
available_caps = {
|
||
"sector_capacity": max_position_pct,
|
||
"correlation_capacity": max_position_pct,
|
||
"heat_capacity": max_position_pct,
|
||
}
|
||
|
||
result = compute_kelly_sizing(
|
||
p_win=p_up,
|
||
b=b,
|
||
confidence=confidence,
|
||
data_quality=data_quality,
|
||
contradiction=contradiction,
|
||
max_position_pct=max_position_pct,
|
||
available_caps=available_caps,
|
||
)
|
||
|
||
# portfolio_pct must be in [0, max_position_pct]
|
||
assert 0.0 <= result.portfolio_pct <= max_position_pct, (
|
||
f"portfolio_pct={result.portfolio_pct} not in [0, {max_position_pct}] "
|
||
f"for p_up={p_up}, b={b}, conf={confidence}, dq={data_quality}, "
|
||
f"contra={contradiction}"
|
||
)
|
||
|
||
# When f_kelly <= 0, portfolio_pct must be exactly 0
|
||
f_kelly = (p_up * b - (1.0 - p_up)) / b
|
||
if f_kelly <= 0:
|
||
assert result.portfolio_pct == 0.0, (
|
||
f"portfolio_pct={result.portfolio_pct} should be 0.0 when "
|
||
f"f_kelly={f_kelly} <= 0 (p_up={p_up}, b={b})"
|
||
)
|
||
assert result.downgrade is True, (
|
||
f"downgrade should be True when f_kelly={f_kelly} <= 0"
|
||
)
|
||
assert result.downgrade_reason == "negative_edge", (
|
||
f"downgrade_reason should be 'negative_edge' when f_kelly={f_kelly} <= 0, "
|
||
f"got '{result.downgrade_reason}'"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Feature: math-core-v3-engine, Property 18: Stop loss is below entry price
|
||
# and take profit is above
|
||
# ---------------------------------------------------------------------------
|
||
# **Validates: Requirements 16.2, 16.3**
|
||
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
entry_price=entry_price_values,
|
||
atr_pct=atr_pct_values,
|
||
regime_atr_mult=st.floats(min_value=0.5, max_value=3.0, allow_nan=False, allow_infinity=False),
|
||
sigma_h=sigma_h_values,
|
||
reward_ratio=reward_ratio_values,
|
||
)
|
||
def test_property_18_stop_loss_below_entry_take_profit_above(
|
||
entry_price: float,
|
||
atr_pct: float,
|
||
regime_atr_mult: float,
|
||
sigma_h: float,
|
||
reward_ratio: float,
|
||
) -> None:
|
||
"""Property 18: Stop loss is below entry price and take profit is above.
|
||
|
||
For any entry_price > 0, stop_distance_pct in [0.005, 1.0), and reward
|
||
ratio b >= 1.2, the computed stop_loss SHALL be less than entry_price
|
||
and take_profit SHALL be greater than entry_price.
|
||
"""
|
||
result = compute_v3_stops(
|
||
entry_price=entry_price,
|
||
atr_pct=atr_pct,
|
||
regime_atr_mult=regime_atr_mult,
|
||
sigma_h=sigma_h,
|
||
reward_ratio=reward_ratio,
|
||
)
|
||
|
||
# stop_distance_pct should be at least 0.005 (the min floor)
|
||
assert result.stop_distance_pct >= 0.005, (
|
||
f"stop_distance_pct={result.stop_distance_pct} should be >= 0.005"
|
||
)
|
||
|
||
# Stop loss must be strictly below entry price
|
||
assert result.stop_loss < entry_price, (
|
||
f"stop_loss={result.stop_loss} should be < entry_price={entry_price} "
|
||
f"(stop_distance_pct={result.stop_distance_pct})"
|
||
)
|
||
|
||
# Take profit must be strictly above entry price
|
||
assert result.take_profit > entry_price, (
|
||
f"take_profit={result.take_profit} should be > entry_price={entry_price} "
|
||
f"(reward_ratio={reward_ratio}, stop_distance_pct={result.stop_distance_pct})"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Feature: math-core-v3-engine, Property 19: Trailing stop never decreases
|
||
# ---------------------------------------------------------------------------
|
||
# **Validates: Requirements 16.5**
|
||
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
entry_price=st.floats(min_value=10.0, max_value=1000.0, allow_nan=False, allow_infinity=False),
|
||
atr_pct=atr_pct_values,
|
||
trailing_atr_mult=trailing_atr_mult_values,
|
||
sigma_h=sigma_h_values,
|
||
price_moves=st.lists(
|
||
st.floats(min_value=0.0, max_value=2.0, allow_nan=False, allow_infinity=False),
|
||
min_size=3,
|
||
max_size=20,
|
||
),
|
||
)
|
||
def test_property_19_trailing_stop_never_decreases(
|
||
entry_price: float,
|
||
atr_pct: float,
|
||
trailing_atr_mult: float,
|
||
sigma_h: float,
|
||
price_moves: list[float],
|
||
) -> None:
|
||
"""Property 19: Trailing stop never decreases.
|
||
|
||
For any sequence of current prices and trailing stop computations,
|
||
each new trailing_stop value SHALL be >= the previous trailing_stop
|
||
value (monotonically non-decreasing).
|
||
"""
|
||
# Set up a take profit that's achievable
|
||
reward_ratio = 2.0
|
||
stop_distance_pct = max(atr_pct * 1.5, sigma_h * 1.25, 0.005)
|
||
take_profit = entry_price * (1.0 + reward_ratio * stop_distance_pct)
|
||
|
||
# Start with an initial stop below entry
|
||
existing_stop = entry_price * (1.0 - stop_distance_pct)
|
||
|
||
# Generate a sequence of prices that move upward from entry
|
||
# (scaled by price_moves multiplied by stop distance to be meaningful)
|
||
previous_trailing_stop = existing_stop
|
||
|
||
for move_factor in price_moves:
|
||
# Price moves upward from entry by some fraction of TP distance
|
||
tp_distance = take_profit - entry_price
|
||
current_price = entry_price + move_factor * tp_distance
|
||
|
||
result = compute_trailing_stop(
|
||
existing_stop=previous_trailing_stop,
|
||
current_price=current_price,
|
||
entry_price=entry_price,
|
||
take_profit=take_profit,
|
||
atr_pct=atr_pct,
|
||
trailing_atr_mult=trailing_atr_mult,
|
||
sigma_h=sigma_h,
|
||
)
|
||
|
||
# The trailing stop must never decrease (monotonically non-decreasing)
|
||
assert result.trailing_stop >= previous_trailing_stop, (
|
||
f"trailing_stop={result.trailing_stop} decreased from "
|
||
f"previous={previous_trailing_stop} at current_price={current_price}, "
|
||
f"entry={entry_price}, tp={take_profit}"
|
||
)
|
||
|
||
# Update for next iteration
|
||
previous_trailing_stop = result.trailing_stop
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Additional imports for heat and tier adjustment tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
from services.risk.engine import (
|
||
TierMetrics,
|
||
check_heat_capacity,
|
||
compute_portfolio_heat,
|
||
evaluate_tier_adjustment,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Strategies for heat and tier tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Portfolio heat inputs
|
||
position_value_strategy = st.floats(
|
||
min_value=100.0, max_value=1_000_000.0, allow_nan=False, allow_infinity=False
|
||
)
|
||
stop_distance_strategy = st.floats(
|
||
min_value=0.005, max_value=0.50, allow_nan=False, allow_infinity=False
|
||
)
|
||
max_heat_pct_strategy = st.floats(
|
||
min_value=0.01, max_value=0.50, allow_nan=False, allow_infinity=False
|
||
)
|
||
portfolio_value_strategy = st.floats(
|
||
min_value=10_000.0, max_value=10_000_000.0, allow_nan=False, allow_infinity=False
|
||
)
|
||
|
||
# Tier metrics inputs
|
||
profit_factor_strategy = st.floats(
|
||
min_value=0.0, max_value=5.0, allow_nan=False, allow_infinity=False
|
||
)
|
||
drawdown_strategy = st.floats(
|
||
min_value=0.0, max_value=0.50, allow_nan=False, allow_infinity=False
|
||
)
|
||
calibration_error_strategy = st.floats(
|
||
min_value=0.0, max_value=0.50, allow_nan=False, allow_infinity=False
|
||
)
|
||
sharpe_strategy = st.floats(
|
||
min_value=-3.0, max_value=5.0, allow_nan=False, allow_infinity=False
|
||
)
|
||
n_trades_strategy = st.integers(min_value=0, max_value=200)
|
||
reserve_pool_strategy = st.floats(
|
||
min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Feature: math-core-v3-engine, Property 22: Portfolio heat rejection is
|
||
# correct
|
||
# ---------------------------------------------------------------------------
|
||
# **Validates: Requirements 15.3, 15.5**
|
||
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
position_values=st.lists(
|
||
position_value_strategy, min_size=1, max_size=10
|
||
),
|
||
stop_distances_list=st.lists(
|
||
stop_distance_strategy, min_size=1, max_size=10
|
||
),
|
||
new_position_value=position_value_strategy,
|
||
new_stop_distance=stop_distance_strategy,
|
||
max_heat_pct=max_heat_pct_strategy,
|
||
portfolio_value=portfolio_value_strategy,
|
||
)
|
||
def test_property_22_portfolio_heat_rejection_is_correct(
|
||
position_values: list[float],
|
||
stop_distances_list: list[float],
|
||
new_position_value: float,
|
||
new_stop_distance: float,
|
||
max_heat_pct: float,
|
||
portfolio_value: float,
|
||
) -> None:
|
||
"""Property 22: Portfolio heat rejection is correct.
|
||
|
||
For any set of open positions with stop distances, if the sum of
|
||
(position_value × stop_distance_pct) exceeds max_portfolio_heat ×
|
||
portfolio_value, then new position entry SHALL be rejected.
|
||
"""
|
||
# Align list lengths (use shorter of the two)
|
||
n = min(len(position_values), len(stop_distances_list))
|
||
position_values = position_values[:n]
|
||
stop_distances_list = stop_distances_list[:n]
|
||
|
||
# Build positions and stop_distances dicts
|
||
positions = []
|
||
stop_distances_dict: dict[str, float] = {}
|
||
for i in range(n):
|
||
ticker = f"TICK{i}"
|
||
positions.append({"ticker": ticker, "position_value": position_values[i]})
|
||
stop_distances_dict[ticker] = stop_distances_list[i]
|
||
|
||
# Compute current portfolio heat
|
||
current_heat = compute_portfolio_heat(positions, stop_distances_dict)
|
||
|
||
# Compute new position risk dollars
|
||
new_risk_dollars = new_position_value * new_stop_distance
|
||
|
||
# Check heat capacity
|
||
allowed = check_heat_capacity(
|
||
current_heat=current_heat,
|
||
new_risk_dollars=new_risk_dollars,
|
||
max_heat_pct=max_heat_pct,
|
||
portfolio_value=portfolio_value,
|
||
)
|
||
|
||
# The max allowed heat in dollars
|
||
max_heat_dollars = max_heat_pct * portfolio_value
|
||
|
||
# Verify: if adding new position exceeds limit, must be rejected (False)
|
||
if (current_heat + new_risk_dollars) > max_heat_dollars:
|
||
assert allowed is False, (
|
||
f"Expected rejection: current_heat={current_heat:.2f} + "
|
||
f"new_risk={new_risk_dollars:.2f} = {current_heat + new_risk_dollars:.2f} "
|
||
f"> max_heat={max_heat_dollars:.2f}, but got allowed=True"
|
||
)
|
||
else:
|
||
# If within limit, must be allowed (True)
|
||
assert allowed is True, (
|
||
f"Expected allowance: current_heat={current_heat:.2f} + "
|
||
f"new_risk={new_risk_dollars:.2f} = {current_heat + new_risk_dollars:.2f} "
|
||
f"<= max_heat={max_heat_dollars:.2f}, but got allowed=False"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Feature: math-core-v3-engine, Property 23: Tier auto-adjustment obeys
|
||
# downgrade-any, upgrade-all logic
|
||
# ---------------------------------------------------------------------------
|
||
# **Validates: Requirements 18.3, 18.4**
|
||
|
||
|
||
@settings(max_examples=100)
|
||
@given(
|
||
profit_factor=profit_factor_strategy,
|
||
max_drawdown=drawdown_strategy,
|
||
calibration_error=calibration_error_strategy,
|
||
sharpe=sharpe_strategy,
|
||
n_trades=n_trades_strategy,
|
||
reserve_pool=reserve_pool_strategy,
|
||
)
|
||
def test_property_23_tier_adjustment_downgrade_any_upgrade_all(
|
||
profit_factor: float,
|
||
max_drawdown: float,
|
||
calibration_error: float,
|
||
sharpe: float,
|
||
n_trades: int,
|
||
reserve_pool: float,
|
||
) -> None:
|
||
"""Property 23: Tier auto-adjustment obeys downgrade-any, upgrade-all logic.
|
||
|
||
For any TierMetrics, if ANY single downgrade condition is met
|
||
(profit_factor < 1.0 OR drawdown > 0.12 OR calibration_error > 0.20 OR
|
||
sharpe < 0), the result SHALL be "downgrade". An "upgrade" SHALL only
|
||
occur when ALL upgrade conditions are simultaneously met.
|
||
"""
|
||
metrics = TierMetrics(
|
||
profit_factor_30d=profit_factor,
|
||
max_drawdown_30d=max_drawdown,
|
||
calibration_error=calibration_error,
|
||
realized_sharpe_30d=sharpe,
|
||
n_trades_30d=n_trades,
|
||
reserve_pool_pct=reserve_pool,
|
||
)
|
||
|
||
result = evaluate_tier_adjustment(metrics)
|
||
|
||
# Check downgrade conditions (any single one triggers downgrade)
|
||
downgrade_triggered = (
|
||
profit_factor < 1.0
|
||
or max_drawdown > 0.12
|
||
or calibration_error > 0.20
|
||
or sharpe < 0
|
||
)
|
||
|
||
# Check upgrade conditions (all must be met simultaneously)
|
||
upgrade_triggered = (
|
||
profit_factor > 1.35
|
||
and max_drawdown < 0.05
|
||
and calibration_error < 0.12
|
||
and reserve_pool > 0.20
|
||
and n_trades >= 20
|
||
)
|
||
|
||
if downgrade_triggered:
|
||
assert result == "downgrade", (
|
||
f"Expected 'downgrade' when downgrade condition met: "
|
||
f"pf={profit_factor}, dd={max_drawdown}, "
|
||
f"cal_err={calibration_error}, sharpe={sharpe}, "
|
||
f"but got '{result}'"
|
||
)
|
||
elif upgrade_triggered:
|
||
assert result == "upgrade", (
|
||
f"Expected 'upgrade' when all upgrade conditions met: "
|
||
f"pf={profit_factor}, dd={max_drawdown}, "
|
||
f"cal_err={calibration_error}, sharpe={sharpe}, "
|
||
f"n_trades={n_trades}, reserve={reserve_pool}, "
|
||
f"but got '{result}'"
|
||
)
|
||
else:
|
||
assert result == "hold", (
|
||
f"Expected 'hold' when neither downgrade nor upgrade: "
|
||
f"pf={profit_factor}, dd={max_drawdown}, "
|
||
f"cal_err={calibration_error}, sharpe={sharpe}, "
|
||
f"n_trades={n_trades}, reserve={reserve_pool}, "
|
||
f"but got '{result}'"
|
||
)
|