fin-infra 0.6.0__py3-none-any.whl → 0.7.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.
@@ -57,11 +57,35 @@ from __future__ import annotations
57
57
 
58
58
  from .add import add_analytics
59
59
 
60
+ # Import benchmark functions for direct access
61
+ from .benchmark import (
62
+ COMMON_BENCHMARKS,
63
+ BenchmarkDataPoint,
64
+ BenchmarkHistory,
65
+ PortfolioVsBenchmark,
66
+ compare_portfolio_to_benchmark,
67
+ get_benchmark_history,
68
+ is_common_benchmark,
69
+ list_common_benchmarks,
70
+ )
71
+
60
72
  # Import actual implementations
61
73
  from .ease import AnalyticsEngine, easy_analytics
62
74
 
63
75
  __all__ = [
76
+ # Easy setup
64
77
  "easy_analytics",
65
78
  "add_analytics",
66
79
  "AnalyticsEngine",
80
+ # Benchmark functions (real market data - accepts ANY ticker)
81
+ "get_benchmark_history",
82
+ "compare_portfolio_to_benchmark",
83
+ # Reference list of common benchmarks (not a restriction)
84
+ "COMMON_BENCHMARKS",
85
+ "list_common_benchmarks",
86
+ "is_common_benchmark",
87
+ # Benchmark models
88
+ "BenchmarkHistory",
89
+ "BenchmarkDataPoint",
90
+ "PortfolioVsBenchmark",
67
91
  ]
