sigma-terminal 2.0.1__py3-none-any.whl → 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sigma/core/intent.py ADDED
@@ -0,0 +1,595 @@
1
+ """Intent parsing and research plan generation for Sigma."""
2
+
3
+ import re
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+ from datetime import date
6
+
7
+ from .models import (
8
+ AssetClass,
9
+ TimeHorizon,
10
+ RiskProfile,
11
+ DeliverableType,
12
+ ResearchPlan,
13
+ Constraint,
14
+ detect_asset_class,
15
+ )
16
+
17
+
18
+ # ============================================================================
19
+ # INTENT PATTERNS
20
+ # ============================================================================
21
+
22
+ # Deliverable type detection
23
+ DELIVERABLE_PATTERNS = {
24
+ DeliverableType.COMPARISON: [
25
+ r"\bcompare\b", r"\bvs\b", r"\bversus\b", r"\bagainst\b",
26
+ r"\bwhich is better\b", r"\bwhich one\b", r"\bdifference between\b",
27
+ ],
28
+ DeliverableType.BACKTEST: [
29
+ r"\bbacktest\b", r"\btest strategy\b", r"\bhistorical performance\b",
30
+ r"\bwould have performed\b", r"\bsimulate\b", r"\bstrategy test\b",
31
+ ],
32
+ DeliverableType.PORTFOLIO: [
33
+ r"\bportfolio\b", r"\ballocation\b", r"\bdiversif\b", r"\brebalance\b",
34
+ r"\bweight\b", r"\brisk parity\b", r"\boptimize\b",
35
+ ],
36
+ DeliverableType.STRATEGY: [
37
+ r"\bstrategy\b", r"\btrading system\b", r"\balgorithm\b", r"\brules?\b",
38
+ r"\bwhen to buy\b", r"\bwhen to sell\b", r"\bsignal\b",
39
+ ],
40
+ DeliverableType.CHART: [
41
+ r"\bchart\b", r"\bgraph\b", r"\bplot\b", r"\bvisualize\b",
42
+ r"\bshow me\b", r"\bdraw\b",
43
+ ],
44
+ DeliverableType.REPORT: [
45
+ r"\breport\b", r"\banalysis report\b", r"\bresearch memo\b",
46
+ r"\bfull analysis\b", r"\bdeep dive\b",
47
+ ],
48
+ DeliverableType.ALERT: [
49
+ r"\balert\b", r"\bnotify\b", r"\bwatch\b", r"\bmonitor\b",
50
+ r"\btell me when\b", r"\bwarn me\b",
51
+ ],
52
+ DeliverableType.ANALYSIS: [
53
+ r"\banalyze\b", r"\banalysis\b", r"\blook at\b", r"\bexamine\b",
54
+ r"\bwhat do you think\b", r"\bopinion on\b", r"\bsentiment\b",
55
+ ],
56
+ }
57
+
58
+ # Time horizon detection
59
+ HORIZON_PATTERNS = {
60
+ TimeHorizon.INTRADAY: [r"\bintraday\b", r"\bday trad\b", r"\bscalp\b", r"\bminute\b", r"\bhour\b"],
61
+ TimeHorizon.DAILY: [r"\bdaily\b", r"\bswing\b", r"\bshort.?term\b", r"\bdays?\b"],
62
+ TimeHorizon.WEEKLY: [r"\bweekly\b", r"\bweeks?\b"],
63
+ TimeHorizon.MONTHLY: [r"\bmonthly\b", r"\bmonths?\b", r"\bmedium.?term\b"],
64
+ TimeHorizon.QUARTERLY: [r"\bquarterly\b", r"\bquarters?\b"],
65
+ TimeHorizon.YEARLY: [r"\byearly\b", r"\bannual\b", r"\byears?\b"],
66
+ TimeHorizon.MULTI_YEAR: [r"\blong.?term\b", r"\bmulti.?year\b", r"\bdecade\b", r"\bretirement\b"],
67
+ }
68
+
69
+ # Risk profile detection
70
+ RISK_PATTERNS = {
71
+ RiskProfile.CONSERVATIVE: [
72
+ r"\bconservative\b", r"\bsafe\b", r"\blow risk\b", r"\bstable\b",
73
+ r"\bdefensive\b", r"\bcapital preservation\b",
74
+ ],
75
+ RiskProfile.MODERATE: [
76
+ r"\bmoderate\b", r"\bbalanced\b", r"\bmid risk\b",
77
+ ],
78
+ RiskProfile.AGGRESSIVE: [
79
+ r"\baggressive\b", r"\bhigh risk\b", r"\bgrowth\b", r"\bhigh return\b",
80
+ ],
81
+ RiskProfile.VERY_AGGRESSIVE: [
82
+ r"\bspeculative\b", r"\bvery aggressive\b", r"\bmaximum\b", r"\bleverage\b",
83
+ ],
84
+ }
85
+
86
+ # Constraint detection
87
+ CONSTRAINT_PATTERNS = [
88
+ (r"max(?:imum)?\s+(?:position\s+)?weight\s*(?:of\s*)?(\d+)%?", "max_weight"),
89
+ (r"max(?:imum)?\s+drawdown\s*(?:of\s*)?(\d+)%?", "max_drawdown"),
90
+ (r"(?:no\s+)?leverage", "leverage"),
91
+ (r"max(?:imum)?\s+leverage\s*(?:of\s*)?([\d.]+)x?", "max_leverage"),
92
+ (r"turnover\s*(?:cap|limit)?\s*(?:of\s*)?(\d+)%?", "max_turnover"),
93
+ (r"sector\s+(?:cap|limit)\s*(?:of\s*)?(\d+)%?", "sector_cap"),
94
+ (r"(?:no\s+)?(?:short|shorting)", "no_shorts"),
95
+ (r"tax.?(?:efficient|aware|sensitive)", "tax_aware"),
96
+ (r"(?:ESG|sustainable|socially responsible)", "esg"),
97
+ ]
98
+
99
+ # Account type detection
100
+ ACCOUNT_PATTERNS = {
101
+ "taxable": [r"\btaxable\b", r"\bbrokerage\b", r"\bindividual\b"],
102
+ "ira": [r"\bira\b", r"\broth\b", r"\btraditional ira\b"],
103
+ "401k": [r"\b401k\b", r"\b401\(k\)\b", r"\bretirement\b"],
104
+ "margin": [r"\bmargin\b"],
105
+ }
106
+
107
+
108
+ # ============================================================================
109
+ # TICKER EXTRACTION
110
+ # ============================================================================
111
+
112
+ # Common tickers to help with extraction
113
+ COMMON_TICKERS = {
114
+ "AAPL", "MSFT", "GOOGL", "GOOG", "AMZN", "NVDA", "META", "TSLA", "BRK.A", "BRK.B",
115
+ "JPM", "V", "JNJ", "WMT", "MA", "PG", "UNH", "HD", "DIS", "BAC",
116
+ "SPY", "QQQ", "IWM", "DIA", "VTI", "VOO", "VEA", "VWO", "BND", "AGG",
117
+ "XLK", "XLF", "XLE", "XLV", "XLI", "XLP", "XLY", "XLB", "XLU", "XLRE",
118
+ "GLD", "SLV", "USO", "UNG", "TLT", "IEF", "SHY", "LQD", "HYG", "JNK",
119
+ "BTC", "ETH", "SOL", "DOGE", "ADA", "XRP", "DOT", "AVAX", "LINK", "MATIC",
120
+ }
121
+
122
+ # Ticker pattern
123
+ TICKER_PATTERN = re.compile(r'\b([A-Z]{1,5}(?:\.[A-Z])?)\b')
124
+
125
+
126
+ def extract_tickers(text: str) -> List[str]:
127
+ """Extract stock tickers from text."""
128
+ text_upper = text.upper()
129
+
130
+ # Find all potential tickers
131
+ matches = TICKER_PATTERN.findall(text_upper)
132
+
133
+ # Filter out common words
134
+ excluded = {
135
+ "I", "A", "THE", "AND", "OR", "FOR", "TO", "IN", "ON", "AT", "IS", "IT",
136
+ "AS", "BE", "BY", "AN", "IF", "VS", "AM", "PM", "US", "UK", "EU", "OF",
137
+ "MY", "ME", "DO", "SO", "NO", "UP", "HE", "WE", "GO", "CEO", "CFO", "CTO",
138
+ "ETF", "IPO", "EPS", "PE", "PB", "ROE", "ROA", "CAGR", "YOY", "QOQ", "MOM",
139
+ "MAX", "MIN", "AVG", "SMA", "EMA", "RSI", "ATR", "ADX", "MACD", "BB",
140
+ "LEAN", "API", "CSV", "PDF", "PNG", "SVG",
141
+ }
142
+
143
+ tickers = []
144
+ for match in matches:
145
+ if match not in excluded:
146
+ # Prefer known tickers
147
+ if match in COMMON_TICKERS:
148
+ tickers.append(match)
149
+ elif len(match) >= 2: # At least 2 chars for unknown tickers
150
+ tickers.append(match)
151
+
152
+ return list(dict.fromkeys(tickers)) # Remove duplicates, preserve order
153
+
154
+
155
+ # ============================================================================
156
+ # INTENT PARSER
157
+ # ============================================================================
158
+
159
+ class IntentParser:
160
+ """Parse user intent into structured research plan."""
161
+
162
+ def __init__(self):
163
+ self.default_benchmark = "SPY"
164
+ self.default_horizon = TimeHorizon.DAILY
165
+ self.default_risk = RiskProfile.MODERATE
166
+
167
+ def parse(self, query: str) -> ResearchPlan:
168
+ """Parse user query into a research plan."""
169
+ query_lower = query.lower()
170
+
171
+ # Extract tickers
172
+ tickers = extract_tickers(query)
173
+
174
+ # Detect asset classes
175
+ asset_classes = [detect_asset_class(t) for t in tickers]
176
+ asset_classes = list(set(asset_classes)) # Unique classes
177
+
178
+ # Detect deliverable type
179
+ deliverable = self._detect_deliverable(query_lower)
180
+
181
+ # Detect time horizon
182
+ horizon = self._detect_horizon(query_lower)
183
+
184
+ # Detect risk profile
185
+ risk_profile = self._detect_risk_profile(query_lower)
186
+
187
+ # Detect constraints
188
+ constraints = self._detect_constraints(query_lower)
189
+
190
+ # Detect account type
191
+ account_type = self._detect_account_type(query_lower)
192
+
193
+ # Detect leverage
194
+ leverage_allowed = "leverage" in query_lower and "no leverage" not in query_lower
195
+ max_leverage = self._extract_leverage(query_lower) if leverage_allowed else 1.0
196
+
197
+ # Detect benchmark
198
+ benchmark = self._detect_benchmark(query_lower, tickers)
199
+
200
+ # Detect date range
201
+ start_date, end_date, lookback = self._detect_dates(query_lower)
202
+
203
+ # Generate goal summary
204
+ goal = self._generate_goal(query, deliverable)
205
+
206
+ # Check for clarifications needed
207
+ clarifications = self._check_clarifications(
208
+ query, tickers, deliverable, horizon, constraints
209
+ )
210
+
211
+ return ResearchPlan(
212
+ goal=goal,
213
+ assets=tickers,
214
+ asset_classes=asset_classes,
215
+ horizon=horizon,
216
+ benchmark=benchmark,
217
+ risk_profile=risk_profile,
218
+ constraints=constraints,
219
+ deliverable=deliverable,
220
+ account_type=account_type,
221
+ leverage_allowed=leverage_allowed,
222
+ max_leverage=max_leverage,
223
+ tax_aware="tax" in query_lower,
224
+ start_date=start_date,
225
+ end_date=end_date,
226
+ lookback_period=lookback,
227
+ clarifications_needed=clarifications,
228
+ context={"original_query": query},
229
+ )
230
+
231
+ def _detect_deliverable(self, text: str) -> DeliverableType:
232
+ """Detect the type of deliverable requested."""
233
+ for dtype, patterns in DELIVERABLE_PATTERNS.items():
234
+ for pattern in patterns:
235
+ if re.search(pattern, text):
236
+ return dtype
237
+ return DeliverableType.ANALYSIS
238
+
239
+ def _detect_horizon(self, text: str) -> TimeHorizon:
240
+ """Detect time horizon from text."""
241
+ for horizon, patterns in HORIZON_PATTERNS.items():
242
+ for pattern in patterns:
243
+ if re.search(pattern, text):
244
+ return horizon
245
+ return self.default_horizon
246
+
247
+ def _detect_risk_profile(self, text: str) -> RiskProfile:
248
+ """Detect risk profile from text."""
249
+ for profile, patterns in RISK_PATTERNS.items():
250
+ for pattern in patterns:
251
+ if re.search(pattern, text):
252
+ return profile
253
+ return self.default_risk
254
+
255
+ def _detect_constraints(self, text: str) -> List[Constraint]:
256
+ """Extract constraints from text."""
257
+ constraints = []
258
+
259
+ for pattern, constraint_type in CONSTRAINT_PATTERNS:
260
+ match = re.search(pattern, text)
261
+ if match:
262
+ value = float(match.group(1)) if match.groups() else 1.0
263
+
264
+ # Normalize percentage values
265
+ if constraint_type in ["max_weight", "max_drawdown", "max_turnover", "sector_cap"]:
266
+ if value > 1: # Assume it's a percentage
267
+ value = value / 100
268
+
269
+ constraints.append(Constraint(
270
+ name=constraint_type,
271
+ type=constraint_type,
272
+ value=value,
273
+ ))
274
+
275
+ return constraints
276
+
277
+ def _detect_account_type(self, text: str) -> Optional[str]:
278
+ """Detect account type from text."""
279
+ for account, patterns in ACCOUNT_PATTERNS.items():
280
+ for pattern in patterns:
281
+ if re.search(pattern, text):
282
+ return account
283
+ return None
284
+
285
+ def _extract_leverage(self, text: str) -> float:
286
+ """Extract leverage multiplier from text."""
287
+ match = re.search(r"(\d+(?:\.\d+)?)\s*x\s*leverage", text)
288
+ if match:
289
+ return float(match.group(1))
290
+
291
+ match = re.search(r"leverage\s*(?:of\s*)?(\d+(?:\.\d+)?)", text)
292
+ if match:
293
+ return float(match.group(1))
294
+
295
+ return 2.0 # Default leverage if mentioned but not specified
296
+
297
+ def _detect_benchmark(self, text: str, tickers: List[str]) -> str:
298
+ """Detect benchmark from text or infer from context."""
299
+ # Explicit benchmark mention
300
+ match = re.search(r"(?:benchmark|vs|against|compared to)\s+([A-Z]{2,5})", text.upper())
301
+ if match:
302
+ return match.group(1)
303
+
304
+ # Infer from asset classes
305
+ if any(detect_asset_class(t) == AssetClass.CRYPTO for t in tickers):
306
+ return "BTC"
307
+ elif any(detect_asset_class(t) in [AssetClass.RATES, AssetClass.COMMODITY] for t in tickers):
308
+ return "SPY" # Default
309
+
310
+ return self.default_benchmark
311
+
312
+ def _detect_dates(self, text: str) -> Tuple[Optional[date], Optional[date], str]:
313
+ """Detect date range from text."""
314
+ today = date.today()
315
+
316
+ # Explicit date patterns
317
+ date_match = re.search(
318
+ r"from\s+(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})",
319
+ text
320
+ )
321
+ if date_match:
322
+ return (
323
+ date.fromisoformat(date_match.group(1)),
324
+ date.fromisoformat(date_match.group(2)),
325
+ ""
326
+ )
327
+
328
+ # Lookback patterns
329
+ lookback_patterns = [
330
+ (r"(\d+)\s*years?", lambda m: f"{m.group(1)}y"),
331
+ (r"(\d+)\s*months?", lambda m: f"{m.group(1)}mo"),
332
+ (r"(\d+)\s*weeks?", lambda m: f"{m.group(1)}w"),
333
+ (r"(\d+)\s*days?", lambda m: f"{m.group(1)}d"),
334
+ (r"\b(ytd|mtd)\b", lambda m: m.group(1)),
335
+ ]
336
+
337
+ for pattern, extractor in lookback_patterns:
338
+ match = re.search(pattern, text)
339
+ if match:
340
+ return None, None, extractor(match)
341
+
342
+ # Default lookback
343
+ return None, None, "2y"
344
+
345
+ def _generate_goal(self, query: str, deliverable: DeliverableType) -> str:
346
+ """Generate a concise goal statement."""
347
+ # Clean and truncate query
348
+ goal = query.strip()
349
+ if len(goal) > 200:
350
+ goal = goal[:197] + "..."
351
+ return goal
352
+
353
+ def _check_clarifications(
354
+ self,
355
+ query: str,
356
+ tickers: List[str],
357
+ deliverable: DeliverableType,
358
+ horizon: TimeHorizon,
359
+ constraints: List[Constraint],
360
+ ) -> List[str]:
361
+ """Check if clarifications are needed."""
362
+ clarifications = []
363
+
364
+ # No tickers found
365
+ if not tickers and deliverable in [
366
+ DeliverableType.ANALYSIS,
367
+ DeliverableType.COMPARISON,
368
+ DeliverableType.BACKTEST,
369
+ ]:
370
+ clarifications.append("Which ticker(s) would you like to analyze?")
371
+
372
+ # Vague comparison
373
+ if deliverable == DeliverableType.COMPARISON and len(tickers) < 2:
374
+ clarifications.append("Please specify at least two assets to compare.")
375
+
376
+ # Strategy without specifics
377
+ if deliverable == DeliverableType.STRATEGY and "strategy" in query.lower():
378
+ if not any(word in query.lower() for word in ["momentum", "trend", "mean reversion", "value", "carry"]):
379
+ clarifications.append("What type of strategy are you interested in? (momentum, mean reversion, value, trend following, etc.)")
380
+
381
+ # Backtest without strategy
382
+ if deliverable == DeliverableType.BACKTEST:
383
+ strategy_words = ["sma", "ema", "rsi", "macd", "momentum", "mean reversion", "crossover"]
384
+ if not any(word in query.lower() for word in strategy_words):
385
+ clarifications.append("Which strategy would you like to backtest?")
386
+
387
+ return clarifications
388
+
389
+
390
+ # ============================================================================
391
+ # PROMPT TEMPLATES
392
+ # ============================================================================
393
+
394
+ class PromptPresets:
395
+ """Pre-built prompt templates for common tasks."""
396
+
397
+ PRESETS = {
398
+ "etf_due_diligence": {
399
+ "name": "ETF Due Diligence",
400
+ "description": "Comprehensive analysis of an ETF",
401
+ "template": "Perform a comprehensive due diligence on {ticker}: holdings analysis, concentration risk, factor tilts, fees, tracking error, liquidity, and how it compares to alternatives.",
402
+ },
403
+ "pairs_trade": {
404
+ "name": "Pairs Trade Analysis",
405
+ "description": "Statistical arbitrage pair analysis",
406
+ "template": "Analyze {ticker1} vs {ticker2} as a pairs trade: cointegration test, spread analysis, optimal hedge ratio, entry/exit signals, and historical performance.",
407
+ },
408
+ "defensive_portfolio": {
409
+ "name": "Defensive Portfolio",
410
+ "description": "Low-volatility, capital preservation focus",
411
+ "template": "Build a defensive portfolio with {tickers}: minimize drawdown, target volatility < 10%, focus on quality and low-beta stocks, suggest hedges.",
412
+ },
413
+ "momentum_rotation": {
414
+ "name": "Momentum Rotation",
415
+ "description": "Sector/asset momentum strategy",
416
+ "template": "Design a momentum rotation strategy for {tickers}: rank by momentum score, rebalance frequency, position sizing, and backtest with transaction costs.",
417
+ },
418
+ "earnings_preview": {
419
+ "name": "Earnings Preview",
420
+ "description": "Pre-earnings analysis",
421
+ "template": "Earnings preview for {ticker}: expected move from options, historical earnings reactions, consensus estimates, key metrics to watch, and trade setup.",
422
+ },
423
+ "risk_assessment": {
424
+ "name": "Risk Assessment",
425
+ "description": "Comprehensive risk analysis",
426
+ "template": "Full risk assessment of {ticker}: factor exposures, tail risk metrics, stress test scenarios, correlation to macro factors, and hedging suggestions.",
427
+ },
428
+ "valuation_deep_dive": {
429
+ "name": "Valuation Deep Dive",
430
+ "description": "Fundamental valuation analysis",
431
+ "template": "Deep dive valuation of {ticker}: DCF model assumptions, comparable analysis, margin of safety, key value drivers, and sensitivity analysis.",
432
+ },
433
+ "sector_rotation": {
434
+ "name": "Sector Rotation",
435
+ "description": "Sector allocation strategy",
436
+ "template": "Sector rotation analysis: current sector momentum, macro environment assessment, recommended tilts, and sector ETF suggestions.",
437
+ },
438
+ }
439
+
440
+ @classmethod
441
+ def get_preset(cls, name: str, **kwargs) -> Optional[str]:
442
+ """Get a preset template with filled parameters."""
443
+ if name not in cls.PRESETS:
444
+ return None
445
+
446
+ preset = cls.PRESETS[name]
447
+ template = preset["template"]
448
+
449
+ # Fill in placeholders
450
+ for key, value in kwargs.items():
451
+ if isinstance(value, list):
452
+ value = ", ".join(value)
453
+ template = template.replace(f"{{{key}}}", str(value))
454
+
455
+ return template
456
+
457
+ @classmethod
458
+ def list_presets(cls) -> List[Dict[str, str]]:
459
+ """List all available presets."""
460
+ return [
461
+ {"name": key, **value}
462
+ for key, value in cls.PRESETS.items()
463
+ ]
464
+
465
+
466
+ # ============================================================================
467
+ # DECISIVENESS ENGINE
468
+ # ============================================================================
469
+
470
+ class DecisivenessEngine:
471
+ """Convert vague prompts into measurable criteria and score tradeoffs."""
472
+
473
+ # Mapping of vague terms to measurable criteria
474
+ VAGUE_TO_MEASURABLE = {
475
+ "safer": ["lower volatility", "smaller max drawdown", "higher Sharpe ratio", "lower beta"],
476
+ "better": ["higher Sharpe ratio", "higher total return", "lower max drawdown"],
477
+ "riskier": ["higher volatility", "larger max drawdown", "higher beta"],
478
+ "stable": ["lower volatility", "smaller drawdowns", "consistent returns"],
479
+ "growth": ["higher CAGR", "higher momentum", "higher earnings growth"],
480
+ "value": ["lower P/E", "lower P/B", "higher dividend yield"],
481
+ "defensive": ["lower beta", "lower volatility", "smaller drawdowns"],
482
+ "aggressive": ["higher beta", "higher volatility", "higher returns"],
483
+ "liquid": ["higher average volume", "tighter spreads", "larger market cap"],
484
+ "diversified": ["lower concentration", "more holdings", "lower correlation"],
485
+ }
486
+
487
+ @classmethod
488
+ def translate_vague_query(cls, query: str) -> Dict[str, Any]:
489
+ """Translate vague query into measurable criteria."""
490
+ query_lower = query.lower()
491
+
492
+ criteria = []
493
+ weights = {}
494
+
495
+ for vague_term, measurables in cls.VAGUE_TO_MEASURABLE.items():
496
+ if vague_term in query_lower:
497
+ criteria.extend(measurables)
498
+ for m in measurables:
499
+ weights[m] = weights.get(m, 0) + 1
500
+
501
+ # Normalize weights
502
+ total = sum(weights.values()) or 1
503
+ weights = {k: v / total for k, v in weights.items()}
504
+
505
+ return {
506
+ "criteria": list(set(criteria)),
507
+ "weights": weights,
508
+ "interpretation": cls._generate_interpretation(criteria),
509
+ }
510
+
511
+ @classmethod
512
+ def _generate_interpretation(cls, criteria: List[str]) -> str:
513
+ """Generate human-readable interpretation."""
514
+ if not criteria:
515
+ return "No specific criteria detected. Using balanced evaluation."
516
+
517
+ return f"Evaluating based on: {', '.join(criteria[:5])}"
518
+
519
+ @classmethod
520
+ def score_assets(
521
+ cls,
522
+ assets: List[str],
523
+ metrics: Dict[str, Dict[str, float]],
524
+ weights: Dict[str, float],
525
+ ) -> List[Dict[str, Any]]:
526
+ """Score and rank assets based on criteria."""
527
+ scores = []
528
+
529
+ metric_mapping = {
530
+ "lower volatility": ("volatility", -1),
531
+ "smaller max drawdown": ("max_drawdown", -1),
532
+ "higher Sharpe ratio": ("sharpe_ratio", 1),
533
+ "lower beta": ("beta", -1),
534
+ "higher total return": ("total_return", 1),
535
+ "higher CAGR": ("cagr", 1),
536
+ "higher momentum": ("momentum", 1),
537
+ "lower P/E": ("pe_ratio", -1),
538
+ "higher dividend yield": ("dividend_yield", 1),
539
+ }
540
+
541
+ for asset in assets:
542
+ asset_metrics = metrics.get(asset, {})
543
+ score = 0
544
+ details = {}
545
+
546
+ for criterion, weight in weights.items():
547
+ if criterion in metric_mapping:
548
+ metric_name, direction = metric_mapping[criterion]
549
+ value = asset_metrics.get(metric_name, 0)
550
+ contribution = value * direction * weight
551
+ score += contribution
552
+ details[criterion] = {
553
+ "value": value,
554
+ "contribution": contribution,
555
+ }
556
+
557
+ scores.append({
558
+ "asset": asset,
559
+ "total_score": score,
560
+ "details": details,
561
+ })
562
+
563
+ # Sort by score
564
+ scores.sort(key=lambda x: x["total_score"], reverse=True)
565
+
566
+ return scores
567
+
568
+ @classmethod
569
+ def explain_tradeoffs(
570
+ cls,
571
+ scores: List[Dict[str, Any]],
572
+ criteria: List[str],
573
+ ) -> str:
574
+ """Generate tradeoff explanation."""
575
+ if len(scores) < 2:
576
+ return "Not enough assets to compare tradeoffs."
577
+
578
+ best = scores[0]
579
+ second = scores[1]
580
+
581
+ explanations = []
582
+ explanations.append(f"{best['asset']} ranks highest overall.")
583
+
584
+ # Find where second asset is better
585
+ for criterion in criteria:
586
+ if criterion in best["details"] and criterion in second["details"]:
587
+ best_val = best["details"][criterion]["value"]
588
+ second_val = second["details"][criterion]["value"]
589
+
590
+ if abs(second_val) > abs(best_val):
591
+ explanations.append(
592
+ f"However, {second['asset']} has better {criterion} ({second_val:.2f} vs {best_val:.2f})."
593
+ )
594
+
595
+ return " ".join(explanations)