Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/models/recommendation_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,15 @@ def _generate_recommendation_explanation(self,
f"Max risk: {strategy.max_drawdown:.1%}"
)

# Position sizing
explanations.append(
f"Recommended position size: {strategy.kelly_size:.1%} of capital"
)
# Position sizing (kelly_size is None when historical data is absent)
if strategy.kelly_size is None:
explanations.append(
"Recommended position size: insufficient historical data to size"
)
else:
explanations.append(
f"Recommended position size: {strategy.kelly_size:.1%} of capital"
)

# Add strategy-specific insights
if strategy.strategy_type in [StrategyType.IRON_CONDOR, StrategyType.BUTTERFLY]:
Expand Down
33 changes: 19 additions & 14 deletions src/models/scoring_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class ScoredStrategy:
raw_probability: float # Neural network output
risk_adjusted_score: float # 0-100 normalized score
expected_value: float # Expected P/L
kelly_size: float # Position size recommendation (0-1)
kelly_size: Optional[float] # Position size recommendation (0-1); None if no historical data
max_drawdown: float # Historical/estimated max drawdown
var_95: float # 95% Value at Risk
confidence: float # Overall confidence (0-1)
Expand Down Expand Up @@ -104,12 +104,8 @@ def score_strategies(self,
metrics.get('win_rate', 0.5)
)

# Calculate Kelly position size
kelly_size = self._calculate_kelly_sizing(
metrics.get('win_rate', 0.5),
metrics.get('avg_win', 1.0),
metrics.get('avg_loss', 1.0)
)
# Calculate Kelly position size (None when historical data is absent)
kelly_size = self._calculate_kelly_sizing(metrics)

# Normalize score to 0-100 range
normalized_score = self._normalize_score(risk_adjusted)
Expand Down Expand Up @@ -192,10 +188,7 @@ def _calculate_expected_value(self,
# Can be enhanced with more sophisticated models
return expected_return * probability * win_rate

def _calculate_kelly_sizing(self,
win_rate: float,
avg_win: float,
avg_loss: float) -> float:
def _calculate_kelly_sizing(self, metrics: Dict[str, float]) -> Optional[float]:
"""
Calculate position size using Kelly criterion.

Expand All @@ -205,16 +198,28 @@ def _calculate_kelly_sizing(self,
- p = probability of winning
- q = probability of losing (1-p)
- b = ratio of win to loss

Returns None when the required historical inputs (win_rate, avg_win,
avg_loss) are not all present. Sizing has no statistical basis without
them, and returning 0 would be indistinguishable from a real "no edge"
result (Kelly = 0). Callers must treat None as "insufficient data".
"""
if not all(key in metrics for key in ("win_rate", "avg_win", "avg_loss")):
return None

win_rate = metrics["win_rate"]
avg_win = metrics["avg_win"]
avg_loss = metrics["avg_loss"]

if avg_loss <= 0 or win_rate <= 0 or win_rate >= 1:
return 0
return 0.0

p = win_rate
q = 1 - p
b = avg_win / avg_loss

if b <= 0:
return 0
return 0.0

# Full Kelly
kelly_full = (p * b - q) / b
Expand All @@ -223,7 +228,7 @@ def _calculate_kelly_sizing(self,
kelly_fraction = kelly_full * self.kelly_fraction

# Cap at maximum position size
return max(0, min(kelly_fraction, self.max_position_size))
return max(0.0, min(kelly_fraction, self.max_position_size))

def _normalize_score(self, raw_score: float) -> float:
"""Normalize score to 0-100 range for interpretability."""
Expand Down
24 changes: 24 additions & 0 deletions tests/models/test_scoring_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,30 @@ def test_kelly_sizing(self, scoring_engine, sample_probabilities,
# Iron Condor has higher win rate, should have higher Kelly size
assert iron_condor.kelly_size > straddle.kelly_size

def test_kelly_sizing_none_without_historical_data(self, scoring_engine):
"""Kelly size is None (not 0) when win_rate/avg_win/avg_loss are absent.

Regression for issue #17: returning 0 made "no data" indistinguishable
from a genuine "no edge" result.
"""
probs = {StrategyType.IRON_CONDOR: 0.5}
risk_metrics = {StrategyType.IRON_CONDOR: {'max_drawdown': 0.1, 'var_95': 0.03}}
expected_returns = {StrategyType.IRON_CONDOR: 0.1}

scored = scoring_engine.score_strategies(probs, risk_metrics, expected_returns)
assert scored[0].kelly_size is None

def test_kelly_sizing_zero_for_no_edge(self, scoring_engine):
"""Kelly size is 0.0 (not None) when data is present but there is no edge."""
probs = {StrategyType.IRON_CONDOR: 0.5}
risk_metrics = {StrategyType.IRON_CONDOR: {
'win_rate': 0.5, 'avg_win': 1.0, 'avg_loss': 1.0,
'max_drawdown': 0.1, 'var_95': 0.03}}
expected_returns = {StrategyType.IRON_CONDOR: 0.1}

scored = scoring_engine.score_strategies(probs, risk_metrics, expected_returns)
assert scored[0].kelly_size == 0.0

def test_risk_adjustment(self, scoring_engine):
"""Test risk adjustment in scoring."""
# High probability but high risk
Expand Down