"""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