TradingAgents/tradingagents/memory/risk_profiles.py

818 lines
27 KiB
Python

"""Risk Profiles Memory for tracking user risk preferences over time.
This module provides memory for tracking and learning from risk preferences:
- Risk tolerance levels across different market conditions
- Risk preference evolution over time
- Market regime-specific risk adjustments
- Historical risk decisions and outcomes
Issue #20: [MEM-19] Risk profiles memory - user preferences over time
"""
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from enum import Enum
from typing import Dict, List, Optional, Any, Tuple
import statistics
import uuid
from .layered_memory import (
LayeredMemory,
MemoryEntry,
MemoryConfig,
ScoringWeights,
ImportanceLevel,
)
class RiskTolerance(Enum):
"""User risk tolerance levels."""
CONSERVATIVE = "conservative" # Low risk, capital preservation
MODERATE = "moderate" # Balanced risk/reward
AGGRESSIVE = "aggressive" # High risk, growth focused
VERY_AGGRESSIVE = "very_aggressive" # Maximum risk tolerance
@classmethod
def from_score(cls, score: float) -> "RiskTolerance":
"""Convert a risk score (0-1) to RiskTolerance.
Args:
score: Risk score between 0 (conservative) and 1 (very aggressive)
Returns:
Corresponding RiskTolerance
"""
if score < 0.25:
return cls.CONSERVATIVE
elif score < 0.50:
return cls.MODERATE
elif score < 0.75:
return cls.AGGRESSIVE
else:
return cls.VERY_AGGRESSIVE
def to_score(self) -> float:
"""Convert RiskTolerance to a numeric score.
Returns:
Score between 0 and 1
"""
mapping = {
RiskTolerance.CONSERVATIVE: 0.125,
RiskTolerance.MODERATE: 0.375,
RiskTolerance.AGGRESSIVE: 0.625,
RiskTolerance.VERY_AGGRESSIVE: 0.875,
}
return mapping[self]
class MarketRegime(Enum):
"""Market regime classifications."""
BULL = "bull" # Strong uptrend
BEAR = "bear" # Strong downtrend
SIDEWAYS = "sideways" # Range-bound
HIGH_VOLATILITY = "high_volatility" # VIX > 25
LOW_VOLATILITY = "low_volatility" # VIX < 15
CRISIS = "crisis" # Market stress/crash
class RiskCategory(Enum):
"""Categories of risk decisions."""
POSITION_SIZE = "position_size" # How much to invest
LEVERAGE = "leverage" # Use of leverage
DIVERSIFICATION = "diversification" # Portfolio spread
HEDGING = "hedging" # Protective positions
STOP_LOSS = "stop_loss" # Exit thresholds
SECTOR_EXPOSURE = "sector_exposure" # Sector concentration
ASSET_CLASS = "asset_class" # Asset allocation
@dataclass
class RiskDecision:
"""A recorded risk decision with context and outcome.
Attributes:
id: Unique decision ID
timestamp: When decision was made
category: Type of risk decision
risk_level: Risk level chosen (0-1 scale)
market_regime: Market conditions at decision time
context: Situation description
vix_level: VIX at decision time
outcome: Outcome description (added later)
outcome_score: Quantified outcome (-1 to 1)
was_appropriate: Whether decision was appropriate in hindsight
notes: Additional notes
"""
id: str
timestamp: datetime
category: RiskCategory
risk_level: float # 0 (min risk) to 1 (max risk)
market_regime: MarketRegime
context: str
vix_level: Optional[float] = None
outcome: Optional[str] = None
outcome_score: Optional[float] = None # -1 (bad) to 1 (good)
was_appropriate: Optional[bool] = None
notes: Optional[str] = None
@classmethod
def create(
cls,
category: RiskCategory,
risk_level: float,
market_regime: MarketRegime,
context: str,
vix_level: Optional[float] = None,
notes: Optional[str] = None,
) -> "RiskDecision":
"""Create a new risk decision record.
Args:
category: Type of risk decision
risk_level: Risk level chosen (0-1)
market_regime: Current market regime
context: Situation description
vix_level: Current VIX level
notes: Additional notes
Returns:
New RiskDecision instance
"""
if not 0.0 <= risk_level <= 1.0:
raise ValueError(f"Risk level must be between 0 and 1, got {risk_level}")
return cls(
id=str(uuid.uuid4()),
timestamp=datetime.now(),
category=category,
risk_level=risk_level,
market_regime=market_regime,
context=context,
vix_level=vix_level,
notes=notes,
)
def evaluate(
self,
outcome: str,
outcome_score: float,
was_appropriate: bool,
) -> "RiskDecision":
"""Evaluate the decision after the outcome is known.
Args:
outcome: Description of what happened
outcome_score: Quantified outcome (-1 to 1)
was_appropriate: Whether the risk level was appropriate
Returns:
Self with updated evaluation
"""
self.outcome = outcome
self.outcome_score = max(-1.0, min(1.0, outcome_score))
self.was_appropriate = was_appropriate
return self
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"id": self.id,
"timestamp": self.timestamp.isoformat(),
"category": self.category.value,
"risk_level": self.risk_level,
"market_regime": self.market_regime.value,
"context": self.context,
"vix_level": self.vix_level,
"outcome": self.outcome,
"outcome_score": self.outcome_score,
"was_appropriate": self.was_appropriate,
"notes": self.notes,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "RiskDecision":
"""Create from dictionary."""
return cls(
id=data["id"],
timestamp=datetime.fromisoformat(data["timestamp"]),
category=RiskCategory(data["category"]),
risk_level=data["risk_level"],
market_regime=MarketRegime(data["market_regime"]),
context=data["context"],
vix_level=data.get("vix_level"),
outcome=data.get("outcome"),
outcome_score=data.get("outcome_score"),
was_appropriate=data.get("was_appropriate"),
notes=data.get("notes"),
)
@dataclass
class RiskProfile:
"""User's risk profile with preferences and history.
Attributes:
user_id: User identifier
base_tolerance: Baseline risk tolerance
regime_adjustments: Adjustments by market regime
category_preferences: Preferences by risk category
max_drawdown_tolerance: Maximum acceptable drawdown
volatility_preference: Preferred portfolio volatility
created_at: Profile creation time
updated_at: Last update time
"""
user_id: str
base_tolerance: RiskTolerance = RiskTolerance.MODERATE
regime_adjustments: Dict[str, float] = field(default_factory=dict)
category_preferences: Dict[str, float] = field(default_factory=dict)
max_drawdown_tolerance: float = 0.20 # 20% max drawdown
volatility_preference: float = 0.15 # 15% annual volatility
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
def __post_init__(self):
"""Initialize default regime adjustments if empty."""
if not self.regime_adjustments:
self.regime_adjustments = {
MarketRegime.BULL.value: 0.1, # Slightly more risk
MarketRegime.BEAR.value: -0.2, # Reduce risk
MarketRegime.SIDEWAYS.value: 0.0, # No change
MarketRegime.HIGH_VOLATILITY.value: -0.3, # Reduce significantly
MarketRegime.LOW_VOLATILITY.value: 0.1, # Slightly more risk
MarketRegime.CRISIS.value: -0.5, # Maximum reduction
}
def get_adjusted_risk_score(self, market_regime: MarketRegime) -> float:
"""Get risk score adjusted for current market regime.
Args:
market_regime: Current market regime
Returns:
Adjusted risk score (0-1)
"""
base_score = self.base_tolerance.to_score()
adjustment = self.regime_adjustments.get(market_regime.value, 0.0)
adjusted = base_score + adjustment
return max(0.0, min(1.0, adjusted))
def get_adjusted_tolerance(self, market_regime: MarketRegime) -> RiskTolerance:
"""Get risk tolerance adjusted for market regime.
Args:
market_regime: Current market regime
Returns:
Adjusted RiskTolerance
"""
score = self.get_adjusted_risk_score(market_regime)
return RiskTolerance.from_score(score)
def update_regime_adjustment(
self,
regime: MarketRegime,
adjustment: float,
) -> None:
"""Update the adjustment for a specific regime.
Args:
regime: Market regime to update
adjustment: New adjustment value (-1 to 1)
"""
self.regime_adjustments[regime.value] = max(-1.0, min(1.0, adjustment))
self.updated_at = datetime.now()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
"user_id": self.user_id,
"base_tolerance": self.base_tolerance.value,
"regime_adjustments": self.regime_adjustments,
"category_preferences": self.category_preferences,
"max_drawdown_tolerance": self.max_drawdown_tolerance,
"volatility_preference": self.volatility_preference,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "RiskProfile":
"""Create from dictionary."""
return cls(
user_id=data["user_id"],
base_tolerance=RiskTolerance(data["base_tolerance"]),
regime_adjustments=data.get("regime_adjustments", {}),
category_preferences=data.get("category_preferences", {}),
max_drawdown_tolerance=data.get("max_drawdown_tolerance", 0.20),
volatility_preference=data.get("volatility_preference", 0.15),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
)
class RiskProfileMemory:
"""Memory system for tracking risk profiles and decisions.
This class provides storage and retrieval for risk profiles and
historical risk decisions, enabling learning from past decisions.
Example:
>>> memory = RiskProfileMemory()
>>>
>>> # Create a risk profile
>>> profile = RiskProfile(user_id="user1", base_tolerance=RiskTolerance.MODERATE)
>>> memory.set_profile(profile)
>>>
>>> # Record a risk decision
>>> decision = RiskDecision.create(
... category=RiskCategory.POSITION_SIZE,
... risk_level=0.6,
... market_regime=MarketRegime.BULL,
... context="Strong momentum in tech sector",
... )
>>> memory.record_decision(decision)
>>>
>>> # Get recommended risk level for similar situation
>>> recommended = memory.recommend_risk_level(
... category=RiskCategory.POSITION_SIZE,
... market_regime=MarketRegime.BULL,
... context="Tech sector showing strength",
... )
"""
def __init__(
self,
config: Optional[MemoryConfig] = None,
embedding_function=None,
):
"""Initialize risk profile memory.
Args:
config: Memory configuration
embedding_function: Optional embedding function
"""
if config is None:
config = MemoryConfig(
weights=ScoringWeights(
recency=0.35, # Recent decisions more relevant
relevancy=0.40, # Similar situations important
importance=0.25, # Outcome importance
),
)
self._layered_memory = LayeredMemory(
config=config,
embedding_function=embedding_function,
)
self._profiles: Dict[str, RiskProfile] = {}
self._decisions: Dict[str, RiskDecision] = {}
self._default_user_id = "default"
def set_profile(self, profile: RiskProfile) -> None:
"""Set or update a user's risk profile.
Args:
profile: Risk profile to store
"""
self._profiles[profile.user_id] = profile
def get_profile(self, user_id: Optional[str] = None) -> Optional[RiskProfile]:
"""Get a user's risk profile.
Args:
user_id: User ID (default: "default")
Returns:
RiskProfile or None
"""
user_id = user_id or self._default_user_id
return self._profiles.get(user_id)
def get_or_create_profile(
self,
user_id: Optional[str] = None,
base_tolerance: RiskTolerance = RiskTolerance.MODERATE,
) -> RiskProfile:
"""Get existing profile or create a new one.
Args:
user_id: User ID
base_tolerance: Default tolerance if creating new
Returns:
RiskProfile
"""
user_id = user_id or self._default_user_id
profile = self._profiles.get(user_id)
if profile is None:
profile = RiskProfile(user_id=user_id, base_tolerance=base_tolerance)
self._profiles[user_id] = profile
return profile
def record_decision(
self,
decision: RiskDecision,
user_id: Optional[str] = None,
) -> str:
"""Record a risk decision.
Args:
decision: The risk decision to record
user_id: User ID (default: "default")
Returns:
Decision ID
"""
user_id = user_id or self._default_user_id
self._decisions[decision.id] = decision
# Calculate importance based on outcome if available
importance = 0.5
if decision.outcome_score is not None:
importance = 0.5 + (abs(decision.outcome_score) * 0.5)
# Create memory entry
content = (
f"Risk decision: {decision.category.value} with risk level "
f"{decision.risk_level:.2f} in {decision.market_regime.value} market. "
f"Context: {decision.context}"
)
entry = MemoryEntry.create(
content=content,
metadata={
"decision_id": decision.id,
"user_id": user_id,
"category": decision.category.value,
"risk_level": decision.risk_level,
"market_regime": decision.market_regime.value,
"vix_level": decision.vix_level,
"outcome": decision.outcome,
"outcome_score": decision.outcome_score,
"was_appropriate": decision.was_appropriate,
},
importance=importance,
tags=[
user_id,
decision.category.value,
decision.market_regime.value,
],
timestamp=decision.timestamp,
)
entry.id = decision.id
self._layered_memory.add(entry)
return decision.id
def evaluate_decision(
self,
decision_id: str,
outcome: str,
outcome_score: float,
was_appropriate: bool,
) -> Optional[RiskDecision]:
"""Evaluate a past decision with hindsight.
Args:
decision_id: ID of the decision
outcome: What happened
outcome_score: Quantified outcome (-1 to 1)
was_appropriate: Whether decision was appropriate
Returns:
Updated decision or None
"""
decision = self._decisions.get(decision_id)
if decision is None:
return None
decision.evaluate(outcome, outcome_score, was_appropriate)
# Update memory importance
importance = 0.5 + (abs(outcome_score) * 0.5)
self._layered_memory.update_importance(decision_id, importance)
return decision
def get_decision(self, decision_id: str) -> Optional[RiskDecision]:
"""Get a decision by ID.
Args:
decision_id: Decision ID
Returns:
RiskDecision or None
"""
return self._decisions.get(decision_id)
def find_similar_decisions(
self,
context: str,
category: Optional[RiskCategory] = None,
market_regime: Optional[MarketRegime] = None,
top_k: int = 5,
) -> List[RiskDecision]:
"""Find similar past decisions.
Args:
context: Current situation context
category: Optional filter by category
market_regime: Optional filter by regime
top_k: Maximum results
Returns:
List of similar decisions
"""
tags = []
if category:
tags.append(category.value)
if market_regime:
tags.append(market_regime.value)
results = self._layered_memory.retrieve(
query=context,
top_k=top_k * 2,
tags=tags if tags else None,
)
decisions = []
for scored in results:
decision_id = scored.entry.metadata.get("decision_id")
if decision_id and decision_id in self._decisions:
decisions.append(self._decisions[decision_id])
if len(decisions) >= top_k:
break
return decisions
def recommend_risk_level(
self,
category: RiskCategory,
market_regime: MarketRegime,
context: str,
user_id: Optional[str] = None,
use_history: bool = True,
) -> Tuple[float, str]:
"""Recommend a risk level based on profile and history.
Args:
category: Risk category
market_regime: Current market regime
context: Current situation
user_id: User ID
use_history: Whether to consider past decisions
Returns:
Tuple of (risk_level, explanation)
"""
user_id = user_id or self._default_user_id
profile = self.get_or_create_profile(user_id)
# Start with profile-based recommendation
base_risk = profile.get_adjusted_risk_score(market_regime)
explanation_parts = [
f"Base risk from profile: {base_risk:.2f} "
f"({profile.base_tolerance.value} adjusted for {market_regime.value})"
]
if not use_history:
return base_risk, " | ".join(explanation_parts)
# Find similar past decisions
similar = self.find_similar_decisions(
context=context,
category=category,
market_regime=market_regime,
top_k=5,
)
if not similar:
explanation_parts.append("No similar past decisions found")
return base_risk, " | ".join(explanation_parts)
# Analyze outcomes of similar decisions
successful_decisions = [
d for d in similar
if d.was_appropriate is True
]
unsuccessful_decisions = [
d for d in similar
if d.was_appropriate is False
]
# Calculate weighted average of successful decisions
if successful_decisions:
successful_avg = statistics.mean([d.risk_level for d in successful_decisions])
explanation_parts.append(
f"Avg risk level from {len(successful_decisions)} successful similar decisions: "
f"{successful_avg:.2f}"
)
# Blend with base risk (weight toward successful history)
adjusted_risk = (base_risk * 0.4) + (successful_avg * 0.6)
else:
adjusted_risk = base_risk
# Warn about unsuccessful patterns
if unsuccessful_decisions:
unsuccessful_avg = statistics.mean([d.risk_level for d in unsuccessful_decisions])
if abs(adjusted_risk - unsuccessful_avg) < 0.1:
explanation_parts.append(
f"WARNING: Similar risk level ({unsuccessful_avg:.2f}) was unsuccessful before"
)
# Adjust away from unsuccessful pattern
if unsuccessful_avg > base_risk:
adjusted_risk = max(0.0, adjusted_risk - 0.1)
else:
adjusted_risk = min(1.0, adjusted_risk + 0.1)
return adjusted_risk, " | ".join(explanation_parts)
def get_regime_statistics(
self,
user_id: Optional[str] = None,
) -> Dict[str, Dict[str, Any]]:
"""Get statistics of risk decisions by market regime.
Args:
user_id: Optional filter by user
Returns:
Dictionary of statistics by regime
"""
stats: Dict[str, Dict[str, Any]] = {}
for regime in MarketRegime:
regime_decisions = [
d for d in self._decisions.values()
if d.market_regime == regime
]
if not regime_decisions:
stats[regime.value] = {
"count": 0,
"avg_risk_level": None,
"success_rate": None,
}
continue
evaluated = [d for d in regime_decisions if d.was_appropriate is not None]
successful = [d for d in evaluated if d.was_appropriate is True]
stats[regime.value] = {
"count": len(regime_decisions),
"avg_risk_level": statistics.mean([d.risk_level for d in regime_decisions]),
"success_rate": len(successful) / len(evaluated) if evaluated else None,
}
return stats
def get_category_statistics(
self,
user_id: Optional[str] = None,
) -> Dict[str, Dict[str, Any]]:
"""Get statistics of risk decisions by category.
Args:
user_id: Optional filter by user
Returns:
Dictionary of statistics by category
"""
stats: Dict[str, Dict[str, Any]] = {}
for category in RiskCategory:
category_decisions = [
d for d in self._decisions.values()
if d.category == category
]
if not category_decisions:
stats[category.value] = {
"count": 0,
"avg_risk_level": None,
"success_rate": None,
}
continue
evaluated = [d for d in category_decisions if d.was_appropriate is not None]
successful = [d for d in evaluated if d.was_appropriate is True]
stats[category.value] = {
"count": len(category_decisions),
"avg_risk_level": statistics.mean([d.risk_level for d in category_decisions]),
"success_rate": len(successful) / len(evaluated) if evaluated else None,
}
return stats
def learn_regime_adjustments(
self,
user_id: Optional[str] = None,
min_decisions: int = 5,
) -> Dict[str, float]:
"""Learn regime adjustments from historical decisions.
Analyzes past decisions to suggest optimal regime adjustments.
Args:
user_id: User ID
min_decisions: Minimum decisions per regime to learn from
Returns:
Suggested regime adjustments
"""
user_id = user_id or self._default_user_id
profile = self.get_or_create_profile(user_id)
suggestions: Dict[str, float] = {}
for regime in MarketRegime:
regime_decisions = [
d for d in self._decisions.values()
if d.market_regime == regime
and d.was_appropriate is not None
]
if len(regime_decisions) < min_decisions:
continue
# Find the risk level with best outcomes
successful = [d for d in regime_decisions if d.was_appropriate]
unsuccessful = [d for d in regime_decisions if not d.was_appropriate]
if not successful:
# All decisions were unsuccessful - suggest lower risk
avg_failed_risk = statistics.mean([d.risk_level for d in unsuccessful])
suggested_adjustment = -0.2 # Lower risk
elif not unsuccessful:
# All decisions were successful - keep similar
avg_success_risk = statistics.mean([d.risk_level for d in successful])
base_score = profile.base_tolerance.to_score()
suggested_adjustment = avg_success_risk - base_score
else:
# Mixed results - prefer successful pattern
avg_success_risk = statistics.mean([d.risk_level for d in successful])
base_score = profile.base_tolerance.to_score()
suggested_adjustment = avg_success_risk - base_score
suggestions[regime.value] = max(-0.5, min(0.5, suggested_adjustment))
return suggestions
def count(self) -> int:
"""Return total number of decisions."""
return len(self._decisions)
def clear(self) -> int:
"""Clear all decisions (preserves profiles).
Returns:
Number of decisions cleared
"""
count = len(self._decisions)
self._decisions.clear()
self._layered_memory.clear()
return count
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"profiles": {
uid: p.to_dict()
for uid, p in self._profiles.items()
},
"decisions": [d.to_dict() for d in self._decisions.values()],
"memory": self._layered_memory.to_dict(),
}
@classmethod
def from_dict(
cls,
data: Dict[str, Any],
embedding_function=None,
) -> "RiskProfileMemory":
"""Create from dictionary."""
instance = cls(embedding_function=embedding_function)
# Restore profiles
for uid, profile_data in data.get("profiles", {}).items():
profile = RiskProfile.from_dict(profile_data)
instance._profiles[uid] = profile
# Restore decisions
for decision_data in data.get("decisions", []):
decision = RiskDecision.from_dict(decision_data)
instance._decisions[decision.id] = decision
# Restore layered memory
if "memory" in data:
instance._layered_memory = LayeredMemory.from_dict(
data["memory"],
embedding_function=embedding_function,
)
return instance