@@ -0,0 +1,594 @@
1
+ """Benchmark comparison and historical performance analysis.
2
+
3
+ Provides real market data integration for portfolio vs benchmark comparisons.
4
+ Uses fin-infra's market data providers (easy_market) for historical prices.
5
+
6
+ Generic Applicability:
7
+ - Personal finance apps: Portfolio performance tracking
8
+ - Wealth management: Client reporting and benchmarking
9
+ - Robo-advisors: Automated performance attribution
10
+ - Investment platforms: Historical chart data
11
+ - Financial advisors: Performance comparison reports
12
+
13
+ Features:
14
+ - Real historical prices from market data providers (Yahoo Finance, Alpha Vantage)
15
+ - Time-series data for charting (normalized to 100)
16
+ - Alpha, beta, and Sharpe ratio calculations
17
+ - Multi-benchmark support (SPY, QQQ, VTI, BND, custom)
18
+ - Caching-friendly design (keyword-only arguments)
19
+
20
+ Examples:
21
+ >>> from fin_infra.analytics.benchmark import (
22
+ ... get_benchmark_history,
23
+ ... compare_portfolio_to_benchmark,
24
+ ... )
25
+ >>>
26
+ >>> # Get historical benchmark data for charting
27
+ >>> history = await get_benchmark_history("SPY", period="1y")
28
+ >>> print(f"SPY 1Y return: {history.total_return_percent:.2f}%")
29
+ >>>
30
+ >>> # Compare portfolio to benchmark
31
+ >>> comparison = await compare_portfolio_to_benchmark(
32
+ ... portfolio_history=[...], # List of portfolio snapshots
33
+ ... benchmark="SPY",
34
+ ... period="1y",
35
+ ... )
36
+ >>> print(f"Alpha: {comparison.alpha:.2f}%")
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import datetime as dt
42
+ import logging
43
+ from collections.abc import Sequence
44
+ from typing import TYPE_CHECKING
45
+
46
+ from pydantic import BaseModel, ConfigDict, Field
47
+
48
+ if TYPE_CHECKING:
49
+ from ..providers.base import MarketDataProvider
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+
54
+ # ============================================================================
55
+ # Models
56
+ # ============================================================================
57
+
58
+
59
+ class BenchmarkDataPoint(BaseModel):
60
+ """Single data point for benchmark time series."""
61
+
62
+ model_config = ConfigDict(extra="forbid")
63
+
64
+ date: dt.date = Field(..., description="Date of the data point")
65
+ close: float = Field(..., description="Closing price")
66
+ normalized: float = Field(..., description="Normalized value (starting at 100)")
67
+ return_pct: float = Field(..., description="Return percentage from start")
68
+
69
+
70
+ class BenchmarkHistory(BaseModel):
71
+ """Historical benchmark data for a given period.
72
+
73
+ Designed for chart visualization with normalized values starting at 100.
74
+ """
75
+
76
+ model_config = ConfigDict(extra="forbid")
77
+
78
+ symbol: str = Field(..., description="Benchmark ticker symbol (e.g., SPY)")
79
+ period: str = Field(..., description="Time period (1m, 3m, 6m, 1y, ytd, all)")
80
+ start_date: dt.date = Field(..., description="Start date of the period")
81
+ end_date: dt.date = Field(..., description="End date of the period")
82
+ data_points: list[BenchmarkDataPoint] = Field(
83
+ default_factory=list, description="Time series data points"
84
+ )
85
+ start_price: float = Field(..., description="Starting price")
86
+ end_price: float = Field(..., description="Ending price")
87
+ total_return_percent: float = Field(..., description="Total return for period (%)")
88
+ annualized_return_percent: float | None = Field(None, description="Annualized return (%)")
89
+
90
+
91
+ class PortfolioVsBenchmark(BaseModel):
92
+ """Complete portfolio vs benchmark comparison with time series.
93
+
94
+ Provides all data needed for performance comparison charts and summaries.
95
+ """
96
+
97
+ model_config = ConfigDict(extra="forbid")
98
+
99
+ benchmark_symbol: str = Field(..., description="Benchmark ticker symbol")
100
+ period: str = Field(..., description="Comparison period")
101
+ start_date: dt.date = Field(..., description="Start date")
102
+ end_date: dt.date = Field(..., description="End date")
103
+
104
+ # Summary metrics
105
+ portfolio_return_percent: float = Field(..., description="Portfolio total return (%)")
106
+ benchmark_return_percent: float = Field(..., description="Benchmark total return (%)")
107
+ alpha: float = Field(..., description="Excess return vs benchmark (%)")
108
+ beta: float | None = Field(None, description="Portfolio beta vs benchmark")
109
+ sharpe_ratio: float | None = Field(None, description="Portfolio Sharpe ratio")
110
+
111
+ # Time series for charting
112
+ portfolio_series: list[BenchmarkDataPoint] = Field(
113
+ default_factory=list, description="Portfolio normalized time series"
114
+ )
115
+ benchmark_series: list[BenchmarkDataPoint] = Field(
116
+ default_factory=list, description="Benchmark normalized time series"
117
+ )
118
+
119
+
120
+ # ============================================================================
121
+ # Common Benchmarks (Reference Only)
122
+ # ============================================================================
123
+
124
+ # This is a reference list of commonly used benchmarks.
125
+ # fin-infra does NOT restrict which tickers can be used - any valid ticker works.
126
+ # Application layers (fin-api, fin-web) should define their own allowed lists.
127
+ COMMON_BENCHMARKS = {
128
+ "SPY": "S&P 500 (SPDR)",
129
+ "QQQ": "Nasdaq 100 (Invesco)",
130
+ "VTI": "Total US Stock Market (Vanguard)",
131
+ "BND": "Total Bond Market (Vanguard)",
132
+ "VT": "Total World Stock (Vanguard)",
133
+ "AGG": "US Aggregate Bond (iShares)",
134
+ "IWM": "Russell 2000 (iShares)",
135
+ "EFA": "EAFE International (iShares)",
136
+ "VNQ": "Real Estate (Vanguard)",
137
+ "GLD": "Gold (SPDR)",
138
+ }
139
+
140
+
141
+ # ============================================================================
142
+ # Period Parsing
143
+ # ============================================================================
144
+
145
+
146
+ def parse_period_to_days(period: str) -> int:
147
+ """Parse period string to number of days.
148
+
149
+ Args:
150
+ period: Period string (1d, 1w, 1m, 3m, 6m, 1y, 2y, 5y, ytd, all)
151
+
152
+ Returns:
153
+ Number of calendar days
154
+
155
+ Raises:
156
+ ValueError: Invalid period format
157
+ """
158
+ period = period.lower().strip()
159
+
160
+ if period == "ytd":
161
+ today = dt.date.today()
162
+ year_start = dt.date(today.year, 1, 1)
163
+ return (today - year_start).days
164
+
165
+ if period == "all" or period == "max":
166
+ return 365 * 10 # 10 years max
167
+
168
+ # Parse numeric periods
169
+ if period.endswith("d"):
170
+ return int(period[:-1])
171
+ elif period.endswith("w"):
172
+ return int(period[:-1]) * 7
173
+ elif period.endswith("m"):
174
+ return int(period[:-1]) * 30
175
+ elif period.endswith("y"):
176
+ return int(period[:-1]) * 365
177
+
178
+ raise ValueError(
179
+ f"Invalid period format: {period}. Use: 1d, 1w, 1m, 3m, 6m, 1y, 2y, 5y, ytd, all"
180
+ )
181
+
182
+
183
+ def period_to_market_period(period: str) -> str:
184
+ """Convert our period format to market provider period format.
185
+
186
+ Args:
187
+ period: Our period format (1m, 3m, 6m, 1y, ytd, all)
188
+
189
+ Returns:
190
+ Market provider period format (1mo, 3mo, 6mo, 1y, ytd, max)
191
+ """
192
+ period = period.lower().strip()
193
+
194
+ # Map our periods to yahooquery/provider periods
195
+ period_map = {
196
+ "1d": "1d",
197
+ "5d": "5d",
198
+ "1w": "5d",
199
+ "1m": "1mo",
200
+ "3m": "3mo",
201
+ "6m": "6mo",
202
+ "1y": "1y",
203
+ "2y": "2y",
204
+ "5y": "5y",
205
+ "10y": "10y",
206
+ "ytd": "ytd",
207
+ "all": "max",
208
+ "max": "max",
209
+ }
210
+
211
+ return period_map.get(period, "1y")
212
+
213
+
214
+ # ============================================================================
215
+ # Core Functions
216
+ # ============================================================================
217
+
218
+
219
+ async def get_benchmark_history(
220
+ symbol: str,
221
+ *,
222
+ period: str = "1y",
223
+ market_provider: MarketDataProvider | None = None,
224
+ ) -> BenchmarkHistory:
225
+ """Fetch historical benchmark data with normalized values for charting.
226
+
227
+ This function fetches real market data from fin-infra's market data providers
228
+ and returns a time series normalized to 100 for easy comparison charting.
229
+
230
+ Args:
231
+ symbol: Benchmark ticker symbol (SPY, QQQ, VTI, BND, etc.)
232
+ period: Time period (1d, 1w, 1m, 3m, 6m, 1y, 2y, 5y, ytd, all)
233
+ market_provider: Optional market data provider instance.
234
+ If None, creates one using easy_market().
235
+
236
+ Returns:
237
+ BenchmarkHistory with normalized time series and summary metrics
238
+
239
+ Raises:
240
+ ValueError: Invalid symbol or period
241
+ Exception: Market data provider errors
242
+
243
+ Examples:
244
+ >>> # Using auto-configured provider
245
+ >>> history = await get_benchmark_history("SPY", period="1y")
246
+ >>> print(f"SPY 1Y return: {history.total_return_percent:.2f}%")
247
+ >>>
248
+ >>> # With custom provider
249
+ >>> from fin_infra.markets import easy_market
250
+ >>> market = easy_market(provider="yahoo")
251
+ >>> history = await get_benchmark_history("QQQ", period="6m", market_provider=market)
252
+ """
253
+ # Create market provider if not provided
254
+ if market_provider is None:
255
+ from ..markets import easy_market
256
+
257
+ market_provider = easy_market()
258
+
259
+ # Validate symbol
260
+ symbol = symbol.upper()
261
+
262
+ # Get market period format
263
+ market_period = period_to_market_period(period)
264
+
265
+ logger.info(f"[Benchmark] Fetching {symbol} history for period={period} ({market_period})")
266
+
267
+ # Fetch historical candles from market provider
268
+ candles = market_provider.history(symbol, period=market_period, interval="1d")
269
+
270
+ if not candles:
271
+ raise ValueError(f"No historical data returned for {symbol}")
272
+
273
+ # Sort candles by timestamp (oldest first for normalization)
274
+ candles_sorted = sorted(candles, key=lambda c: c.ts)
275
+
276
+ # Get first and last prices for normalization
277
+ first_candle = candles_sorted[0]
278
+ last_candle = candles_sorted[-1]
279
+ first_price = float(first_candle.close)
280
+ last_price = float(last_candle.close)
281
+
282
+ if first_price <= 0:
283
+ raise ValueError(f"Invalid starting price for {symbol}: {first_price}")
284
+
285
+ # Calculate total return
286
+ total_return_pct = ((last_price - first_price) / first_price) * 100
287
+
288
+ # Calculate annualized return
289
+ days_in_period = parse_period_to_days(period)
290
+ if days_in_period >= 365:
291
+ years = days_in_period / 365
292
+ annualized_return = ((1 + total_return_pct / 100) ** (1 / years) - 1) * 100
293
+ else:
294
+ annualized_return = None
295
+
296
+ # Build normalized data points
297
+ data_points: list[BenchmarkDataPoint] = []
298
+ for candle in candles_sorted:
299
+ # Convert timestamp to date
300
+ candle_dt = dt.datetime.fromtimestamp(candle.ts / 1000, tz=dt.UTC)
301
+ close_price = float(candle.close)
302
+
303
+ # Normalize to 100
304
+ normalized = (close_price / first_price) * 100
305
+ return_pct = normalized - 100
306
+
307
+ data_points.append(
308
+ BenchmarkDataPoint(
309
+ date=candle_dt.date(),
310
+ close=close_price,
311
+ normalized=round(normalized, 2),
312
+ return_pct=round(return_pct, 2),
313
+ )
314
+ )
315
+
316
+ # Determine start and end dates
317
+ start_date = data_points[0].date if data_points else dt.date.today()
318
+ end_date = data_points[-1].date if data_points else dt.date.today()
319
+
320
+ logger.info(
321
+ f"[Benchmark] {symbol}: {len(data_points)} points, "
322
+ f"{start_date} to {end_date}, return={total_return_pct:.2f}%"
323
+ )
324
+
325
+ return BenchmarkHistory(
326
+ symbol=symbol,
327
+ period=period,
328
+ start_date=start_date,
329
+ end_date=end_date,
330
+ data_points=data_points,
331
+ start_price=first_price,
332
+ end_price=last_price,
333
+ total_return_percent=round(total_return_pct, 2),
334
+ annualized_return_percent=round(annualized_return, 2) if annualized_return else None,
335
+ )
336
+
337
+
338
+ async def compare_portfolio_to_benchmark(
339
+ portfolio_values: Sequence[tuple[dt.date, float]],
340
+ *,
341
+ benchmark: str = "SPY",
342
+ period: str | None = None,
343
+ market_provider: MarketDataProvider | None = None,
344
+ risk_free_rate: float = 0.03,
345
+ ) -> PortfolioVsBenchmark:
346
+ """Compare portfolio performance to a benchmark index.
347
+
348
+ Takes portfolio historical values and compares them to benchmark performance,
349
+ calculating alpha, beta, and providing normalized time series for charting.
350
+
351
+ Args:
352
+ portfolio_values: List of (date, value) tuples representing portfolio history.
353
+ Values should be total portfolio value on each date.
354
+ benchmark: Benchmark ticker symbol (default: SPY)
355
+ period: Time period override. If None, uses the date range from portfolio_values.
356
+ market_provider: Optional market data provider instance.
357
+ risk_free_rate: Annual risk-free rate for Sharpe calculation (default: 0.03 = 3%)
358
+
359
+ Returns:
360
+ PortfolioVsBenchmark with comparison metrics and time series
361
+
362
+ Raises:
363
+ ValueError: Invalid input or insufficient data
364
+
365
+ Examples:
366
+ >>> # Compare portfolio to S&P 500
367
+ >>> portfolio_history = [
368
+ ... (date(2024, 1, 1), 100000),
369
+ ... (date(2024, 2, 1), 102000),
370
+ ... (date(2024, 3, 1), 105000),
371
+ ... # ...
372
+ ... ]
373
+ >>> comparison = await compare_portfolio_to_benchmark(
374
+ ... portfolio_history,
375
+ ... benchmark="SPY",
376
+ ... )
377
+ >>> print(f"Alpha: {comparison.alpha:.2f}%")
378
+ >>> print(f"Portfolio: {comparison.portfolio_return_percent:.2f}%")
379
+ >>> print(f"Benchmark: {comparison.benchmark_return_percent:.2f}%")
380
+ """
381
+ if not portfolio_values:
382
+ raise ValueError("portfolio_values cannot be empty")
383
+
384
+ # Sort by date
385
+ sorted_values = sorted(portfolio_values, key=lambda x: x[0])
386
+ start_date = sorted_values[0][0]
387
+ end_date = sorted_values[-1][0]
388
+
389
+ # Calculate portfolio return
390
+ first_value = sorted_values[0][1]
391
+ last_value = sorted_values[-1][1]
392
+
393
+ if first_value <= 0:
394
+ raise ValueError(f"Invalid starting portfolio value: {first_value}")
395
+
396
+ portfolio_return_pct = ((last_value - first_value) / first_value) * 100
397
+
398
+ # Calculate period from data range if not specified
399
+ if period is None:
400
+ days_diff = (end_date - start_date).days
401
+ if days_diff <= 30:
402
+ period = "1m"
403
+ elif days_diff <= 90:
404
+ period = "3m"
405
+ elif days_diff <= 180:
406
+ period = "6m"
407
+ elif days_diff <= 365:
408
+ period = "1y"
409
+ else:
410
+ period = "all"
411
+
412
+ # Fetch benchmark history
413
+ benchmark_history = await get_benchmark_history(
414
+ benchmark,
415
+ period=period,
416
+ market_provider=market_provider,
417
+ )
418
+
419
+ benchmark_return_pct = benchmark_history.total_return_percent
420
+
421
+ # Calculate alpha (simple excess return)
422
+ alpha = portfolio_return_pct - benchmark_return_pct
423
+
424
+ # Calculate beta (requires daily returns - simplified calculation)
425
+ beta = _calculate_beta_simple(sorted_values, benchmark_history.data_points)
426
+
427
+ # Calculate Sharpe ratio (simplified - uses portfolio return vs risk-free)
428
+ # For proper Sharpe, would need daily returns and standard deviation
429
+ sharpe = _calculate_sharpe_simple(
430
+ portfolio_return_pct,
431
+ risk_free_rate * 100, # Convert to percentage
432
+ period,
433
+ )
434
+
435
+ # Build normalized portfolio series
436
+ portfolio_series: list[BenchmarkDataPoint] = []
437
+ for value_date, value in sorted_values:
438
+ normalized = (value / first_value) * 100
439
+ return_pct = normalized - 100
440
+ portfolio_series.append(
441
+ BenchmarkDataPoint(
442
+ date=value_date,
443
+ close=value,
444
+ normalized=round(normalized, 2),
445
+ return_pct=round(return_pct, 2),
446
+ )
447
+ )
448
+
449
+ return PortfolioVsBenchmark(
450
+ benchmark_symbol=benchmark,
451
+ period=period,
452
+ start_date=start_date,
453
+ end_date=end_date,
454
+ portfolio_return_percent=round(portfolio_return_pct, 2),
455
+ benchmark_return_percent=round(benchmark_return_pct, 2),
456
+ alpha=round(alpha, 2),
457
+ beta=round(beta, 2) if beta is not None else None,
458
+ sharpe_ratio=round(sharpe, 2) if sharpe is not None else None,
459
+ portfolio_series=portfolio_series,
460
+ benchmark_series=benchmark_history.data_points,
461
+ )
462
+
463
+
464
+ def _calculate_beta_simple(
465
+ portfolio_values: Sequence[tuple[dt.date, float]],
466
+ benchmark_points: Sequence[BenchmarkDataPoint],
467
+ ) -> float | None:
468
+ """Calculate simplified beta from value series.
469
+
470
+ Uses simplified covariance/variance calculation. For proper beta,
471
+ would need more sophisticated time series alignment and daily returns.
472
+
473
+ Returns None if insufficient data.
474
+ """
475
+ if len(portfolio_values) < 5 or len(benchmark_points) < 5:
476
+ return None
477
+
478
+ # Calculate daily returns for portfolio
479
+ portfolio_returns: list[float] = []
480
+ for i in range(1, len(portfolio_values)):
481
+ prev_val = portfolio_values[i - 1][1]
482
+ curr_val = portfolio_values[i][1]
483
+ if prev_val > 0:
484
+ ret = (curr_val - prev_val) / prev_val
485
+ portfolio_returns.append(ret)
486
+
487
+ # Calculate daily returns for benchmark
488
+ benchmark_returns: list[float] = []
489
+ for i in range(1, len(benchmark_points)):
490
+ prev_close = benchmark_points[i - 1].close
491
+ curr_close = benchmark_points[i].close
492
+ if prev_close > 0:
493
+ ret = (curr_close - prev_close) / prev_close
494
+ benchmark_returns.append(ret)
495
+
496
+ if len(portfolio_returns) < 3 or len(benchmark_returns) < 3:
497
+ return None
498
+
499
+ # Use the shorter length for alignment
500
+ n = min(len(portfolio_returns), len(benchmark_returns))
501
+ p_returns = portfolio_returns[-n:]
502
+ b_returns = benchmark_returns[-n:]
503
+
504
+ # Calculate means
505
+ p_mean = sum(p_returns) / n
506
+ b_mean = sum(b_returns) / n
507
+
508
+ # Calculate covariance and variance
509
+ covariance = sum((p - p_mean) * (b - b_mean) for p, b in zip(p_returns, b_returns)) / n
510
+ variance = sum((b - b_mean) ** 2 for b in b_returns) / n
511
+
512
+ if variance == 0:
513
+ return None
514
+
515
+ return covariance / variance
516
+
517
+
518
+ def _calculate_sharpe_simple(
519
+ portfolio_return_pct: float,
520
+ risk_free_rate_pct: float,
521
+ period: str,
522
+ ) -> float | None:
523
+ """Calculate simplified Sharpe ratio.
524
+
525
+ Uses a simplified volatility estimate based on period.
526
+ For proper Sharpe, would need daily return standard deviation.
527
+
528
+ Args:
529
+ portfolio_return_pct: Portfolio return percentage
530
+ risk_free_rate_pct: Risk-free rate percentage (annualized)
531
+ period: Time period for volatility estimation
532
+
533
+ Returns:
534
+ Simplified Sharpe ratio or None if cannot calculate
535
+ """
536
+ # Annualize the portfolio return if needed
537
+ days = parse_period_to_days(period)
538
+
539
+ if days < 30:
540
+ return None # Too short a period for meaningful Sharpe
541
+
542
+ # Annualize return
543
+ if days < 365:
544
+ annualized_return = portfolio_return_pct * (365 / days)
545
+ else:
546
+ years = days / 365
547
+ annualized_return = ((1 + portfolio_return_pct / 100) ** (1 / years) - 1) * 100
548
+
549
+ # Excess return
550
+ excess_return = annualized_return - risk_free_rate_pct
551
+
552
+ # Estimate volatility (rough estimate: 15% for diversified portfolio)
553
+ # This is a simplification - proper Sharpe needs actual std dev of returns
554
+ estimated_volatility = 15.0
555
+
556
+ if estimated_volatility == 0:
557
+ return None
558
+
559
+ return excess_return / estimated_volatility
560
+
561
+
562
+ # ============================================================================
563
+ # Utility Functions
564
+ # ============================================================================
565
+
566
+
567
+ def list_common_benchmarks() -> dict[str, str]:
568
+ """Return dictionary of commonly used benchmark symbols and names.
569
+
570
+ This is a REFERENCE list only. fin-infra allows ANY valid ticker
571
+ to be used as a benchmark via get_benchmark_history().
572
+
573
+ Application layers should define their own allowed lists if needed.
574
+
575
+ Returns:
576
+ Dict mapping symbol to full name (e.g., {"SPY": "S&P 500 (SPDR)"})
577
+ """
578
+ return COMMON_BENCHMARKS.copy()
579
+
580
+
581
+ def is_common_benchmark(symbol: str) -> bool:
582
+ """Check if a symbol is in the common benchmarks reference list.
583
+
584
+ Note: This does NOT validate whether a ticker is usable.
585
+ Any valid stock/ETF ticker can be used with get_benchmark_history().
586
+ This function only checks if it's in the commonly-used reference list.
587
+
588
+ Args:
589
+ symbol: Ticker symbol to check
590
+
591
+ Returns:
592
+ True if symbol is in the common benchmarks reference list
593
+ """
594
+ return symbol.upper() in COMMON_BENCHMARKS
@@ -252,17 +252,22 @@ class AnalyticsEngine:
252
252
  benchmark: str | None = None,
253
253
  period: str = "1y",
254
254
  accounts: list[str] | None = None,
255
+ portfolio_history: list[tuple] | None = None,
255
256
  ) -> BenchmarkComparison:
256
257
  """Compare portfolio to benchmark index.
