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.
- fin_infra/analytics/__init__.py +24 -0
- fin_infra/analytics/benchmark.py +594 -0
- fin_infra/analytics/ease.py +33 -2
- fin_infra/analytics/models.py +3 -0
- fin_infra/analytics/portfolio.py +113 -23
- fin_infra/analytics/rebalancing.py +50 -4
- fin_infra/analytics/rebalancing_llm.py +710 -0
- fin_infra/categorization/llm_layer.py +1 -1
- fin_infra/insights/__init__.py +2 -1
- fin_infra/insights/aggregator.py +106 -45
- fin_infra/models/brokerage.py +1 -0
- {fin_infra-0.6.0.dist-info → fin_infra-0.7.0.dist-info}/METADATA +7 -1
- {fin_infra-0.6.0.dist-info → fin_infra-0.7.0.dist-info}/RECORD +16 -14
- {fin_infra-0.6.0.dist-info → fin_infra-0.7.0.dist-info}/LICENSE +0 -0
- {fin_infra-0.6.0.dist-info → fin_infra-0.7.0.dist-info}/WHEEL +0 -0
- {fin_infra-0.6.0.dist-info → fin_infra-0.7.0.dist-info}/entry_points.txt +0 -0
fin_infra/analytics/__init__.py
CHANGED
|
@@ -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
|
fin_infra/analytics/ease.py
CHANGED
|
@@ -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", "
|
|
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(
|
fin_infra/analytics/models.py
CHANGED
|
@@ -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):
|