1178 lines
41 KiB
Python
1178 lines
41 KiB
Python
"""Economic Conditions Module for regime tagging and evaluation.
|
|
|
|
This module provides economic and market regime analysis including:
|
|
- Market regime detection (bull, bear, sideways)
|
|
- Scenario tagging by economic conditions
|
|
- Performance evaluation by regime
|
|
- Regime-specific recommendations
|
|
|
|
Issue #35: [SIM-34] Economic conditions - regime tagging, evaluation
|
|
|
|
Design Principles:
|
|
- Compatible with ScenarioRunner and StrategyComparator
|
|
- Statistical regime detection
|
|
- Comprehensive performance breakdown by regime
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import date, datetime, timedelta
|
|
from decimal import Decimal
|
|
from enum import Enum
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
|
import statistics
|
|
import uuid
|
|
|
|
|
|
# ============================================================================
|
|
# Enums
|
|
# ============================================================================
|
|
|
|
class MarketRegime(str, Enum):
|
|
"""Market regime classifications based on price action."""
|
|
BULL = "bull" # Strong uptrend (>20% annualized)
|
|
MODERATE_BULL = "moderate_bull" # Modest uptrend (5-20% annualized)
|
|
SIDEWAYS = "sideways" # Range-bound (-5% to 5% annualized)
|
|
MODERATE_BEAR = "moderate_bear" # Modest downtrend (-20% to -5%)
|
|
BEAR = "bear" # Strong downtrend (<-20% annualized)
|
|
|
|
|
|
class VolatilityRegime(str, Enum):
|
|
"""Volatility regime classifications."""
|
|
LOW = "low" # VIX < 15 or vol < 10%
|
|
NORMAL = "normal" # VIX 15-20 or vol 10-20%
|
|
ELEVATED = "elevated" # VIX 20-30 or vol 20-30%
|
|
HIGH = "high" # VIX > 30 or vol > 30%
|
|
|
|
|
|
class TrendStrength(str, Enum):
|
|
"""Strength of the detected trend."""
|
|
STRONG = "strong" # Clear, persistent trend
|
|
MODERATE = "moderate" # Identifiable trend with noise
|
|
WEAK = "weak" # Marginal trend
|
|
NONE = "none" # No discernible trend
|
|
|
|
|
|
class RegimeConfidence(str, Enum):
|
|
"""Confidence level in regime classification."""
|
|
HIGH = "high" # Strong statistical support
|
|
MEDIUM = "medium" # Moderate support
|
|
LOW = "low" # Marginal classification
|
|
|
|
|
|
# ============================================================================
|
|
# Data Classes
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class RegimeTag:
|
|
"""Tag for a scenario or time period with regime information.
|
|
|
|
Attributes:
|
|
tag_id: Unique identifier for this tag
|
|
start_date: Start of the tagged period
|
|
end_date: End of the tagged period
|
|
market_regime: Bull/bear/sideways classification
|
|
volatility_regime: Low/normal/elevated/high volatility
|
|
trend_strength: Strength of the detected trend
|
|
confidence: Confidence in the regime classification
|
|
annualized_return: Return during this period (annualized)
|
|
volatility: Volatility during this period (annualized)
|
|
max_drawdown: Maximum drawdown during period
|
|
metadata: Additional tag data
|
|
"""
|
|
tag_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
start_date: Optional[date] = None
|
|
end_date: Optional[date] = None
|
|
market_regime: MarketRegime = MarketRegime.SIDEWAYS
|
|
volatility_regime: VolatilityRegime = VolatilityRegime.NORMAL
|
|
trend_strength: TrendStrength = TrendStrength.NONE
|
|
confidence: RegimeConfidence = RegimeConfidence.MEDIUM
|
|
annualized_return: Decimal = Decimal("0")
|
|
volatility: Decimal = Decimal("0")
|
|
max_drawdown: Decimal = Decimal("0")
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class RegimePerformance:
|
|
"""Performance summary for a specific regime.
|
|
|
|
Attributes:
|
|
regime: The regime this performance is for
|
|
period_count: Number of periods in this regime
|
|
total_days: Total trading days in this regime
|
|
avg_return: Average return across periods
|
|
total_return: Cumulative return
|
|
volatility: Average volatility in this regime
|
|
avg_drawdown: Average max drawdown
|
|
worst_drawdown: Worst max drawdown seen
|
|
win_rate: Percentage of winning periods
|
|
sharpe_ratio: Sharpe ratio for this regime
|
|
best_period_return: Best single period return
|
|
worst_period_return: Worst single period return
|
|
consistency_score: How consistent returns are (0-1)
|
|
"""
|
|
regime: Union[MarketRegime, VolatilityRegime]
|
|
period_count: int = 0
|
|
total_days: int = 0
|
|
avg_return: Decimal = Decimal("0")
|
|
total_return: Decimal = Decimal("0")
|
|
volatility: Decimal = Decimal("0")
|
|
avg_drawdown: Decimal = Decimal("0")
|
|
worst_drawdown: Decimal = Decimal("0")
|
|
win_rate: Decimal = Decimal("0")
|
|
sharpe_ratio: Optional[Decimal] = None
|
|
best_period_return: Decimal = Decimal("0")
|
|
worst_period_return: Decimal = Decimal("0")
|
|
consistency_score: Decimal = Decimal("0")
|
|
|
|
|
|
@dataclass
|
|
class RegimeTransition:
|
|
"""Record of a transition between regimes.
|
|
|
|
Attributes:
|
|
transition_id: Unique identifier
|
|
transition_date: When the transition occurred
|
|
from_regime: Previous regime
|
|
to_regime: New regime
|
|
transition_return: Return during transition period
|
|
transition_volatility: Volatility during transition
|
|
days_in_prior_regime: How long in prior regime
|
|
"""
|
|
transition_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
transition_date: Optional[date] = None
|
|
from_regime: Optional[MarketRegime] = None
|
|
to_regime: Optional[MarketRegime] = None
|
|
transition_return: Decimal = Decimal("0")
|
|
transition_volatility: Decimal = Decimal("0")
|
|
days_in_prior_regime: int = 0
|
|
|
|
|
|
@dataclass
|
|
class RegimeRecommendation:
|
|
"""Strategy recommendation for a specific regime.
|
|
|
|
Attributes:
|
|
regime: The regime this recommendation is for
|
|
allocation_adjustment: Suggested allocation change (-1 to 1)
|
|
risk_adjustment: Suggested risk adjustment (-1 to 1)
|
|
position_sizing: Recommended position size factor (0.5-1.5)
|
|
strategy_notes: Specific strategy recommendations
|
|
cautions: Warnings for this regime
|
|
opportunities: Potential opportunities
|
|
"""
|
|
regime: MarketRegime
|
|
allocation_adjustment: Decimal = Decimal("0")
|
|
risk_adjustment: Decimal = Decimal("0")
|
|
position_sizing: Decimal = Decimal("1")
|
|
strategy_notes: List[str] = field(default_factory=list)
|
|
cautions: List[str] = field(default_factory=list)
|
|
opportunities: List[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class RegimeEvaluationResult:
|
|
"""Complete result of regime-based evaluation.
|
|
|
|
Attributes:
|
|
evaluation_id: Unique identifier
|
|
strategy_id: ID of evaluated strategy
|
|
strategy_name: Name of the strategy
|
|
start_date: Evaluation period start
|
|
end_date: Evaluation period end
|
|
current_regime: Currently detected regime
|
|
current_volatility: Current volatility regime
|
|
regime_tags: All regime tags detected
|
|
performance_by_market_regime: Performance in each market regime
|
|
performance_by_volatility: Performance in each volatility regime
|
|
transitions: Regime transitions detected
|
|
recommendations: Regime-specific recommendations
|
|
overall_regime_score: How well strategy handles regimes (0-100)
|
|
regime_adaptability: How adaptive strategy is to changes (0-100)
|
|
metadata: Additional evaluation data
|
|
"""
|
|
evaluation_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
strategy_id: str = ""
|
|
strategy_name: str = ""
|
|
start_date: Optional[date] = None
|
|
end_date: Optional[date] = None
|
|
current_regime: MarketRegime = MarketRegime.SIDEWAYS
|
|
current_volatility: VolatilityRegime = VolatilityRegime.NORMAL
|
|
regime_tags: List[RegimeTag] = field(default_factory=list)
|
|
performance_by_market_regime: Dict[MarketRegime, RegimePerformance] = field(
|
|
default_factory=dict
|
|
)
|
|
performance_by_volatility: Dict[VolatilityRegime, RegimePerformance] = field(
|
|
default_factory=dict
|
|
)
|
|
transitions: List[RegimeTransition] = field(default_factory=list)
|
|
recommendations: Dict[MarketRegime, RegimeRecommendation] = field(
|
|
default_factory=dict
|
|
)
|
|
overall_regime_score: Decimal = Decimal("0")
|
|
regime_adaptability: Decimal = Decimal("0")
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
# ============================================================================
|
|
# RegimeDetector Class
|
|
# ============================================================================
|
|
|
|
class RegimeDetector:
|
|
"""Detects market and volatility regimes from price/return data.
|
|
|
|
Uses statistical analysis of returns to classify market conditions
|
|
into bull, bear, or sideways regimes with volatility assessment.
|
|
|
|
Attributes:
|
|
bull_threshold: Annualized return above this = bull
|
|
bear_threshold: Annualized return below this = bear
|
|
vol_low_threshold: Vol below this = low volatility
|
|
vol_high_threshold: Vol above this = high volatility
|
|
min_periods: Minimum periods needed for regime detection
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
bull_threshold: Decimal = Decimal("0.20"),
|
|
bear_threshold: Decimal = Decimal("-0.20"),
|
|
moderate_bull_threshold: Decimal = Decimal("0.05"),
|
|
moderate_bear_threshold: Decimal = Decimal("-0.05"),
|
|
vol_low_threshold: Decimal = Decimal("0.10"),
|
|
vol_normal_threshold: Decimal = Decimal("0.20"),
|
|
vol_high_threshold: Decimal = Decimal("0.30"),
|
|
min_periods: int = 20,
|
|
):
|
|
"""Initialize regime detector with thresholds.
|
|
|
|
Args:
|
|
bull_threshold: Return above this = strong bull (default 20%)
|
|
bear_threshold: Return below this = strong bear (default -20%)
|
|
moderate_bull_threshold: Return above this = moderate bull (default 5%)
|
|
moderate_bear_threshold: Return below this = moderate bear (default -5%)
|
|
vol_low_threshold: Volatility below this = low (default 10%)
|
|
vol_normal_threshold: Vol above this = elevated (default 20%)
|
|
vol_high_threshold: Vol above this = high (default 30%)
|
|
min_periods: Minimum periods for detection (default 20)
|
|
"""
|
|
self.bull_threshold = bull_threshold
|
|
self.bear_threshold = bear_threshold
|
|
self.moderate_bull_threshold = moderate_bull_threshold
|
|
self.moderate_bear_threshold = moderate_bear_threshold
|
|
self.vol_low_threshold = vol_low_threshold
|
|
self.vol_normal_threshold = vol_normal_threshold
|
|
self.vol_high_threshold = vol_high_threshold
|
|
self.min_periods = min_periods
|
|
|
|
def detect_market_regime(
|
|
self,
|
|
returns: List[Decimal],
|
|
periods_per_year: int = 252,
|
|
) -> Tuple[MarketRegime, RegimeConfidence]:
|
|
"""Detect market regime from return series.
|
|
|
|
Args:
|
|
returns: List of periodic returns (daily, weekly, etc.)
|
|
periods_per_year: Number of periods in a year (252 for daily)
|
|
|
|
Returns:
|
|
Tuple of (MarketRegime, RegimeConfidence)
|
|
"""
|
|
if len(returns) < self.min_periods:
|
|
return MarketRegime.SIDEWAYS, RegimeConfidence.LOW
|
|
|
|
# Calculate annualized return
|
|
avg_return = sum(returns) / len(returns)
|
|
annualized = avg_return * Decimal(str(periods_per_year))
|
|
|
|
# Calculate confidence based on consistency
|
|
positive_count = sum(1 for r in returns if r > 0)
|
|
win_rate = Decimal(str(positive_count)) / Decimal(str(len(returns)))
|
|
|
|
# Determine regime
|
|
if annualized > self.bull_threshold:
|
|
regime = MarketRegime.BULL
|
|
confidence = self._calculate_confidence(win_rate, Decimal("0.55"))
|
|
elif annualized > self.moderate_bull_threshold:
|
|
regime = MarketRegime.MODERATE_BULL
|
|
confidence = self._calculate_confidence(win_rate, Decimal("0.52"))
|
|
elif annualized < self.bear_threshold:
|
|
regime = MarketRegime.BEAR
|
|
confidence = self._calculate_confidence(
|
|
Decimal("1") - win_rate, Decimal("0.55")
|
|
)
|
|
elif annualized < self.moderate_bear_threshold:
|
|
regime = MarketRegime.MODERATE_BEAR
|
|
confidence = self._calculate_confidence(
|
|
Decimal("1") - win_rate, Decimal("0.52")
|
|
)
|
|
else:
|
|
regime = MarketRegime.SIDEWAYS
|
|
# Sideways confidence based on how close to zero
|
|
deviation = abs(annualized)
|
|
if deviation < Decimal("0.02"):
|
|
confidence = RegimeConfidence.HIGH
|
|
elif deviation < Decimal("0.04"):
|
|
confidence = RegimeConfidence.MEDIUM
|
|
else:
|
|
confidence = RegimeConfidence.LOW
|
|
|
|
return regime, confidence
|
|
|
|
def detect_volatility_regime(
|
|
self,
|
|
returns: List[Decimal],
|
|
periods_per_year: int = 252,
|
|
) -> Tuple[VolatilityRegime, Decimal]:
|
|
"""Detect volatility regime from return series.
|
|
|
|
Args:
|
|
returns: List of periodic returns
|
|
periods_per_year: Number of periods in a year
|
|
|
|
Returns:
|
|
Tuple of (VolatilityRegime, annualized_volatility)
|
|
"""
|
|
if len(returns) < self.min_periods:
|
|
return VolatilityRegime.NORMAL, Decimal("0.15")
|
|
|
|
# Calculate standard deviation of returns
|
|
float_returns = [float(r) for r in returns]
|
|
if len(set(float_returns)) == 1:
|
|
# All returns identical, zero volatility
|
|
return VolatilityRegime.LOW, Decimal("0")
|
|
|
|
period_vol = Decimal(str(statistics.stdev(float_returns)))
|
|
annual_vol = period_vol * Decimal(str(periods_per_year)).sqrt()
|
|
|
|
if annual_vol < self.vol_low_threshold:
|
|
regime = VolatilityRegime.LOW
|
|
elif annual_vol < self.vol_normal_threshold:
|
|
regime = VolatilityRegime.NORMAL
|
|
elif annual_vol < self.vol_high_threshold:
|
|
regime = VolatilityRegime.ELEVATED
|
|
else:
|
|
regime = VolatilityRegime.HIGH
|
|
|
|
return regime, annual_vol
|
|
|
|
def detect_trend_strength(
|
|
self,
|
|
returns: List[Decimal],
|
|
) -> TrendStrength:
|
|
"""Detect strength of trend from returns.
|
|
|
|
Uses autocorrelation and directional consistency.
|
|
|
|
Args:
|
|
returns: List of periodic returns
|
|
|
|
Returns:
|
|
TrendStrength classification
|
|
"""
|
|
if len(returns) < self.min_periods:
|
|
return TrendStrength.NONE
|
|
|
|
# Calculate directional consistency
|
|
positive_count = sum(1 for r in returns if r > 0)
|
|
consistency = abs(
|
|
Decimal(str(positive_count)) / Decimal(str(len(returns))) - Decimal("0.5")
|
|
)
|
|
|
|
# Strong trend: >65% consistent direction
|
|
if consistency > Decimal("0.15"):
|
|
return TrendStrength.STRONG
|
|
elif consistency > Decimal("0.10"):
|
|
return TrendStrength.MODERATE
|
|
elif consistency > Decimal("0.05"):
|
|
return TrendStrength.WEAK
|
|
else:
|
|
return TrendStrength.NONE
|
|
|
|
def _calculate_confidence(
|
|
self,
|
|
rate: Decimal,
|
|
threshold: Decimal,
|
|
) -> RegimeConfidence:
|
|
"""Calculate confidence from win/loss rate.
|
|
|
|
Args:
|
|
rate: Observed win rate
|
|
threshold: Threshold for high confidence
|
|
|
|
Returns:
|
|
RegimeConfidence level
|
|
"""
|
|
if rate > threshold + Decimal("0.10"):
|
|
return RegimeConfidence.HIGH
|
|
elif rate > threshold:
|
|
return RegimeConfidence.MEDIUM
|
|
else:
|
|
return RegimeConfidence.LOW
|
|
|
|
def tag_period(
|
|
self,
|
|
returns: List[Decimal],
|
|
start_date: Optional[date] = None,
|
|
end_date: Optional[date] = None,
|
|
periods_per_year: int = 252,
|
|
) -> RegimeTag:
|
|
"""Create a regime tag for a time period.
|
|
|
|
Args:
|
|
returns: List of returns for the period
|
|
start_date: Period start date
|
|
end_date: Period end date
|
|
periods_per_year: Trading periods per year
|
|
|
|
Returns:
|
|
RegimeTag with detected regimes
|
|
"""
|
|
market_regime, confidence = self.detect_market_regime(
|
|
returns, periods_per_year
|
|
)
|
|
volatility_regime, vol = self.detect_volatility_regime(
|
|
returns, periods_per_year
|
|
)
|
|
trend = self.detect_trend_strength(returns)
|
|
|
|
# Calculate metrics
|
|
if returns:
|
|
avg_return = sum(returns) / len(returns)
|
|
annualized = avg_return * Decimal(str(periods_per_year))
|
|
max_dd = self._calculate_max_drawdown(returns)
|
|
else:
|
|
annualized = Decimal("0")
|
|
max_dd = Decimal("0")
|
|
|
|
return RegimeTag(
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
market_regime=market_regime,
|
|
volatility_regime=volatility_regime,
|
|
trend_strength=trend,
|
|
confidence=confidence,
|
|
annualized_return=annualized,
|
|
volatility=vol,
|
|
max_drawdown=max_dd,
|
|
)
|
|
|
|
def _calculate_max_drawdown(self, returns: List[Decimal]) -> Decimal:
|
|
"""Calculate maximum drawdown from returns.
|
|
|
|
Args:
|
|
returns: List of periodic returns
|
|
|
|
Returns:
|
|
Maximum drawdown as negative decimal
|
|
"""
|
|
if not returns:
|
|
return Decimal("0")
|
|
|
|
cumulative = Decimal("1")
|
|
peak = Decimal("1")
|
|
max_dd = Decimal("0")
|
|
|
|
for ret in returns:
|
|
cumulative *= (Decimal("1") + ret)
|
|
if cumulative > peak:
|
|
peak = cumulative
|
|
drawdown = (cumulative - peak) / peak
|
|
if drawdown < max_dd:
|
|
max_dd = drawdown
|
|
|
|
return max_dd
|
|
|
|
|
|
# ============================================================================
|
|
# RegimeEvaluator Class
|
|
# ============================================================================
|
|
|
|
class RegimeEvaluator:
|
|
"""Evaluates strategy performance across different regimes.
|
|
|
|
Provides comprehensive analysis of how strategies perform in
|
|
different market and volatility conditions.
|
|
|
|
Attributes:
|
|
detector: RegimeDetector for regime classification
|
|
lookback_periods: Periods to look back for regime detection
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
detector: Optional[RegimeDetector] = None,
|
|
lookback_periods: int = 60,
|
|
):
|
|
"""Initialize regime evaluator.
|
|
|
|
Args:
|
|
detector: RegimeDetector instance (creates default if None)
|
|
lookback_periods: Periods for rolling regime detection
|
|
"""
|
|
self.detector = detector or RegimeDetector()
|
|
self.lookback_periods = lookback_periods
|
|
|
|
def evaluate_strategy(
|
|
self,
|
|
strategy_id: str,
|
|
strategy_name: str,
|
|
returns: List[Decimal],
|
|
dates: Optional[List[date]] = None,
|
|
periods_per_year: int = 252,
|
|
benchmark_returns: Optional[List[Decimal]] = None,
|
|
) -> RegimeEvaluationResult:
|
|
"""Evaluate a strategy across all detected regimes.
|
|
|
|
Args:
|
|
strategy_id: Unique strategy identifier
|
|
strategy_name: Human-readable strategy name
|
|
returns: List of strategy returns
|
|
dates: Optional list of dates for each return
|
|
periods_per_year: Periods per year (252 for daily)
|
|
benchmark_returns: Optional benchmark returns for regime detection
|
|
|
|
Returns:
|
|
RegimeEvaluationResult with complete analysis
|
|
"""
|
|
if not returns:
|
|
return RegimeEvaluationResult(
|
|
strategy_id=strategy_id,
|
|
strategy_name=strategy_name,
|
|
)
|
|
|
|
# Use benchmark returns for regime detection if provided
|
|
regime_returns = benchmark_returns if benchmark_returns else returns
|
|
|
|
# Detect regime tags using rolling windows
|
|
regime_tags = self._detect_rolling_regimes(
|
|
regime_returns, dates, periods_per_year
|
|
)
|
|
|
|
# Calculate performance by market regime
|
|
perf_by_market = self._calculate_performance_by_regime(
|
|
returns, dates, regime_tags, "market", periods_per_year
|
|
)
|
|
|
|
# Calculate performance by volatility regime
|
|
perf_by_vol = self._calculate_performance_by_regime(
|
|
returns, dates, regime_tags, "volatility", periods_per_year
|
|
)
|
|
|
|
# Detect transitions
|
|
transitions = self._detect_transitions(regime_tags)
|
|
|
|
# Generate recommendations
|
|
recommendations = self._generate_recommendations(
|
|
perf_by_market, perf_by_vol
|
|
)
|
|
|
|
# Get current regime (most recent)
|
|
current_regime = MarketRegime.SIDEWAYS
|
|
current_vol = VolatilityRegime.NORMAL
|
|
if regime_tags:
|
|
current_regime = regime_tags[-1].market_regime
|
|
current_vol = regime_tags[-1].volatility_regime
|
|
|
|
# Calculate overall scores
|
|
regime_score = self._calculate_regime_score(perf_by_market)
|
|
adaptability = self._calculate_adaptability(transitions, perf_by_market)
|
|
|
|
# Determine date range
|
|
start = dates[0] if dates else None
|
|
end = dates[-1] if dates else None
|
|
|
|
return RegimeEvaluationResult(
|
|
strategy_id=strategy_id,
|
|
strategy_name=strategy_name,
|
|
start_date=start,
|
|
end_date=end,
|
|
current_regime=current_regime,
|
|
current_volatility=current_vol,
|
|
regime_tags=regime_tags,
|
|
performance_by_market_regime=perf_by_market,
|
|
performance_by_volatility=perf_by_vol,
|
|
transitions=transitions,
|
|
recommendations=recommendations,
|
|
overall_regime_score=regime_score,
|
|
regime_adaptability=adaptability,
|
|
)
|
|
|
|
def compare_strategies_by_regime(
|
|
self,
|
|
strategies: List[Tuple[str, str, List[Decimal]]],
|
|
dates: Optional[List[date]] = None,
|
|
periods_per_year: int = 252,
|
|
benchmark_returns: Optional[List[Decimal]] = None,
|
|
) -> Dict[str, RegimeEvaluationResult]:
|
|
"""Compare multiple strategies by regime performance.
|
|
|
|
Args:
|
|
strategies: List of (id, name, returns) tuples
|
|
dates: Optional shared date list
|
|
periods_per_year: Periods per year
|
|
benchmark_returns: Benchmark for regime detection
|
|
|
|
Returns:
|
|
Dict mapping strategy_id to RegimeEvaluationResult
|
|
"""
|
|
results = {}
|
|
for strategy_id, strategy_name, returns in strategies:
|
|
results[strategy_id] = self.evaluate_strategy(
|
|
strategy_id=strategy_id,
|
|
strategy_name=strategy_name,
|
|
returns=returns,
|
|
dates=dates,
|
|
periods_per_year=periods_per_year,
|
|
benchmark_returns=benchmark_returns,
|
|
)
|
|
return results
|
|
|
|
def get_best_strategy_for_regime(
|
|
self,
|
|
evaluations: Dict[str, RegimeEvaluationResult],
|
|
regime: MarketRegime,
|
|
) -> Optional[str]:
|
|
"""Find the best performing strategy for a specific regime.
|
|
|
|
Args:
|
|
evaluations: Dict of strategy evaluations
|
|
regime: The regime to find best strategy for
|
|
|
|
Returns:
|
|
strategy_id of best strategy, or None
|
|
"""
|
|
best_id = None
|
|
best_sharpe = None
|
|
|
|
for strategy_id, result in evaluations.items():
|
|
if regime in result.performance_by_market_regime:
|
|
perf = result.performance_by_market_regime[regime]
|
|
if perf.sharpe_ratio is not None:
|
|
if best_sharpe is None or perf.sharpe_ratio > best_sharpe:
|
|
best_sharpe = perf.sharpe_ratio
|
|
best_id = strategy_id
|
|
|
|
return best_id
|
|
|
|
def _detect_rolling_regimes(
|
|
self,
|
|
returns: List[Decimal],
|
|
dates: Optional[List[date]],
|
|
periods_per_year: int,
|
|
) -> List[RegimeTag]:
|
|
"""Detect regimes using rolling windows.
|
|
|
|
Args:
|
|
returns: Return series
|
|
dates: Optional date list
|
|
periods_per_year: Periods per year
|
|
|
|
Returns:
|
|
List of RegimeTag for each window
|
|
"""
|
|
tags = []
|
|
window_size = self.lookback_periods
|
|
|
|
if len(returns) < window_size:
|
|
# Single tag for entire period
|
|
tag = self.detector.tag_period(
|
|
returns=returns,
|
|
start_date=dates[0] if dates else None,
|
|
end_date=dates[-1] if dates else None,
|
|
periods_per_year=periods_per_year,
|
|
)
|
|
return [tag]
|
|
|
|
# Rolling windows with step = half window
|
|
step = max(1, window_size // 2)
|
|
for i in range(0, len(returns) - window_size + 1, step):
|
|
window_returns = returns[i:i + window_size]
|
|
start = dates[i] if dates else None
|
|
end = dates[i + window_size - 1] if dates else None
|
|
|
|
tag = self.detector.tag_period(
|
|
returns=window_returns,
|
|
start_date=start,
|
|
end_date=end,
|
|
periods_per_year=periods_per_year,
|
|
)
|
|
tags.append(tag)
|
|
|
|
return tags
|
|
|
|
def _calculate_performance_by_regime(
|
|
self,
|
|
returns: List[Decimal],
|
|
dates: Optional[List[date]],
|
|
tags: List[RegimeTag],
|
|
regime_type: str, # "market" or "volatility"
|
|
periods_per_year: int,
|
|
) -> Dict:
|
|
"""Calculate performance breakdown by regime.
|
|
|
|
Args:
|
|
returns: Strategy returns
|
|
dates: Date list
|
|
tags: Regime tags
|
|
regime_type: "market" or "volatility"
|
|
periods_per_year: Periods per year
|
|
|
|
Returns:
|
|
Dict mapping regime to RegimePerformance
|
|
"""
|
|
if regime_type == "market":
|
|
regimes = list(MarketRegime)
|
|
else:
|
|
regimes = list(VolatilityRegime)
|
|
|
|
result = {}
|
|
|
|
for regime in regimes:
|
|
# Collect returns for this regime
|
|
regime_returns = []
|
|
regime_days = 0
|
|
|
|
for i, tag in enumerate(tags):
|
|
if regime_type == "market":
|
|
tag_regime = tag.market_regime
|
|
else:
|
|
tag_regime = tag.volatility_regime
|
|
|
|
if tag_regime == regime:
|
|
# Add returns from this tag's period
|
|
# Approximate: divide returns evenly across tags
|
|
start_idx = i * (self.lookback_periods // 2)
|
|
end_idx = min(
|
|
start_idx + self.lookback_periods,
|
|
len(returns)
|
|
)
|
|
regime_returns.extend(returns[start_idx:end_idx])
|
|
regime_days += end_idx - start_idx
|
|
|
|
if not regime_returns:
|
|
continue
|
|
|
|
# Calculate metrics
|
|
avg_ret = sum(regime_returns) / len(regime_returns)
|
|
total_ret = self._calculate_cumulative_return(regime_returns)
|
|
|
|
# Volatility
|
|
if len(regime_returns) > 1:
|
|
float_rets = [float(r) for r in regime_returns]
|
|
period_vol = Decimal(str(statistics.stdev(float_rets)))
|
|
vol = period_vol * Decimal(str(periods_per_year)).sqrt()
|
|
else:
|
|
vol = Decimal("0")
|
|
|
|
# Win rate
|
|
wins = sum(1 for r in regime_returns if r > 0)
|
|
win_rate = Decimal(str(wins)) / Decimal(str(len(regime_returns)))
|
|
|
|
# Max drawdown
|
|
max_dd = self.detector._calculate_max_drawdown(regime_returns)
|
|
|
|
# Sharpe ratio
|
|
if vol > 0:
|
|
annual_ret = avg_ret * Decimal(str(periods_per_year))
|
|
sharpe = annual_ret / vol
|
|
else:
|
|
sharpe = None
|
|
|
|
# Best/worst
|
|
best = max(regime_returns)
|
|
worst = min(regime_returns)
|
|
|
|
# Consistency (inverse of return std)
|
|
if len(regime_returns) > 1:
|
|
ret_std = Decimal(str(statistics.stdev(float_rets)))
|
|
consistency = max(Decimal("0"), Decimal("1") - ret_std * 10)
|
|
else:
|
|
consistency = Decimal("0.5")
|
|
|
|
result[regime] = RegimePerformance(
|
|
regime=regime,
|
|
period_count=len([t for t in tags if (
|
|
t.market_regime if regime_type == "market"
|
|
else t.volatility_regime
|
|
) == regime]),
|
|
total_days=regime_days,
|
|
avg_return=avg_ret * Decimal(str(periods_per_year)),
|
|
total_return=total_ret,
|
|
volatility=vol,
|
|
avg_drawdown=max_dd,
|
|
worst_drawdown=max_dd,
|
|
win_rate=win_rate,
|
|
sharpe_ratio=sharpe,
|
|
best_period_return=best,
|
|
worst_period_return=worst,
|
|
consistency_score=consistency,
|
|
)
|
|
|
|
return result
|
|
|
|
def _calculate_cumulative_return(
|
|
self,
|
|
returns: List[Decimal],
|
|
) -> Decimal:
|
|
"""Calculate cumulative return from periodic returns.
|
|
|
|
Args:
|
|
returns: List of periodic returns
|
|
|
|
Returns:
|
|
Total cumulative return
|
|
"""
|
|
cumulative = Decimal("1")
|
|
for ret in returns:
|
|
cumulative *= (Decimal("1") + ret)
|
|
return cumulative - Decimal("1")
|
|
|
|
def _detect_transitions(
|
|
self,
|
|
tags: List[RegimeTag],
|
|
) -> List[RegimeTransition]:
|
|
"""Detect regime transitions from tag sequence.
|
|
|
|
Args:
|
|
tags: List of regime tags
|
|
|
|
Returns:
|
|
List of RegimeTransition
|
|
"""
|
|
transitions = []
|
|
|
|
for i in range(1, len(tags)):
|
|
prev_regime = tags[i - 1].market_regime
|
|
curr_regime = tags[i].market_regime
|
|
|
|
if prev_regime != curr_regime:
|
|
transitions.append(RegimeTransition(
|
|
transition_date=tags[i].start_date,
|
|
from_regime=prev_regime,
|
|
to_regime=curr_regime,
|
|
transition_return=tags[i].annualized_return,
|
|
transition_volatility=tags[i].volatility,
|
|
days_in_prior_regime=self.lookback_periods,
|
|
))
|
|
|
|
return transitions
|
|
|
|
def _generate_recommendations(
|
|
self,
|
|
perf_by_market: Dict[MarketRegime, RegimePerformance],
|
|
perf_by_vol: Dict[VolatilityRegime, RegimePerformance],
|
|
) -> Dict[MarketRegime, RegimeRecommendation]:
|
|
"""Generate regime-specific recommendations.
|
|
|
|
Args:
|
|
perf_by_market: Performance by market regime
|
|
perf_by_vol: Performance by volatility regime
|
|
|
|
Returns:
|
|
Dict of recommendations per regime
|
|
"""
|
|
recommendations = {}
|
|
|
|
for regime in MarketRegime:
|
|
rec = self._create_recommendation_for_regime(
|
|
regime, perf_by_market.get(regime)
|
|
)
|
|
recommendations[regime] = rec
|
|
|
|
return recommendations
|
|
|
|
def _create_recommendation_for_regime(
|
|
self,
|
|
regime: MarketRegime,
|
|
perf: Optional[RegimePerformance],
|
|
) -> RegimeRecommendation:
|
|
"""Create recommendation for a specific regime.
|
|
|
|
Args:
|
|
regime: The market regime
|
|
perf: Performance in this regime
|
|
|
|
Returns:
|
|
RegimeRecommendation
|
|
"""
|
|
rec = RegimeRecommendation(regime=regime)
|
|
|
|
if regime == MarketRegime.BULL:
|
|
rec.allocation_adjustment = Decimal("0.2")
|
|
rec.position_sizing = Decimal("1.2")
|
|
rec.strategy_notes = [
|
|
"Increase equity exposure",
|
|
"Consider momentum strategies",
|
|
"Reduce cash holdings",
|
|
]
|
|
rec.opportunities = [
|
|
"Growth stocks outperform",
|
|
"Risk-on positioning favored",
|
|
]
|
|
|
|
elif regime == MarketRegime.MODERATE_BULL:
|
|
rec.allocation_adjustment = Decimal("0.1")
|
|
rec.position_sizing = Decimal("1.1")
|
|
rec.strategy_notes = [
|
|
"Maintain balanced exposure",
|
|
"Focus on quality growth",
|
|
]
|
|
|
|
elif regime == MarketRegime.SIDEWAYS:
|
|
rec.allocation_adjustment = Decimal("0")
|
|
rec.position_sizing = Decimal("1.0")
|
|
rec.strategy_notes = [
|
|
"Range-trading strategies may work",
|
|
"Consider covered calls for income",
|
|
"Reduce directional bets",
|
|
]
|
|
rec.cautions = [
|
|
"Low returns expected",
|
|
"Transaction costs eat into profits",
|
|
]
|
|
|
|
elif regime == MarketRegime.MODERATE_BEAR:
|
|
rec.allocation_adjustment = Decimal("-0.1")
|
|
rec.risk_adjustment = Decimal("-0.2")
|
|
rec.position_sizing = Decimal("0.9")
|
|
rec.strategy_notes = [
|
|
"Increase defensive allocation",
|
|
"Add quality names on dips",
|
|
]
|
|
rec.cautions = [
|
|
"Trend may accelerate lower",
|
|
]
|
|
|
|
elif regime == MarketRegime.BEAR:
|
|
rec.allocation_adjustment = Decimal("-0.3")
|
|
rec.risk_adjustment = Decimal("-0.4")
|
|
rec.position_sizing = Decimal("0.7")
|
|
rec.strategy_notes = [
|
|
"Maximize defensive allocation",
|
|
"Consider hedging strategies",
|
|
"Hold cash for opportunities",
|
|
]
|
|
rec.cautions = [
|
|
"Capital preservation is priority",
|
|
"Avoid catching falling knives",
|
|
]
|
|
rec.opportunities = [
|
|
"Build watchlist for recovery",
|
|
"Quality assets on sale",
|
|
]
|
|
|
|
# Adjust based on actual performance
|
|
if perf:
|
|
if perf.sharpe_ratio and perf.sharpe_ratio < Decimal("0"):
|
|
rec.cautions.append(
|
|
f"Strategy underperforms in {regime.value} markets"
|
|
)
|
|
rec.position_sizing = max(
|
|
Decimal("0.5"),
|
|
rec.position_sizing - Decimal("0.2")
|
|
)
|
|
|
|
if perf.win_rate < Decimal("0.4"):
|
|
rec.cautions.append(
|
|
f"Low win rate ({float(perf.win_rate):.1%}) in this regime"
|
|
)
|
|
|
|
if perf.worst_drawdown < Decimal("-0.15"):
|
|
rec.cautions.append(
|
|
f"Large drawdowns ({float(perf.worst_drawdown):.1%}) observed"
|
|
)
|
|
|
|
return rec
|
|
|
|
def _calculate_regime_score(
|
|
self,
|
|
perf_by_market: Dict[MarketRegime, RegimePerformance],
|
|
) -> Decimal:
|
|
"""Calculate overall regime handling score.
|
|
|
|
Considers performance across all regimes weighted by severity.
|
|
|
|
Args:
|
|
perf_by_market: Performance by market regime
|
|
|
|
Returns:
|
|
Score from 0 to 100
|
|
"""
|
|
if not perf_by_market:
|
|
return Decimal("50")
|
|
|
|
# Weight regimes by difficulty
|
|
weights = {
|
|
MarketRegime.BULL: Decimal("1.0"),
|
|
MarketRegime.MODERATE_BULL: Decimal("1.0"),
|
|
MarketRegime.SIDEWAYS: Decimal("1.2"),
|
|
MarketRegime.MODERATE_BEAR: Decimal("1.5"),
|
|
MarketRegime.BEAR: Decimal("2.0"),
|
|
}
|
|
|
|
total_weight = Decimal("0")
|
|
weighted_score = Decimal("0")
|
|
|
|
for regime, perf in perf_by_market.items():
|
|
weight = weights.get(regime, Decimal("1"))
|
|
|
|
# Score based on Sharpe
|
|
if perf.sharpe_ratio is not None:
|
|
# Convert Sharpe to 0-100 score
|
|
# Sharpe of 2 = 100, Sharpe of -1 = 0
|
|
sharpe_score = min(
|
|
Decimal("100"),
|
|
max(
|
|
Decimal("0"),
|
|
(perf.sharpe_ratio + Decimal("1")) * Decimal("33.33")
|
|
)
|
|
)
|
|
else:
|
|
sharpe_score = Decimal("50")
|
|
|
|
weighted_score += weight * sharpe_score
|
|
total_weight += weight
|
|
|
|
if total_weight > 0:
|
|
return weighted_score / total_weight
|
|
return Decimal("50")
|
|
|
|
def _calculate_adaptability(
|
|
self,
|
|
transitions: List[RegimeTransition],
|
|
perf_by_market: Dict[MarketRegime, RegimePerformance],
|
|
) -> Decimal:
|
|
"""Calculate strategy adaptability score.
|
|
|
|
Measures how well strategy handles regime changes.
|
|
|
|
Args:
|
|
transitions: List of regime transitions
|
|
perf_by_market: Performance by regime
|
|
|
|
Returns:
|
|
Score from 0 to 100
|
|
"""
|
|
if not transitions or not perf_by_market:
|
|
return Decimal("50")
|
|
|
|
# Check performance variance across regimes
|
|
sharpes = [
|
|
p.sharpe_ratio for p in perf_by_market.values()
|
|
if p.sharpe_ratio is not None
|
|
]
|
|
|
|
if len(sharpes) < 2:
|
|
return Decimal("50")
|
|
|
|
float_sharpes = [float(s) for s in sharpes]
|
|
sharpe_std = Decimal(str(statistics.stdev(float_sharpes)))
|
|
|
|
# Lower std = more adaptive
|
|
# Std of 0 = 100, Std of 2 = 0
|
|
adaptability = max(
|
|
Decimal("0"),
|
|
Decimal("100") - sharpe_std * Decimal("50")
|
|
)
|
|
|
|
return adaptability
|
|
|
|
def generate_regime_report(
|
|
self,
|
|
result: RegimeEvaluationResult,
|
|
) -> str:
|
|
"""Generate a formatted regime evaluation report.
|
|
|
|
Args:
|
|
result: RegimeEvaluationResult to report on
|
|
|
|
Returns:
|
|
Formatted markdown report
|
|
"""
|
|
lines = [
|
|
"# Regime Evaluation Report",
|
|
f"**Strategy**: {result.strategy_name}",
|
|
f"**Period**: {result.start_date} to {result.end_date}",
|
|
"",
|
|
"## Current Conditions",
|
|
f"- **Market Regime**: {result.current_regime.value}",
|
|
f"- **Volatility**: {result.current_volatility.value}",
|
|
f"- **Overall Score**: {float(result.overall_regime_score):.1f}/100",
|
|
f"- **Adaptability**: {float(result.regime_adaptability):.1f}/100",
|
|
"",
|
|
"## Performance by Market Regime",
|
|
"",
|
|
"| Regime | Periods | Avg Return | Sharpe | Win Rate | Max DD |",
|
|
"|--------|---------|------------|--------|----------|--------|",
|
|
]
|
|
|
|
for regime in MarketRegime:
|
|
if regime in result.performance_by_market_regime:
|
|
perf = result.performance_by_market_regime[regime]
|
|
sharpe = f"{float(perf.sharpe_ratio):.2f}" if perf.sharpe_ratio else "N/A"
|
|
lines.append(
|
|
f"| {regime.value} | {perf.period_count} | "
|
|
f"{float(perf.avg_return):.1%} | {sharpe} | "
|
|
f"{float(perf.win_rate):.1%} | {float(perf.worst_drawdown):.1%} |"
|
|
)
|
|
|
|
lines.extend([
|
|
"",
|
|
"## Performance by Volatility",
|
|
"",
|
|
"| Vol Regime | Periods | Avg Return | Sharpe | Win Rate |",
|
|
"|------------|---------|------------|--------|----------|",
|
|
])
|
|
|
|
for regime in VolatilityRegime:
|
|
if regime in result.performance_by_volatility:
|
|
perf = result.performance_by_volatility[regime]
|
|
sharpe = f"{float(perf.sharpe_ratio):.2f}" if perf.sharpe_ratio else "N/A"
|
|
lines.append(
|
|
f"| {regime.value} | {perf.period_count} | "
|
|
f"{float(perf.avg_return):.1%} | {sharpe} | "
|
|
f"{float(perf.win_rate):.1%} |"
|
|
)
|
|
|
|
if result.transitions:
|
|
lines.extend([
|
|
"",
|
|
"## Regime Transitions",
|
|
f"Total transitions detected: {len(result.transitions)}",
|
|
"",
|
|
])
|
|
for t in result.transitions[:5]:
|
|
lines.append(
|
|
f"- {t.transition_date}: "
|
|
f"{t.from_regime.value if t.from_regime else 'N/A'} → "
|
|
f"{t.to_regime.value if t.to_regime else 'N/A'}"
|
|
)
|
|
|
|
# Add recommendation for current regime
|
|
if result.current_regime in result.recommendations:
|
|
rec = result.recommendations[result.current_regime]
|
|
lines.extend([
|
|
"",
|
|
f"## Recommendations for {result.current_regime.value.title()} Market",
|
|
"",
|
|
"### Strategy Notes",
|
|
])
|
|
for note in rec.strategy_notes:
|
|
lines.append(f"- {note}")
|
|
|
|
if rec.cautions:
|
|
lines.append("")
|
|
lines.append("### Cautions")
|
|
for caution in rec.cautions:
|
|
lines.append(f"- ⚠️ {caution}")
|
|
|
|
if rec.opportunities:
|
|
lines.append("")
|
|
lines.append("### Opportunities")
|
|
for opp in rec.opportunities:
|
|
lines.append(f"- ✓ {opp}")
|
|
|
|
return "\n".join(lines)
|