diff --git a/tests/unit/agents/test_position_sizing_manager.py b/tests/unit/agents/test_position_sizing_manager.py new file mode 100644 index 00000000..51861ae5 --- /dev/null +++ b/tests/unit/agents/test_position_sizing_manager.py @@ -0,0 +1,936 @@ +"""Tests for Position Sizing Manager. + +Issue #16: [AGENT-15] Position Sizing Manager - Kelly, risk parity, ATR + +These tests define the logic locally to avoid langchain import issues. +""" + +import pytest +import pandas as pd +import numpy as np +from datetime import datetime, timedelta +from unittest.mock import Mock, MagicMock +from enum import Enum +from dataclasses import dataclass + +pytestmark = pytest.mark.unit + + +# ============================================================================ +# Local Definitions (matching position_sizing_manager.py) +# ============================================================================ + +class SizingMethod(str, Enum): + """Position sizing method types.""" + KELLY = "kelly" + HALF_KELLY = "half_kelly" + QUARTER_KELLY = "quarter_kelly" + RISK_PARITY = "risk_parity" + ATR_BASED = "atr_based" + FIXED_FRACTIONAL = "fixed_fractional" + EQUAL_WEIGHT = "equal_weight" + VOLATILITY_TARGET = "volatility_target" + + +class RiskLevel(str, Enum): + """Risk tolerance levels.""" + CONSERVATIVE = "conservative" + MODERATE = "moderate" + AGGRESSIVE = "aggressive" + + +@dataclass +class PositionSizeResult: + """Result of position size calculation.""" + method: SizingMethod + position_size: float + dollar_amount: float + shares: int + risk_per_trade: float + rationale: str + + +# ============================================================================ +# Helper Functions (matching position_sizing_manager.py) +# ============================================================================ + +def _calculate_kelly_fraction( + win_rate: float, + avg_win: float, + avg_loss: float +) -> float: + """Calculate Kelly Criterion fraction.""" + if avg_loss == 0 or win_rate <= 0 or win_rate >= 1: + return 0.0 + + win_loss_ratio = abs(avg_win / avg_loss) + kelly = win_rate - ((1 - win_rate) / win_loss_ratio) + return max(0.0, min(kelly, 1.0)) + + +def _calculate_half_kelly( + win_rate: float, + avg_win: float, + avg_loss: float +) -> float: + """Calculate half Kelly for reduced volatility.""" + return _calculate_kelly_fraction(win_rate, avg_win, avg_loss) / 2 + + +def _calculate_quarter_kelly( + win_rate: float, + avg_win: float, + avg_loss: float +) -> float: + """Calculate quarter Kelly for conservative sizing.""" + return _calculate_kelly_fraction(win_rate, avg_win, avg_loss) / 4 + + +def _calculate_atr(prices: pd.DataFrame, period: int = 14) -> float: + """Calculate Average True Range (ATR).""" + if len(prices) < period + 1: + return 0.0 + + high = prices['high'] if 'high' in prices.columns else prices['High'] + low = prices['low'] if 'low' in prices.columns else prices['Low'] + close = prices['close'] if 'close' in prices.columns else prices['Close'] + + tr1 = high - low + tr2 = abs(high - close.shift(1)) + tr3 = abs(low - close.shift(1)) + + true_range = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + atr = true_range.rolling(window=period).mean() + + return float(atr.iloc[-1]) if not pd.isna(atr.iloc[-1]) else 0.0 + + +def _calculate_position_from_atr( + account_value: float, + risk_per_trade: float, + atr: float, + atr_multiplier: float = 2.0, + current_price: float = 1.0 +) -> tuple: + """Calculate position size based on ATR.""" + if atr == 0 or current_price == 0: + return 0.0, 0 + + dollar_risk = account_value * risk_per_trade + stop_distance = atr * atr_multiplier + + shares = int(dollar_risk / stop_distance) + position_value = shares * current_price + position_fraction = position_value / account_value if account_value > 0 else 0 + + return position_fraction, shares + + +def _calculate_volatility(returns: pd.Series, annualize: bool = True) -> float: + """Calculate volatility (standard deviation of returns).""" + if len(returns) < 2: + return 0.0 + + vol = returns.std() + + if annualize: + vol = vol * np.sqrt(252) + + return float(vol) + + +def _calculate_risk_parity_weights( + volatilities: dict, + target_vol: float = 0.15 +) -> dict: + """Calculate Risk Parity weights.""" + if not volatilities or all(v == 0 for v in volatilities.values()): + n = len(volatilities) + return {k: 1/n for k in volatilities} if n > 0 else {} + + inv_vols = {k: 1/v if v > 0 else 0 for k, v in volatilities.items()} + total_inv_vol = sum(inv_vols.values()) + + if total_inv_vol == 0: + n = len(volatilities) + return {k: 1/n for k in volatilities} + + weights = {k: v/total_inv_vol for k, v in inv_vols.items()} + + port_vol = np.sqrt(sum((w**2) * (volatilities[k]**2) for k, w in weights.items())) + + if port_vol > 0: + scale = target_vol / port_vol + weights = {k: min(w * scale, 1.0) for k, w in weights.items()} + + return weights + + +def _calculate_fixed_fractional( + account_value: float, + risk_fraction: float, + stop_loss_pct: float, + current_price: float +) -> tuple: + """Calculate fixed fractional position size.""" + if stop_loss_pct == 0 or current_price == 0: + return 0.0, 0 + + dollar_risk = account_value * risk_fraction + position_value = dollar_risk / stop_loss_pct + shares = int(position_value / current_price) + position_fraction = (shares * current_price) / account_value if account_value > 0 else 0 + + return position_fraction, shares + + +def _calculate_volatility_target_size( + account_value: float, + target_vol: float, + asset_vol: float, + current_price: float +) -> tuple: + """Calculate position size to achieve target volatility.""" + if asset_vol == 0 or current_price == 0: + return 0.0, 0 + + weight = target_vol / asset_vol + weight = min(weight, 1.0) + + position_value = account_value * weight + shares = int(position_value / current_price) + position_fraction = (shares * current_price) / account_value if account_value > 0 else 0 + + return position_fraction, shares + + +def _apply_constraints( + position_fraction: float, + max_position: float = 0.25, + max_portfolio_risk: float = 0.02, + current_portfolio_risk: float = 0.0 +) -> float: + """Apply position size constraints.""" + constrained = min(position_fraction, max_position) + + if current_portfolio_risk + constrained > max_portfolio_risk * 10: + constrained = max(0, max_portfolio_risk * 10 - current_portfolio_risk) + + return constrained + + +def _interpret_sizing_result( + method: SizingMethod, + position_fraction: float, + kelly_fraction: float = 0 +) -> str: + """Generate interpretation of sizing result.""" + interpretations = [] + + if method in [SizingMethod.KELLY, SizingMethod.HALF_KELLY, SizingMethod.QUARTER_KELLY]: + if kelly_fraction < 0: + interpretations.append("Negative Kelly suggests no edge - avoid trade") + elif kelly_fraction > 0.5: + interpretations.append("Large Kelly suggests strong edge but high variance") + elif kelly_fraction > 0.25: + interpretations.append("Moderate Kelly suggests reasonable edge") + else: + interpretations.append("Small Kelly suggests marginal edge - size conservatively") + + if position_fraction > 0.2: + interpretations.append("Large position - consider splitting into tranches") + elif position_fraction < 0.01: + interpretations.append("Very small position - may not be worth transaction costs") + + return "; ".join(interpretations) if interpretations else "Standard position size" + + +def _get_risk_parameters(risk_level: RiskLevel) -> dict: + """Get risk parameters based on risk tolerance.""" + params = { + RiskLevel.CONSERVATIVE: { + "max_position": 0.10, + "risk_per_trade": 0.01, + "kelly_fraction_used": 0.25, + "target_vol": 0.10 + }, + RiskLevel.MODERATE: { + "max_position": 0.20, + "risk_per_trade": 0.02, + "kelly_fraction_used": 0.50, + "target_vol": 0.15 + }, + RiskLevel.AGGRESSIVE: { + "max_position": 0.30, + "risk_per_trade": 0.03, + "kelly_fraction_used": 1.0, + "target_vol": 0.20 + } + } + return params.get(risk_level, params[RiskLevel.MODERATE]) + + +# ============================================================================ +# Test Fixtures +# ============================================================================ + +@pytest.fixture +def sample_price_data(): + """Generate sample OHLCV data for testing.""" + np.random.seed(42) + n = 100 + dates = pd.date_range(start='2024-01-01', periods=n, freq='D') + + # Generate realistic price series + base_price = 100 + returns = np.random.normal(0.001, 0.02, n) + close = pd.Series(base_price * (1 + returns).cumprod()) + + # Generate OHLCV + high = close * (1 + np.abs(np.random.normal(0, 0.01, n))) + low = close * (1 - np.abs(np.random.normal(0, 0.01, n))) + open_price = close.shift(1).fillna(base_price) + volume = np.random.randint(100000, 1000000, n) + + df = pd.DataFrame({ + 'date': dates, + 'open': open_price, + 'high': high, + 'low': low, + 'close': close, + 'volume': volume + }) + return df + + +@pytest.fixture +def volatile_price_data(): + """Generate high volatility price data.""" + np.random.seed(43) + n = 100 + dates = pd.date_range(start='2024-01-01', periods=n, freq='D') + + base_price = 100 + returns = np.random.normal(0.001, 0.05, n) # 5% daily volatility + close = base_price * (1 + returns).cumprod() + + high = close * (1 + np.abs(np.random.normal(0, 0.02, n))) + low = close * (1 - np.abs(np.random.normal(0, 0.02, n))) + + df = pd.DataFrame({ + 'date': dates, + 'high': high, + 'low': low, + 'close': close + }) + return df + + +@pytest.fixture +def account_params(): + """Standard account parameters for testing.""" + return { + "account_value": 100000, + "current_price": 150, + "win_rate": 0.55, + "avg_win": 0.05, + "avg_loss": -0.03 + } + + +# ============================================================================ +# Test Classes +# ============================================================================ + +class TestKellyCalculation: + """Tests for Kelly Criterion calculation.""" + + def test_positive_kelly(self, account_params): + """Test positive Kelly with edge.""" + kelly = _calculate_kelly_fraction( + win_rate=0.55, + avg_win=0.05, + avg_loss=0.03 + ) + assert kelly > 0 + # Kelly = 0.55 - (0.45 / 1.667) = 0.55 - 0.27 = 0.28 + assert abs(kelly - 0.28) < 0.05 + + def test_negative_kelly(self): + """Test negative Kelly with no edge.""" + kelly = _calculate_kelly_fraction( + win_rate=0.40, + avg_win=0.03, + avg_loss=0.05 + ) + # When result is negative, should return 0 + assert kelly == 0.0 + + def test_zero_win_rate(self): + """Test Kelly with zero win rate.""" + kelly = _calculate_kelly_fraction( + win_rate=0.0, + avg_win=0.05, + avg_loss=0.03 + ) + assert kelly == 0.0 + + def test_zero_loss(self): + """Test Kelly with zero average loss.""" + kelly = _calculate_kelly_fraction( + win_rate=0.55, + avg_win=0.05, + avg_loss=0.0 + ) + assert kelly == 0.0 + + def test_perfect_win_rate(self): + """Test Kelly with 100% win rate.""" + kelly = _calculate_kelly_fraction( + win_rate=1.0, + avg_win=0.05, + avg_loss=0.03 + ) + # Should return 0 for invalid input (win_rate >= 1) + assert kelly == 0.0 + + def test_half_kelly(self): + """Test half Kelly calculation.""" + full_kelly = _calculate_kelly_fraction(0.55, 0.05, 0.03) + half_kelly = _calculate_half_kelly(0.55, 0.05, 0.03) + assert abs(half_kelly - full_kelly / 2) < 0.001 + + def test_quarter_kelly(self): + """Test quarter Kelly calculation.""" + full_kelly = _calculate_kelly_fraction(0.55, 0.05, 0.03) + quarter_kelly = _calculate_quarter_kelly(0.55, 0.05, 0.03) + assert abs(quarter_kelly - full_kelly / 4) < 0.001 + + def test_kelly_capped_at_one(self): + """Test Kelly is capped at 100%.""" + # Very high edge scenario + kelly = _calculate_kelly_fraction( + win_rate=0.90, + avg_win=0.20, + avg_loss=0.02 + ) + assert kelly <= 1.0 + + +class TestATRCalculation: + """Tests for ATR calculation.""" + + def test_atr_calculation(self, sample_price_data): + """Test ATR calculation produces valid result.""" + atr = _calculate_atr(sample_price_data, period=14) + assert atr > 0 + # ATR should be a reasonable percentage of price + current_price = sample_price_data['close'].iloc[-1] + assert atr < current_price * 0.2 # Less than 20% of price + + def test_atr_insufficient_data(self): + """Test ATR with insufficient data.""" + short_data = pd.DataFrame({ + 'high': [101, 102], + 'low': [99, 98], + 'close': [100, 101] + }) + atr = _calculate_atr(short_data, period=14) + assert atr == 0.0 + + def test_atr_with_volatile_data(self, volatile_price_data): + """Test ATR is higher for volatile data.""" + atr_volatile = _calculate_atr(volatile_price_data, period=14) + assert atr_volatile > 0 + # Volatile data should have higher ATR + + def test_atr_different_periods(self, sample_price_data): + """Test ATR with different periods.""" + atr_14 = _calculate_atr(sample_price_data, period=14) + atr_7 = _calculate_atr(sample_price_data, period=7) + # Both should be positive + assert atr_14 > 0 + assert atr_7 > 0 + + +class TestATRPositionSizing: + """Tests for ATR-based position sizing.""" + + def test_atr_position_calculation(self): + """Test ATR position sizing calculation.""" + fraction, shares = _calculate_position_from_atr( + account_value=100000, + risk_per_trade=0.02, + atr=2.0, + atr_multiplier=2.0, + current_price=100 + ) + # Risk = $2000, Stop = $4, Shares = 500 + assert shares == 500 + # Position value = 500 * 100 = 50000, fraction = 0.5 + assert abs(fraction - 0.5) < 0.01 + + def test_atr_position_zero_atr(self): + """Test ATR position with zero ATR.""" + fraction, shares = _calculate_position_from_atr( + account_value=100000, + risk_per_trade=0.02, + atr=0.0, + atr_multiplier=2.0, + current_price=100 + ) + assert fraction == 0.0 + assert shares == 0 + + def test_atr_position_zero_price(self): + """Test ATR position with zero price.""" + fraction, shares = _calculate_position_from_atr( + account_value=100000, + risk_per_trade=0.02, + atr=2.0, + atr_multiplier=2.0, + current_price=0 + ) + assert fraction == 0.0 + assert shares == 0 + + +class TestVolatilityCalculation: + """Tests for volatility calculation.""" + + def test_volatility_calculation(self, sample_price_data): + """Test volatility calculation.""" + returns = sample_price_data['close'].pct_change().dropna() + vol = _calculate_volatility(returns, annualize=True) + assert vol > 0 + # Should be in reasonable range (10-50% annual) + assert vol < 1.0 + + def test_volatility_daily_vs_annual(self, sample_price_data): + """Test annualized vs daily volatility.""" + returns = sample_price_data['close'].pct_change().dropna() + vol_annual = _calculate_volatility(returns, annualize=True) + vol_daily = _calculate_volatility(returns, annualize=False) + # Annual should be ~16x daily (sqrt(252)) + assert vol_annual > vol_daily * 10 + assert vol_annual < vol_daily * 20 + + def test_volatility_insufficient_data(self): + """Test volatility with insufficient data.""" + returns = pd.Series([0.01]) + vol = _calculate_volatility(returns) + assert vol == 0.0 + + +class TestRiskParityWeights: + """Tests for Risk Parity weight calculation.""" + + def test_equal_volatility_weights(self): + """Test Risk Parity with equal volatilities gives equal weights.""" + volatilities = {"A": 0.20, "B": 0.20, "C": 0.20} + weights = _calculate_risk_parity_weights(volatilities) + # Should be approximately equal + for w in weights.values(): + assert abs(w - 1/3) < 0.1 + + def test_different_volatility_weights(self): + """Test Risk Parity with different volatilities.""" + volatilities = {"A": 0.10, "B": 0.20, "C": 0.40} + weights = _calculate_risk_parity_weights(volatilities) + # Lower vol should get higher weight + assert weights["A"] > weights["B"] > weights["C"] + + def test_empty_volatilities(self): + """Test Risk Parity with empty volatilities.""" + weights = _calculate_risk_parity_weights({}) + assert weights == {} + + def test_zero_volatilities(self): + """Test Risk Parity with all zero volatilities.""" + volatilities = {"A": 0.0, "B": 0.0} + weights = _calculate_risk_parity_weights(volatilities) + # Should return equal weights as fallback + for w in weights.values(): + assert abs(w - 0.5) < 0.01 + + def test_single_asset(self): + """Test Risk Parity with single asset.""" + volatilities = {"A": 0.15} + weights = _calculate_risk_parity_weights(volatilities) + assert weights["A"] > 0 + + +class TestFixedFractional: + """Tests for fixed fractional position sizing.""" + + def test_fixed_fractional_calculation(self): + """Test fixed fractional position size.""" + fraction, shares = _calculate_fixed_fractional( + account_value=100000, + risk_fraction=0.02, + stop_loss_pct=0.05, + current_price=100 + ) + # Risk = $2000, Stop = 5%, Position = $40000 + # Shares = 400, Fraction = 0.4 + assert shares == 400 + assert abs(fraction - 0.4) < 0.01 + + def test_fixed_fractional_zero_stop(self): + """Test fixed fractional with zero stop loss.""" + fraction, shares = _calculate_fixed_fractional( + account_value=100000, + risk_fraction=0.02, + stop_loss_pct=0.0, + current_price=100 + ) + assert fraction == 0.0 + assert shares == 0 + + def test_fixed_fractional_small_risk(self): + """Test fixed fractional with small risk.""" + fraction, shares = _calculate_fixed_fractional( + account_value=100000, + risk_fraction=0.005, # 0.5% + stop_loss_pct=0.10, + current_price=100 + ) + # Risk = $500, Stop = 10%, Position = $5000 + assert shares == 50 + + +class TestVolatilityTargetSizing: + """Tests for volatility target position sizing.""" + + def test_volatility_target_calculation(self): + """Test volatility target position size.""" + fraction, shares = _calculate_volatility_target_size( + account_value=100000, + target_vol=0.15, + asset_vol=0.30, + current_price=100 + ) + # Weight = 0.15/0.30 = 0.5 + assert abs(fraction - 0.5) < 0.01 + assert shares == 500 + + def test_volatility_target_low_vol_asset(self): + """Test volatility target with low vol asset.""" + fraction, shares = _calculate_volatility_target_size( + account_value=100000, + target_vol=0.15, + asset_vol=0.10, + current_price=100 + ) + # Weight = 0.15/0.10 = 1.5, capped at 1.0 + assert fraction <= 1.0 + + def test_volatility_target_zero_vol(self): + """Test volatility target with zero asset vol.""" + fraction, shares = _calculate_volatility_target_size( + account_value=100000, + target_vol=0.15, + asset_vol=0.0, + current_price=100 + ) + assert fraction == 0.0 + assert shares == 0 + + +class TestPositionConstraints: + """Tests for position size constraints.""" + + def test_max_position_constraint(self): + """Test maximum position constraint.""" + constrained = _apply_constraints( + position_fraction=0.40, + max_position=0.25, + max_portfolio_risk=0.05 # 0.05 * 10 = 0.5, so won't interfere + ) + assert constrained == 0.25 + + def test_no_constraint_needed(self): + """Test when no constraint is needed.""" + constrained = _apply_constraints( + position_fraction=0.15, + max_position=0.25 + ) + assert constrained == 0.15 + + def test_zero_position(self): + """Test zero position.""" + constrained = _apply_constraints( + position_fraction=0.0, + max_position=0.25 + ) + assert constrained == 0.0 + + +class TestSizingInterpretation: + """Tests for sizing result interpretation.""" + + def test_kelly_interpretation_negative(self): + """Test Kelly interpretation for negative Kelly.""" + result = _interpret_sizing_result( + SizingMethod.KELLY, + position_fraction=0.0, + kelly_fraction=-0.1 + ) + assert "Negative Kelly" in result + assert "avoid trade" in result + + def test_kelly_interpretation_large(self): + """Test Kelly interpretation for large Kelly.""" + result = _interpret_sizing_result( + SizingMethod.KELLY, + position_fraction=0.4, + kelly_fraction=0.6 + ) + assert "Large Kelly" in result + + def test_large_position_interpretation(self): + """Test interpretation for large position.""" + result = _interpret_sizing_result( + SizingMethod.ATR_BASED, + position_fraction=0.25, + kelly_fraction=0 + ) + assert "Large position" in result + + def test_small_position_interpretation(self): + """Test interpretation for very small position.""" + result = _interpret_sizing_result( + SizingMethod.ATR_BASED, + position_fraction=0.005, + kelly_fraction=0 + ) + assert "Very small position" in result + + +class TestRiskParameters: + """Tests for risk parameter configuration.""" + + def test_conservative_parameters(self): + """Test conservative risk parameters.""" + params = _get_risk_parameters(RiskLevel.CONSERVATIVE) + assert params["max_position"] == 0.10 + assert params["risk_per_trade"] == 0.01 + assert params["kelly_fraction_used"] == 0.25 + assert params["target_vol"] == 0.10 + + def test_moderate_parameters(self): + """Test moderate risk parameters.""" + params = _get_risk_parameters(RiskLevel.MODERATE) + assert params["max_position"] == 0.20 + assert params["risk_per_trade"] == 0.02 + assert params["kelly_fraction_used"] == 0.50 + assert params["target_vol"] == 0.15 + + def test_aggressive_parameters(self): + """Test aggressive risk parameters.""" + params = _get_risk_parameters(RiskLevel.AGGRESSIVE) + assert params["max_position"] == 0.30 + assert params["risk_per_trade"] == 0.03 + assert params["kelly_fraction_used"] == 1.0 + assert params["target_vol"] == 0.20 + + +class TestEnumValues: + """Tests for enum value consistency.""" + + def test_sizing_method_values(self): + """Test sizing method enum values.""" + assert SizingMethod.KELLY.value == "kelly" + assert SizingMethod.HALF_KELLY.value == "half_kelly" + assert SizingMethod.ATR_BASED.value == "atr_based" + assert SizingMethod.RISK_PARITY.value == "risk_parity" + + def test_risk_level_values(self): + """Test risk level enum values.""" + assert RiskLevel.CONSERVATIVE.value == "conservative" + assert RiskLevel.MODERATE.value == "moderate" + assert RiskLevel.AGGRESSIVE.value == "aggressive" + + +class TestEdgeCases: + """Tests for edge cases and error handling.""" + + def test_very_large_account(self): + """Test with very large account value.""" + fraction, shares = _calculate_position_from_atr( + account_value=10000000, # $10M + risk_per_trade=0.01, + atr=5.0, + atr_multiplier=2.0, + current_price=200 + ) + assert shares > 0 + assert fraction > 0 + + def test_very_small_account(self): + """Test with very small account value.""" + fraction, shares = _calculate_position_from_atr( + account_value=1000, # $1K + risk_per_trade=0.02, + atr=2.0, + atr_multiplier=2.0, + current_price=100 + ) + assert shares >= 0 # May round to 0 for small accounts + + def test_high_price_stock(self): + """Test with high priced stock.""" + fraction, shares = _calculate_fixed_fractional( + account_value=100000, + risk_fraction=0.02, + stop_loss_pct=0.05, + current_price=5000 # High price like BRK.A + ) + # May get very few shares + assert shares >= 0 + + def test_penny_stock(self): + """Test with penny stock.""" + fraction, shares = _calculate_fixed_fractional( + account_value=100000, + risk_fraction=0.02, + stop_loss_pct=0.10, + current_price=0.50 + ) + # Should get many shares + assert shares > 1000 + + +class TestIntegration: + """Integration tests for combined functionality.""" + + def test_full_kelly_workflow(self, account_params): + """Test full Kelly sizing workflow.""" + # Calculate Kelly + kelly = _calculate_kelly_fraction( + account_params["win_rate"], + account_params["avg_win"], + abs(account_params["avg_loss"]) + ) + + # Apply half Kelly + half_kelly = kelly / 2 + + # Calculate position + position_value = account_params["account_value"] * half_kelly + shares = int(position_value / account_params["current_price"]) + + # Apply constraints + constrained = _apply_constraints(half_kelly, max_position=0.20) + + # Interpret + interpretation = _interpret_sizing_result( + SizingMethod.HALF_KELLY, + constrained, + kelly + ) + + assert kelly > 0 + assert half_kelly < kelly + assert constrained <= 0.20 + assert len(interpretation) > 0 + + def test_atr_workflow(self, sample_price_data): + """Test full ATR sizing workflow.""" + # Calculate ATR + atr = _calculate_atr(sample_price_data, period=14) + + # Get current price + current_price = sample_price_data['close'].iloc[-1] + + # Calculate position + fraction, shares = _calculate_position_from_atr( + account_value=100000, + risk_per_trade=0.02, + atr=atr, + atr_multiplier=2.0, + current_price=current_price + ) + + # Apply constraints + constrained = _apply_constraints(fraction, max_position=0.25) + + assert atr > 0 + assert shares >= 0 + assert constrained <= 0.25 + + def test_risk_parity_workflow(self): + """Test full Risk Parity workflow.""" + volatilities = { + "SPY": 0.15, + "TLT": 0.12, + "GLD": 0.18, + "VNQ": 0.20 + } + + weights = _calculate_risk_parity_weights(volatilities, target_vol=0.12) + + # Verify weights sum to reasonable amount + total_weight = sum(weights.values()) + assert total_weight > 0 + assert total_weight <= 4.0 # May be levered for vol target + + # Verify lower vol gets higher weight + assert weights["TLT"] > weights["VNQ"] + + +class TestFactoryExpectations: + """Tests for factory function expectations.""" + + def test_expected_tools_list(self): + """Test expected position sizing tools.""" + expected_tools = [ + "calculate_kelly_position_size", + "calculate_atr_position_size", + "calculate_risk_parity_allocation", + "calculate_volatility_target_size", + "get_position_sizing_recommendation" + ] + for tool_name in expected_tools: + assert len(tool_name) > 0 + + def test_factory_expected_signature(self): + """Test factory should accept LLM parameter.""" + def mock_factory(llm): + return lambda state: {"messages": [], "position_sizing_report": ""} + + mock_llm = Mock() + node = mock_factory(mock_llm) + result = node({}) + assert "position_sizing_report" in result + + +class TestDataClassResult: + """Tests for PositionSizeResult dataclass.""" + + def test_position_size_result_creation(self): + """Test creating PositionSizeResult.""" + result = PositionSizeResult( + method=SizingMethod.KELLY, + position_size=0.15, + dollar_amount=15000.0, + shares=100, + risk_per_trade=500.0, + rationale="Based on 55% win rate" + ) + assert result.method == SizingMethod.KELLY + assert result.position_size == 0.15 + assert result.shares == 100 + + def test_position_size_result_different_methods(self): + """Test PositionSizeResult with different methods.""" + for method in SizingMethod: + result = PositionSizeResult( + method=method, + position_size=0.10, + dollar_amount=10000.0, + shares=50, + risk_per_trade=200.0, + rationale="Test" + ) + assert result.method == method diff --git a/tradingagents/agents/managers/__init__.py b/tradingagents/agents/managers/__init__.py new file mode 100644 index 00000000..8ea3f0cd --- /dev/null +++ b/tradingagents/agents/managers/__init__.py @@ -0,0 +1 @@ +"""Manager agents for portfolio and risk management.""" diff --git a/tradingagents/agents/managers/position_sizing_manager.py b/tradingagents/agents/managers/position_sizing_manager.py new file mode 100644 index 00000000..d441ad59 --- /dev/null +++ b/tradingagents/agents/managers/position_sizing_manager.py @@ -0,0 +1,1038 @@ +"""Position Sizing Manager. + +Specializes in optimal position sizing calculations using: +- Kelly Criterion for edge-based sizing +- Risk Parity for balanced risk allocation +- ATR-based sizing for volatility adjustment +- Fixed fractional position sizing +- Maximum drawdown constraints + +Issue #16: [AGENT-15] Position Sizing Manager - Kelly, risk parity, ATR +""" + +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder +from langchain_core.tools import tool +from typing import Annotated, Dict, Any, List, Optional +from enum import Enum +import pandas as pd +import numpy as np +from dataclasses import dataclass + +from tradingagents.dataflows.interface import route_to_vendor + + +# ============================================================================ +# Position Sizing Enums and Data Classes +# ============================================================================ + +class SizingMethod(str, Enum): + """Position sizing method types.""" + KELLY = "kelly" + HALF_KELLY = "half_kelly" + QUARTER_KELLY = "quarter_kelly" + RISK_PARITY = "risk_parity" + ATR_BASED = "atr_based" + FIXED_FRACTIONAL = "fixed_fractional" + EQUAL_WEIGHT = "equal_weight" + VOLATILITY_TARGET = "volatility_target" + + +class RiskLevel(str, Enum): + """Risk tolerance levels.""" + CONSERVATIVE = "conservative" + MODERATE = "moderate" + AGGRESSIVE = "aggressive" + + +@dataclass +class PositionSizeResult: + """Result of position size calculation.""" + method: SizingMethod + position_size: float # As fraction of portfolio (0-1) + dollar_amount: float + shares: int + risk_per_trade: float # Dollar risk + rationale: str + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def _calculate_kelly_fraction( + win_rate: float, + avg_win: float, + avg_loss: float +) -> float: + """ + Calculate Kelly Criterion fraction. + + Kelly % = W - [(1-W) / R] + Where: + W = Win probability + R = Win/Loss ratio (avg_win / avg_loss) + """ + if avg_loss == 0 or win_rate <= 0 or win_rate >= 1: + return 0.0 + + win_loss_ratio = abs(avg_win / avg_loss) + + kelly = win_rate - ((1 - win_rate) / win_loss_ratio) + + # Kelly can be negative (don't bet) or very large (reduce) + return max(0.0, min(kelly, 1.0)) + + +def _calculate_half_kelly( + win_rate: float, + avg_win: float, + avg_loss: float +) -> float: + """Calculate half Kelly for reduced volatility.""" + return _calculate_kelly_fraction(win_rate, avg_win, avg_loss) / 2 + + +def _calculate_quarter_kelly( + win_rate: float, + avg_win: float, + avg_loss: float +) -> float: + """Calculate quarter Kelly for conservative sizing.""" + return _calculate_kelly_fraction(win_rate, avg_win, avg_loss) / 4 + + +def _calculate_atr(prices: pd.DataFrame, period: int = 14) -> float: + """ + Calculate Average True Range (ATR). + + True Range = max(H-L, |H-C_prev|, |L-C_prev|) + ATR = SMA(True Range, period) + """ + if len(prices) < period + 1: + return 0.0 + + high = prices['high'] if 'high' in prices.columns else prices['High'] + low = prices['low'] if 'low' in prices.columns else prices['Low'] + close = prices['close'] if 'close' in prices.columns else prices['Close'] + + # Calculate True Range components + tr1 = high - low + tr2 = abs(high - close.shift(1)) + tr3 = abs(low - close.shift(1)) + + true_range = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + + # ATR is SMA of True Range + atr = true_range.rolling(window=period).mean() + + return float(atr.iloc[-1]) if not pd.isna(atr.iloc[-1]) else 0.0 + + +def _calculate_position_from_atr( + account_value: float, + risk_per_trade: float, # As fraction (e.g., 0.02 for 2%) + atr: float, + atr_multiplier: float = 2.0, + current_price: float = 1.0 +) -> tuple: + """ + Calculate position size based on ATR. + + Position Size = (Account * Risk%) / (ATR * Multiplier) + """ + if atr == 0 or current_price == 0: + return 0.0, 0 + + dollar_risk = account_value * risk_per_trade + stop_distance = atr * atr_multiplier + + shares = int(dollar_risk / stop_distance) + position_value = shares * current_price + position_fraction = position_value / account_value if account_value > 0 else 0 + + return position_fraction, shares + + +def _calculate_volatility(returns: pd.Series, annualize: bool = True) -> float: + """Calculate volatility (standard deviation of returns).""" + if len(returns) < 2: + return 0.0 + + vol = returns.std() + + if annualize: + vol = vol * np.sqrt(252) # Annualize daily volatility + + return float(vol) + + +def _calculate_risk_parity_weights( + volatilities: Dict[str, float], + target_vol: float = 0.15 +) -> Dict[str, float]: + """ + Calculate Risk Parity weights. + + Each asset contributes equally to portfolio risk. + Weight_i = (1/Vol_i) / sum(1/Vol_j for all j) + """ + if not volatilities or all(v == 0 for v in volatilities.values()): + # Equal weight fallback + n = len(volatilities) + return {k: 1/n for k in volatilities} if n > 0 else {} + + # Inverse volatility weighting + inv_vols = {k: 1/v if v > 0 else 0 for k, v in volatilities.items()} + total_inv_vol = sum(inv_vols.values()) + + if total_inv_vol == 0: + n = len(volatilities) + return {k: 1/n for k in volatilities} + + weights = {k: v/total_inv_vol for k, v in inv_vols.items()} + + # Scale to target volatility + # Portfolio vol = sqrt(sum(w_i^2 * vol_i^2)) for uncorrelated assets + port_vol = np.sqrt(sum((w**2) * (volatilities[k]**2) for k, w in weights.items())) + + if port_vol > 0: + scale = target_vol / port_vol + weights = {k: min(w * scale, 1.0) for k, w in weights.items()} + + return weights + + +def _calculate_fixed_fractional( + account_value: float, + risk_fraction: float, + stop_loss_pct: float, + current_price: float +) -> tuple: + """ + Calculate fixed fractional position size. + + Position Size = (Account * Risk%) / Stop Loss % + """ + if stop_loss_pct == 0 or current_price == 0: + return 0.0, 0 + + dollar_risk = account_value * risk_fraction + position_value = dollar_risk / stop_loss_pct + shares = int(position_value / current_price) + position_fraction = (shares * current_price) / account_value if account_value > 0 else 0 + + return position_fraction, shares + + +def _calculate_volatility_target_size( + account_value: float, + target_vol: float, + asset_vol: float, + current_price: float +) -> tuple: + """ + Calculate position size to achieve target volatility. + + Weight = Target Vol / Asset Vol + """ + if asset_vol == 0 or current_price == 0: + return 0.0, 0 + + weight = target_vol / asset_vol + weight = min(weight, 1.0) # Cap at 100% + + position_value = account_value * weight + shares = int(position_value / current_price) + position_fraction = (shares * current_price) / account_value if account_value > 0 else 0 + + return position_fraction, shares + + +def _apply_constraints( + position_fraction: float, + max_position: float = 0.25, + max_portfolio_risk: float = 0.02, + current_portfolio_risk: float = 0.0 +) -> float: + """Apply position size constraints.""" + # Max position size constraint + constrained = min(position_fraction, max_position) + + # Portfolio risk constraint + if current_portfolio_risk + constrained > max_portfolio_risk * 10: # Rough check + constrained = max(0, max_portfolio_risk * 10 - current_portfolio_risk) + + return constrained + + +def _interpret_sizing_result( + method: SizingMethod, + position_fraction: float, + kelly_fraction: float = 0 +) -> str: + """Generate interpretation of sizing result.""" + interpretations = [] + + if method in [SizingMethod.KELLY, SizingMethod.HALF_KELLY, SizingMethod.QUARTER_KELLY]: + if kelly_fraction < 0: + interpretations.append("Negative Kelly suggests no edge - avoid trade") + elif kelly_fraction > 0.5: + interpretations.append("Large Kelly suggests strong edge but high variance") + elif kelly_fraction > 0.25: + interpretations.append("Moderate Kelly suggests reasonable edge") + else: + interpretations.append("Small Kelly suggests marginal edge - size conservatively") + + if position_fraction > 0.2: + interpretations.append("Large position - consider splitting into tranches") + elif position_fraction < 0.01: + interpretations.append("Very small position - may not be worth transaction costs") + + return "; ".join(interpretations) if interpretations else "Standard position size" + + +def _get_risk_parameters(risk_level: RiskLevel) -> Dict[str, float]: + """Get risk parameters based on risk tolerance.""" + params = { + RiskLevel.CONSERVATIVE: { + "max_position": 0.10, + "risk_per_trade": 0.01, + "kelly_fraction_used": 0.25, # Quarter Kelly + "target_vol": 0.10 + }, + RiskLevel.MODERATE: { + "max_position": 0.20, + "risk_per_trade": 0.02, + "kelly_fraction_used": 0.50, # Half Kelly + "target_vol": 0.15 + }, + RiskLevel.AGGRESSIVE: { + "max_position": 0.30, + "risk_per_trade": 0.03, + "kelly_fraction_used": 1.0, # Full Kelly + "target_vol": 0.20 + } + } + return params.get(risk_level, params[RiskLevel.MODERATE]) + + +# ============================================================================ +# Position Sizing Tools +# ============================================================================ + +@tool +def calculate_kelly_position_size( + win_rate: Annotated[float, "Historical win rate (0-1)"], + avg_win_pct: Annotated[float, "Average winning trade return (e.g., 0.05 for 5%)"], + avg_loss_pct: Annotated[float, "Average losing trade return (e.g., -0.03 for -3%)"], + account_value: Annotated[float, "Total account value in dollars"], + current_price: Annotated[float, "Current price of the asset"], + kelly_fraction: Annotated[float, "Fraction of Kelly to use (0.25, 0.5, or 1.0)"] = 0.5, +) -> str: + """ + Calculate position size using Kelly Criterion. + + The Kelly Criterion maximizes long-term growth rate but can be volatile. + Most practitioners use Half-Kelly (0.5) or Quarter-Kelly (0.25) for smoother equity curves. + + Returns optimal position size with risk analysis. + """ + try: + # Calculate full Kelly + full_kelly = _calculate_kelly_fraction(win_rate, avg_win_pct, abs(avg_loss_pct)) + + # Apply fraction + used_kelly = full_kelly * kelly_fraction + used_kelly = min(used_kelly, 0.25) # Cap at 25% of account + + # Calculate position + position_value = account_value * used_kelly + shares = int(position_value / current_price) if current_price > 0 else 0 + actual_position_value = shares * current_price + + # Risk metrics + expected_return = (win_rate * avg_win_pct) + ((1 - win_rate) * avg_loss_pct) + expected_risk = abs(avg_loss_pct) * actual_position_value + + # Interpretation + interpretation = _interpret_sizing_result( + SizingMethod.KELLY if kelly_fraction == 1.0 else SizingMethod.HALF_KELLY, + used_kelly, + full_kelly + ) + + report = f""" +## Kelly Criterion Position Sizing + +### Input Parameters + +| Parameter | Value | +|-----------|-------| +| Win Rate | {win_rate:.1%} | +| Avg Win | {avg_win_pct:.2%} | +| Avg Loss | {avg_loss_pct:.2%} | +| Account Value | ${account_value:,.2f} | +| Current Price | ${current_price:.2f} | +| Kelly Fraction Used | {kelly_fraction:.0%} | + +### Kelly Calculation + +| Metric | Value | +|--------|-------| +| Full Kelly % | {full_kelly:.2%} | +| Adjusted Kelly % | {used_kelly:.2%} | +| Win/Loss Ratio | {abs(avg_win_pct/avg_loss_pct):.2f}x | +| Expected Edge | {expected_return:.2%} per trade | + +### Position Size Recommendation + +| Metric | Value | +|--------|-------| +| Position Size | {used_kelly:.2%} of account | +| Dollar Amount | ${actual_position_value:,.2f} | +| Number of Shares | {shares:,} | +| Risk at Entry | ${expected_risk:,.2f} | + +### Interpretation + +{interpretation} + +### Risk Warnings +""" + if full_kelly < 0: + report += "\n⚠️ **Negative Kelly**: Historical edge is negative. Do not trade." + if full_kelly > 0.4: + report += f"\n⚠️ **High Kelly ({full_kelly:.1%})**: Consider reducing position size further." + if win_rate < 0.4: + report += f"\n⚠️ **Low Win Rate ({win_rate:.1%})**: Need larger wins to compensate." + + return report.strip() + + except Exception as e: + return f"Error calculating Kelly position size: {str(e)}" + + +@tool +def calculate_atr_position_size( + symbol: Annotated[str, "Ticker symbol"], + curr_date: Annotated[str, "Current trading date in YYYY-MM-DD format"], + account_value: Annotated[float, "Total account value in dollars"], + risk_per_trade: Annotated[float, "Risk per trade as fraction (e.g., 0.02 for 2%)"] = 0.02, + atr_multiplier: Annotated[float, "ATR multiplier for stop distance (default: 2.0)"] = 2.0, + atr_period: Annotated[int, "ATR calculation period (default: 14)"] = 14, +) -> str: + """ + Calculate position size based on ATR (Average True Range). + + ATR-based sizing adjusts position size inversely to volatility: + - High volatility = smaller position + - Low volatility = larger position + + This maintains consistent dollar risk across different volatility regimes. + """ + try: + # Get price data + lookback = max(atr_period * 3, 60) + data = route_to_vendor("get_stock_data", symbol, curr_date, lookback) + + if isinstance(data, str): + if "error" in data.lower(): + return f"Error retrieving data: {data}" + from io import StringIO + df = pd.read_csv(StringIO(data)) + else: + df = data + + if df.empty or len(df) < atr_period + 1: + return "Insufficient data for ATR calculation." + + # Calculate ATR + atr = _calculate_atr(df, atr_period) + + if atr == 0: + return "ATR calculation returned zero - check data quality." + + # Get current price + close_col = 'close' if 'close' in df.columns else 'Close' + current_price = float(df[close_col].iloc[-1]) + + # Calculate position size + dollar_risk = account_value * risk_per_trade + stop_distance = atr * atr_multiplier + + shares = int(dollar_risk / stop_distance) + position_value = shares * current_price + position_fraction = position_value / account_value if account_value > 0 else 0 + + # Calculate stop loss level + stop_loss_price = current_price - stop_distance + stop_loss_pct = stop_distance / current_price + + # Volatility context + returns = df[close_col].pct_change().dropna() + daily_vol = returns.std() + annual_vol = daily_vol * np.sqrt(252) + + report = f""" +## ATR-Based Position Sizing for {symbol} +Analysis Date: {curr_date} + +### Volatility Analysis + +| Metric | Value | +|--------|-------| +| ATR ({atr_period}-day) | ${atr:.2f} | +| ATR as % of Price | {(atr/current_price)*100:.2f}% | +| Daily Volatility | {daily_vol:.2%} | +| Annual Volatility | {annual_vol:.2%} | + +### Position Size Calculation + +| Parameter | Value | +|-----------|-------| +| Account Value | ${account_value:,.2f} | +| Risk Per Trade | {risk_per_trade:.2%} (${dollar_risk:,.2f}) | +| ATR Multiplier | {atr_multiplier}x | +| Stop Distance | ${stop_distance:.2f} ({stop_loss_pct:.2%}) | + +### Position Size Recommendation + +| Metric | Value | +|--------|-------| +| Position Size | {position_fraction:.2%} of account | +| Dollar Amount | ${position_value:,.2f} | +| Number of Shares | {shares:,} | +| Current Price | ${current_price:.2f} | +| Stop Loss Level | ${stop_loss_price:.2f} | + +### Risk Profile + +- Max Loss at Stop: ${dollar_risk:,.2f} ({risk_per_trade:.2%} of account) +- Position volatility contribution: {position_fraction * annual_vol:.2%} annual + +### Adjustments for Volatility +""" + + if annual_vol > 0.40: + report += "\n⚠️ **High Volatility**: Position automatically reduced. Consider wider stops." + elif annual_vol < 0.15: + report += "\n✅ **Low Volatility**: Larger position size appropriate for risk budget." + else: + report += "\n✅ **Normal Volatility**: Standard position sizing applied." + + return report.strip() + + except Exception as e: + return f"Error calculating ATR position size: {str(e)}" + + +@tool +def calculate_risk_parity_allocation( + symbols: Annotated[str, "Comma-separated list of ticker symbols"], + curr_date: Annotated[str, "Current trading date in YYYY-MM-DD format"], + account_value: Annotated[float, "Total account value in dollars"], + target_volatility: Annotated[float, "Target portfolio volatility (e.g., 0.15 for 15%)"] = 0.15, + lookback_days: Annotated[int, "Days of history for volatility calculation"] = 60, +) -> str: + """ + Calculate Risk Parity allocation across multiple assets. + + Risk Parity equalizes the risk contribution from each asset, + so lower volatility assets get higher weights and vice versa. + + Returns optimal allocation with individual position sizes. + """ + try: + # Parse symbols + symbol_list = [s.strip().upper() for s in symbols.split(",")] + + if len(symbol_list) < 2: + return "Risk Parity requires at least 2 assets." + + # Get data and calculate volatilities + volatilities = {} + prices = {} + + for symbol in symbol_list: + try: + data = route_to_vendor("get_stock_data", symbol, curr_date, lookback_days + 20) + + if isinstance(data, str): + if "error" in data.lower(): + continue + from io import StringIO + df = pd.read_csv(StringIO(data)) + else: + df = data + + if df.empty: + continue + + close_col = 'close' if 'close' in df.columns else 'Close' + returns = df[close_col].pct_change().dropna() + + vol = _calculate_volatility(returns, annualize=True) + volatilities[symbol] = vol + prices[symbol] = float(df[close_col].iloc[-1]) + + except Exception: + continue + + if len(volatilities) < 2: + return "Insufficient data for Risk Parity calculation." + + # Calculate weights + weights = _calculate_risk_parity_weights(volatilities, target_volatility) + + # Calculate position sizes + allocations = {} + total_allocated = 0 + + for symbol in weights: + weight = weights[symbol] + dollar_value = account_value * weight + price = prices.get(symbol, 1) + shares = int(dollar_value / price) + actual_value = shares * price + total_allocated += actual_value + + allocations[symbol] = { + "weight": weight, + "volatility": volatilities[symbol], + "dollar_value": actual_value, + "shares": shares, + "price": price + } + + # Portfolio metrics + port_vol = np.sqrt(sum((weights[s]**2) * (volatilities[s]**2) for s in weights)) + + # Calculate risk contribution + risk_contributions = {} + for s in weights: + # Marginal contribution to risk (simplified for uncorrelated) + risk_contributions[s] = (weights[s]**2 * volatilities[s]**2) / (port_vol**2) if port_vol > 0 else 0 + + report = f""" +## Risk Parity Allocation +Analysis Date: {curr_date} +Target Volatility: {target_volatility:.1%} + +### Asset Volatilities + +| Symbol | Annual Vol | Inverse Weight | +|--------|------------|----------------| +""" + for symbol in sorted(volatilities.keys(), key=lambda x: volatilities[x]): + vol = volatilities[symbol] + inv_weight = (1/vol) / sum(1/v if v > 0 else 0 for v in volatilities.values()) if vol > 0 else 0 + report += f"| {symbol} | {vol:.1%} | {inv_weight:.2%} |\n" + + report += f""" +### Risk Parity Weights + +| Symbol | Weight | Vol | Dollar Value | Shares | Risk Contrib | +|--------|--------|-----|--------------|--------|--------------| +""" + for symbol, alloc in sorted(allocations.items(), key=lambda x: x[1]["weight"], reverse=True): + weight = alloc["weight"] + vol = alloc["volatility"] + value = alloc["dollar_value"] + shares = alloc["shares"] + risk_c = risk_contributions.get(symbol, 0) + report += f"| {symbol} | {weight:.2%} | {vol:.1%} | ${value:,.0f} | {shares:,} | {risk_c:.1%} |\n" + + report += f""" +### Portfolio Summary + +| Metric | Value | +|--------|-------| +| Total Account | ${account_value:,.2f} | +| Total Allocated | ${total_allocated:,.2f} | +| Cash Reserve | ${account_value - total_allocated:,.2f} | +| Portfolio Volatility | {port_vol:.1%} | +| Target Volatility | {target_volatility:.1%} | + +### Risk Parity Benefits + +1. **Equal Risk Contribution**: Each asset contributes similarly to portfolio risk +2. **Volatility Balance**: Lower volatility assets get higher weights +3. **Drawdown Control**: More balanced risk reduces concentration risk + +### Rebalancing Triggers +""" + # Check if rebalancing needed + avg_risk_contrib = 1.0 / len(risk_contributions) if risk_contributions else 0 + max_drift = max(abs(rc - avg_risk_contrib) for rc in risk_contributions.values()) if risk_contributions else 0 + + if max_drift > 0.1: + report += f"\n⚠️ **Rebalancing Recommended**: Risk contribution drift of {max_drift:.1%} exceeds threshold." + else: + report += "\n✅ **Balanced**: Risk contributions are within acceptable range." + + return report.strip() + + except Exception as e: + return f"Error calculating Risk Parity allocation: {str(e)}" + + +@tool +def calculate_volatility_target_size( + symbol: Annotated[str, "Ticker symbol"], + curr_date: Annotated[str, "Current trading date in YYYY-MM-DD format"], + account_value: Annotated[float, "Total account value in dollars"], + target_volatility: Annotated[float, "Target position volatility (e.g., 0.15 for 15%)"] = 0.15, + lookback_days: Annotated[int, "Days of history for volatility calculation"] = 60, +) -> str: + """ + Calculate position size to achieve target volatility contribution. + + Volatility targeting sizes positions so each contributes a consistent + amount of volatility to the portfolio, regardless of the asset's inherent vol. + + Returns position size scaled to target volatility. + """ + try: + # Get price data + data = route_to_vendor("get_stock_data", symbol, curr_date, lookback_days + 20) + + if isinstance(data, str): + if "error" in data.lower(): + return f"Error retrieving data: {data}" + from io import StringIO + df = pd.read_csv(StringIO(data)) + else: + df = data + + if df.empty or len(df) < 20: + return "Insufficient data for volatility calculation." + + # Calculate asset volatility + close_col = 'close' if 'close' in df.columns else 'Close' + current_price = float(df[close_col].iloc[-1]) + returns = df[close_col].pct_change().dropna() + + asset_vol = _calculate_volatility(returns, annualize=True) + + if asset_vol == 0: + return "Asset volatility is zero - cannot calculate target size." + + # Calculate position size + position_fraction, shares = _calculate_volatility_target_size( + account_value, target_volatility, asset_vol, current_price + ) + + position_value = shares * current_price + + # Calculate expected contribution + position_vol_contribution = position_fraction * asset_vol + + # Historical volatility metrics + vol_20d = returns.iloc[-20:].std() * np.sqrt(252) if len(returns) >= 20 else asset_vol + vol_60d = returns.iloc[-60:].std() * np.sqrt(252) if len(returns) >= 60 else asset_vol + + report = f""" +## Volatility Target Position Sizing for {symbol} +Analysis Date: {curr_date} + +### Volatility Analysis + +| Period | Annualized Volatility | +|--------|----------------------| +| 20-day | {vol_20d:.1%} | +| 60-day | {vol_60d:.1%} | +| Full Period | {asset_vol:.1%} | + +### Volatility Targeting Calculation + +| Parameter | Value | +|-----------|-------| +| Target Volatility | {target_volatility:.1%} | +| Asset Volatility | {asset_vol:.1%} | +| Scaling Factor | {target_volatility/asset_vol:.2f}x | + +### Position Size Recommendation + +| Metric | Value | +|--------|-------| +| Position Size | {position_fraction:.2%} of account | +| Dollar Amount | ${position_value:,.2f} | +| Number of Shares | {shares:,} | +| Current Price | ${current_price:.2f} | +| Vol Contribution | {position_vol_contribution:.1%} | + +### Volatility Regime Assessment +""" + # Volatility regime + if vol_20d > vol_60d * 1.3: + report += "\n⚠️ **Rising Volatility**: Recent vol higher than historical. Consider reducing size." + report += f"\n Suggested adjustment: {(vol_60d/vol_20d)*100:.0f}% of calculated size." + elif vol_20d < vol_60d * 0.7: + report += "\n📈 **Declining Volatility**: Recent vol lower than historical. Current size appropriate." + else: + report += "\n✅ **Stable Volatility**: No regime change detected." + + report += f""" + +### Leverage Implications +""" + if position_fraction > 1.0: + report += f"\n⚠️ **Leverage Required**: Target vol requires {position_fraction:.1%} position." + report += "\nConsider reducing target volatility or accepting lower contribution." + else: + report += f"\n✅ **No Leverage**: Position is within account bounds." + + return report.strip() + + except Exception as e: + return f"Error calculating volatility target size: {str(e)}" + + +@tool +def get_position_sizing_recommendation( + symbol: Annotated[str, "Ticker symbol"], + curr_date: Annotated[str, "Current trading date in YYYY-MM-DD format"], + account_value: Annotated[float, "Total account value in dollars"], + win_rate: Annotated[float, "Historical win rate (0-1)"] = 0.5, + avg_win_pct: Annotated[float, "Average winning trade return"] = 0.05, + avg_loss_pct: Annotated[float, "Average losing trade return"] = -0.03, + risk_level: Annotated[str, "Risk tolerance: conservative, moderate, aggressive"] = "moderate", +) -> str: + """ + Get comprehensive position sizing recommendation using multiple methods. + + Compares Kelly, ATR, Volatility Target, and Fixed Fractional sizing + to provide a balanced recommendation based on risk tolerance. + + Returns comparison of sizing methods with final recommendation. + """ + try: + # Get risk parameters + try: + risk = RiskLevel(risk_level.lower()) + except ValueError: + risk = RiskLevel.MODERATE + + params = _get_risk_parameters(risk) + + # Get price data + data = route_to_vendor("get_stock_data", symbol, curr_date, 90) + + if isinstance(data, str): + if "error" in data.lower(): + return f"Error retrieving data: {data}" + from io import StringIO + df = pd.read_csv(StringIO(data)) + else: + df = data + + if df.empty or len(df) < 20: + return "Insufficient data for position sizing." + + close_col = 'close' if 'close' in df.columns else 'Close' + current_price = float(df[close_col].iloc[-1]) + returns = df[close_col].pct_change().dropna() + asset_vol = _calculate_volatility(returns, annualize=True) + atr = _calculate_atr(df) + + # Calculate each method + methods = {} + + # Kelly + full_kelly = _calculate_kelly_fraction(win_rate, avg_win_pct, abs(avg_loss_pct)) + kelly_size = full_kelly * params["kelly_fraction_used"] + kelly_size = min(kelly_size, params["max_position"]) + methods["Kelly"] = { + "fraction": kelly_size, + "value": kelly_size * account_value, + "shares": int((kelly_size * account_value) / current_price) + } + + # ATR-based + if atr > 0: + atr_fraction, atr_shares = _calculate_position_from_atr( + account_value, params["risk_per_trade"], atr, 2.0, current_price + ) + atr_fraction = min(atr_fraction, params["max_position"]) + methods["ATR"] = { + "fraction": atr_fraction, + "value": atr_shares * current_price, + "shares": atr_shares + } + + # Volatility Target + if asset_vol > 0: + vol_fraction, vol_shares = _calculate_volatility_target_size( + account_value, params["target_vol"], asset_vol, current_price + ) + vol_fraction = min(vol_fraction, params["max_position"]) + methods["Vol Target"] = { + "fraction": vol_fraction, + "value": vol_shares * current_price, + "shares": vol_shares + } + + # Fixed Fractional + ff_fraction, ff_shares = _calculate_fixed_fractional( + account_value, params["risk_per_trade"], 0.05, current_price + ) + ff_fraction = min(ff_fraction, params["max_position"]) + methods["Fixed Frac"] = { + "fraction": ff_fraction, + "value": ff_shares * current_price, + "shares": ff_shares + } + + # Calculate consensus (average of non-zero methods) + valid_fractions = [m["fraction"] for m in methods.values() if m["fraction"] > 0] + consensus_fraction = np.mean(valid_fractions) if valid_fractions else 0 + consensus_fraction = min(consensus_fraction, params["max_position"]) + consensus_value = consensus_fraction * account_value + consensus_shares = int(consensus_value / current_price) if current_price > 0 else 0 + + report = f""" +## Comprehensive Position Sizing for {symbol} +Analysis Date: {curr_date} +Risk Level: {risk.value.title()} + +### Asset Analysis + +| Metric | Value | +|--------|-------| +| Current Price | ${current_price:.2f} | +| Annual Volatility | {asset_vol:.1%} | +| 14-day ATR | ${atr:.2f} ({(atr/current_price)*100:.1f}% of price) | + +### Trading Edge Analysis + +| Metric | Value | +|--------|-------| +| Win Rate | {win_rate:.1%} | +| Avg Win | {avg_win_pct:.2%} | +| Avg Loss | {avg_loss_pct:.2%} | +| Win/Loss Ratio | {abs(avg_win_pct/avg_loss_pct):.2f}x | +| Full Kelly | {full_kelly:.2%} | + +### Position Sizing Comparison + +| Method | Position % | Dollar Value | Shares | +|--------|-----------|--------------|--------| +""" + for method_name, result in methods.items(): + report += f"| {method_name} | {result['fraction']:.2%} | ${result['value']:,.0f} | {result['shares']:,} |\n" + + report += f"""| **Consensus** | **{consensus_fraction:.2%}** | **${consensus_value:,.0f}** | **{consensus_shares:,}** | + +### Final Recommendation + +Based on your {risk.value} risk profile: + +| Parameter | Value | +|-----------|-------| +| Max Position Size | {params['max_position']:.0%} | +| Risk Per Trade | {params['risk_per_trade']:.1%} | +| Target Volatility | {params['target_vol']:.0%} | + +**Recommended Position**: {consensus_shares:,} shares (${consensus_value:,.0f}) +**Recommended Stop Loss**: ${current_price - atr*2:.2f} (2x ATR below entry) + +### Method Notes +""" + # Add specific notes + if full_kelly < 0: + report += "\n⚠️ **Kelly Warning**: Negative Kelly suggests no statistical edge." + if consensus_fraction > params["max_position"] * 0.8: + report += f"\n⚠️ **Size Warning**: Near maximum position limit ({params['max_position']:.0%})." + if asset_vol > 0.40: + report += "\n⚠️ **Volatility Warning**: High volatility asset - consider reduced size." + + return report.strip() + + except Exception as e: + return f"Error calculating position sizing recommendation: {str(e)}" + + +# ============================================================================ +# Position Sizing Manager Factory +# ============================================================================ + +def create_position_sizing_manager(llm): + """ + Factory function to create the Position Sizing Manager agent. + + Args: + llm: Language model to use for the agent + + Returns: + Callable node function for the agent graph + """ + tools = [ + calculate_kelly_position_size, + calculate_atr_position_size, + calculate_risk_parity_allocation, + calculate_volatility_target_size, + get_position_sizing_recommendation + ] + + tool_names = ", ".join([t.name for t in tools]) + + prompt = ChatPromptTemplate.from_messages([ + ("system", f"""You are a specialized Position Sizing Manager focused on optimal +position size calculation and risk management. + +You have access to these tools: {tool_names} + +Your expertise includes: +1. Kelly Criterion calculations for edge-based sizing +2. ATR-based position sizing for volatility adjustment +3. Risk Parity allocation for portfolio balancing +4. Volatility targeting for consistent risk contribution +5. Fixed fractional sizing for controlled risk + +When calculating position sizes: +- Always consider the trader's risk tolerance level +- Account for current market volatility +- Provide multiple sizing methods for comparison +- Include stop loss recommendations +- Warn about over-sizing or under-sizing + +Key principles: +- Never risk more than 2-3% of account on a single trade +- Use fractional Kelly (half or quarter) to reduce variance +- Adjust size for volatility regime changes +- Consider transaction costs for very small positions + +Be precise with numbers and always show your calculations."""), + MessagesPlaceholder(variable_name="messages"), + ]) + + chain = prompt | llm.bind_tools(tools) + + def position_sizing_node(state): + """Execute the Position Sizing Manager agent.""" + messages = state.get("messages", []) + trade_date = state.get("trade_date", "") + company = state.get("company_of_interest", "") + + # Add context if not in messages + if trade_date and company: + context_msg = f"Calculate position sizing for {company} as of {trade_date}." + from langchain_core.messages import HumanMessage + if not any(context_msg in str(m) for m in messages): + messages = [HumanMessage(content=context_msg)] + list(messages) + + response = chain.invoke({"messages": messages}) + + # Extract report from tool responses + report = "" + if hasattr(response, 'tool_calls') and response.tool_calls: + report = "Position sizing calculated. See tool results for details." + elif hasattr(response, 'content'): + report = response.content + + return { + "messages": [response], + "position_sizing_report": report + } + + return position_sizing_node