257
258
 
259
+ Uses REAL market data from fin-infra's market data providers.
260
+
258
261
  Args:
259
262
  user_id: User identifier
260
263
  benchmark: Benchmark symbol (default: self.default_benchmark)
261
- period: Comparison period ("1y", "3y", "5y", "ytd", "max")
264
+ period: Comparison period ("1m", "3m", "6m", "1y", "2y", "5y", "ytd", "all")
262
265
  accounts: Optional list of account IDs to include
266
+ portfolio_history: Optional list of (date, value) tuples for portfolio history.
267
+ If not provided, will use brokerage_provider or mock data.
263
268
 
264
269
  Returns:
265
- BenchmarkComparison with alpha, beta, returns
270
+ BenchmarkComparison with alpha, beta, Sharpe ratio, and returns
266
271
  """
267
272
  if benchmark is None:
268
273
  benchmark = self.default_benchmark
@@ -274,6 +279,32 @@ class AnalyticsEngine:
274
279
  accounts=accounts,
275
280
  brokerage_provider=self.brokerage_provider,
276
281
  market_provider=self.market_provider,
282
+ portfolio_history=portfolio_history,
283
+ )
284
+
285
+ async def benchmark_history(
286
+ self,
287
+ symbol: str,
288
+ *,
289
+ period: str = "1y",
290
+ ):
291
+ """Get historical benchmark data for charting.
292
+
293
+ Returns normalized time series (starting at 100) for easy comparison charts.
294
+
295
+ Args:
296
+ symbol: Benchmark ticker symbol (SPY, QQQ, VTI, BND, etc.)
297
+ period: Time period ("1m", "3m", "6m", "1y", "2y", "5y", "ytd", "all")
298
+
299
+ Returns:
300
+ BenchmarkHistory with normalized time series and summary metrics
301
+ """
302
+ from .benchmark import get_benchmark_history
303
+
304
+ return await get_benchmark_history(
305
+ symbol,
306
+ period=period,
307
+ market_provider=self.market_provider,
277
308
  )
278
309
 
279
310
  async def net_worth_projection(
@@ -184,7 +184,10 @@ class BenchmarkComparison(BaseModel):
184
184
  benchmark_symbol: str = Field(..., description="Benchmark ticker (e.g., SPY)")
185
185
  alpha: float = Field(..., description="Portfolio alpha (excess return)")
186
186
  beta: float | None = Field(None, description="Portfolio beta (volatility vs benchmark)")
187
+ sharpe_ratio: float | None = Field(None, description="Risk-adjusted return")
187
188
  period: str = Field(..., description="Comparison period (1y, 3y, 5y, etc.)")
189
+ start_date: Any | None = Field(None, description="Comparison start date")
190
+ end_date: Any | None = Field(None, description="Comparison end date")
188
191
 
189
192
 
190
193
  class Scenario(BaseModel):