1039 lines
35 KiB
Python
1039 lines
35 KiB
Python
"""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
|