Files
stonks-oracle/tests/test_pbt_v3_decision.py
T

451 lines
17 KiB
Python
Raw 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.
"""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}'"
)