fundedness 0.1.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.
Potentially problematic release.
This version of fundedness might be problematic. Click here for more details.
- fundedness/__init__.py +38 -0
- fundedness/allocation/__init__.py +12 -0
- fundedness/allocation/base.py +32 -0
- fundedness/allocation/constant.py +25 -0
- fundedness/allocation/glidepath.py +111 -0
- fundedness/cefr.py +241 -0
- fundedness/liabilities.py +221 -0
- fundedness/liquidity.py +49 -0
- fundedness/models/__init__.py +35 -0
- fundedness/models/assets.py +148 -0
- fundedness/models/household.py +153 -0
- fundedness/models/liabilities.py +99 -0
- fundedness/models/market.py +188 -0
- fundedness/models/simulation.py +80 -0
- fundedness/models/tax.py +125 -0
- fundedness/models/utility.py +154 -0
- fundedness/policies.py +204 -0
- fundedness/risk.py +72 -0
- fundedness/simulate.py +401 -0
- fundedness/viz/__init__.py +19 -0
- fundedness/viz/colors.py +110 -0
- fundedness/viz/comparison.py +294 -0
- fundedness/viz/fan_chart.py +193 -0
- fundedness/viz/histogram.py +225 -0
- fundedness/viz/survival.py +230 -0
- fundedness/viz/tornado.py +236 -0
- fundedness/viz/waterfall.py +203 -0
- fundedness/withdrawals/__init__.py +19 -0
- fundedness/withdrawals/base.py +116 -0
- fundedness/withdrawals/comparison.py +230 -0
- fundedness/withdrawals/fixed_swr.py +113 -0
- fundedness/withdrawals/guardrails.py +136 -0
- fundedness/withdrawals/rmd_style.py +203 -0
- fundedness/withdrawals/vpw.py +136 -0
- fundedness-0.1.0.dist-info/METADATA +233 -0
- fundedness-0.1.0.dist-info/RECORD +38 -0
- fundedness-0.1.0.dist-info/WHEEL +4 -0
- fundedness-0.1.0.dist-info/entry_points.txt +2 -0
fundedness/simulate.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""Monte Carlo simulation engine for retirement projections."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import stats
|
|
7
|
+
|
|
8
|
+
from fundedness.models.market import MarketModel
|
|
9
|
+
from fundedness.models.simulation import SimulationConfig
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SimulationResult:
|
|
14
|
+
"""Results from a Monte Carlo simulation."""
|
|
15
|
+
|
|
16
|
+
# Core paths (shape: n_simulations x n_years)
|
|
17
|
+
wealth_paths: np.ndarray
|
|
18
|
+
spending_paths: np.ndarray | None = None
|
|
19
|
+
|
|
20
|
+
# Time metrics (shape: n_simulations)
|
|
21
|
+
time_to_ruin: np.ndarray | None = None
|
|
22
|
+
time_to_floor_breach: np.ndarray | None = None
|
|
23
|
+
|
|
24
|
+
# Percentile summaries (shape: n_percentiles x n_years)
|
|
25
|
+
wealth_percentiles: dict[str, np.ndarray] = field(default_factory=dict)
|
|
26
|
+
spending_percentiles: dict[str, np.ndarray] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
# Aggregate metrics
|
|
29
|
+
success_rate: float = 0.0 # % of paths that never hit ruin
|
|
30
|
+
floor_breach_rate: float = 0.0 # % of paths that breach spending floor
|
|
31
|
+
median_terminal_wealth: float = 0.0
|
|
32
|
+
mean_terminal_wealth: float = 0.0
|
|
33
|
+
|
|
34
|
+
# Configuration
|
|
35
|
+
n_simulations: int = 0
|
|
36
|
+
n_years: int = 0
|
|
37
|
+
random_seed: int | None = None
|
|
38
|
+
|
|
39
|
+
def get_survival_probability(self) -> np.ndarray:
|
|
40
|
+
"""Calculate survival probability at each year.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Array of shape (n_years,) with P(not ruined) at each year
|
|
44
|
+
"""
|
|
45
|
+
if self.time_to_ruin is None:
|
|
46
|
+
return np.ones(self.n_years)
|
|
47
|
+
|
|
48
|
+
survival = np.zeros(self.n_years)
|
|
49
|
+
for year in range(self.n_years):
|
|
50
|
+
survival[year] = np.mean(self.time_to_ruin > year)
|
|
51
|
+
return survival
|
|
52
|
+
|
|
53
|
+
def get_floor_survival_probability(self) -> np.ndarray:
|
|
54
|
+
"""Calculate probability of being above spending floor at each year.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Array of shape (n_years,) with P(above floor) at each year
|
|
58
|
+
"""
|
|
59
|
+
if self.time_to_floor_breach is None:
|
|
60
|
+
return np.ones(self.n_years)
|
|
61
|
+
|
|
62
|
+
survival = np.zeros(self.n_years)
|
|
63
|
+
for year in range(self.n_years):
|
|
64
|
+
survival[year] = np.mean(self.time_to_floor_breach > year)
|
|
65
|
+
return survival
|
|
66
|
+
|
|
67
|
+
def get_percentile(self, percentile: int, metric: str = "wealth") -> np.ndarray:
|
|
68
|
+
"""Get a specific percentile path.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
percentile: Percentile value (0-100)
|
|
72
|
+
metric: "wealth" or "spending"
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Array of shape (n_years,) with percentile values
|
|
76
|
+
"""
|
|
77
|
+
key = f"P{percentile}"
|
|
78
|
+
if metric == "wealth":
|
|
79
|
+
return self.wealth_percentiles.get(key, np.zeros(self.n_years))
|
|
80
|
+
else:
|
|
81
|
+
return self.spending_percentiles.get(key, np.zeros(self.n_years))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def generate_returns(
|
|
85
|
+
n_simulations: int,
|
|
86
|
+
n_years: int,
|
|
87
|
+
market_model: MarketModel,
|
|
88
|
+
stock_weight: float,
|
|
89
|
+
bond_weight: float | None = None,
|
|
90
|
+
random_seed: int | None = None,
|
|
91
|
+
) -> np.ndarray:
|
|
92
|
+
"""Generate correlated portfolio returns.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
n_simulations: Number of simulation paths
|
|
96
|
+
n_years: Number of years to simulate
|
|
97
|
+
market_model: Market assumptions
|
|
98
|
+
stock_weight: Portfolio weight in stocks
|
|
99
|
+
bond_weight: Portfolio weight in bonds (rest is cash if None)
|
|
100
|
+
random_seed: Random seed for reproducibility
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Array of shape (n_simulations, n_years) with portfolio returns
|
|
104
|
+
"""
|
|
105
|
+
rng = np.random.default_rng(random_seed)
|
|
106
|
+
|
|
107
|
+
if bond_weight is None:
|
|
108
|
+
bond_weight = 1 - stock_weight
|
|
109
|
+
cash_weight = max(0, 1 - stock_weight - bond_weight)
|
|
110
|
+
|
|
111
|
+
# Portfolio expected return and volatility
|
|
112
|
+
portfolio_return = market_model.expected_portfolio_return(stock_weight, bond_weight)
|
|
113
|
+
portfolio_vol = market_model.portfolio_volatility(stock_weight, bond_weight)
|
|
114
|
+
|
|
115
|
+
# Generate returns
|
|
116
|
+
if market_model.use_fat_tails:
|
|
117
|
+
# Use t-distribution for fatter tails
|
|
118
|
+
z = stats.t.rvs(
|
|
119
|
+
df=market_model.degrees_of_freedom,
|
|
120
|
+
size=(n_simulations, n_years),
|
|
121
|
+
random_state=rng,
|
|
122
|
+
)
|
|
123
|
+
# Scale t-distribution to have unit variance
|
|
124
|
+
scale_factor = np.sqrt(market_model.degrees_of_freedom / (market_model.degrees_of_freedom - 2))
|
|
125
|
+
z = z / scale_factor
|
|
126
|
+
else:
|
|
127
|
+
# Standard normal
|
|
128
|
+
z = rng.standard_normal((n_simulations, n_years))
|
|
129
|
+
|
|
130
|
+
# Convert to returns (log-normal model)
|
|
131
|
+
# r = μ - σ²/2 + σ*z (continuous compounding adjustment)
|
|
132
|
+
returns = portfolio_return - portfolio_vol**2 / 2 + portfolio_vol * z
|
|
133
|
+
|
|
134
|
+
return returns
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_simulation(
|
|
138
|
+
initial_wealth: float,
|
|
139
|
+
annual_spending: float | np.ndarray,
|
|
140
|
+
config: SimulationConfig,
|
|
141
|
+
stock_weight: float | np.ndarray = 0.6,
|
|
142
|
+
spending_floor: float | None = None,
|
|
143
|
+
inflation_rate: float = 0.025,
|
|
144
|
+
) -> SimulationResult:
|
|
145
|
+
"""Run Monte Carlo simulation of retirement portfolio.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
initial_wealth: Starting portfolio value
|
|
149
|
+
annual_spending: Annual spending (constant or array by year)
|
|
150
|
+
config: Simulation configuration
|
|
151
|
+
stock_weight: Allocation to stocks (constant or array by year)
|
|
152
|
+
spending_floor: Minimum acceptable spending (for floor breach tracking)
|
|
153
|
+
inflation_rate: Annual inflation rate for real spending
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
SimulationResult with all paths and metrics
|
|
157
|
+
"""
|
|
158
|
+
n_sim = config.n_simulations
|
|
159
|
+
n_years = config.n_years
|
|
160
|
+
seed = config.random_seed
|
|
161
|
+
|
|
162
|
+
# Handle spending as array
|
|
163
|
+
if isinstance(annual_spending, (int, float)):
|
|
164
|
+
spending_schedule = np.full(n_years, annual_spending)
|
|
165
|
+
else:
|
|
166
|
+
spending_schedule = np.array(annual_spending)[:n_years]
|
|
167
|
+
if len(spending_schedule) < n_years:
|
|
168
|
+
# Extend with last value
|
|
169
|
+
spending_schedule = np.pad(
|
|
170
|
+
spending_schedule,
|
|
171
|
+
(0, n_years - len(spending_schedule)),
|
|
172
|
+
mode="edge",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Handle stock weight as array
|
|
176
|
+
if isinstance(stock_weight, (int, float)):
|
|
177
|
+
stock_weights = np.full(n_years, stock_weight)
|
|
178
|
+
else:
|
|
179
|
+
stock_weights = np.array(stock_weight)[:n_years]
|
|
180
|
+
if len(stock_weights) < n_years:
|
|
181
|
+
stock_weights = np.pad(
|
|
182
|
+
stock_weights,
|
|
183
|
+
(0, n_years - len(stock_weights)),
|
|
184
|
+
mode="edge",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Generate returns for each year's allocation
|
|
188
|
+
# For simplicity, use average allocation for return generation
|
|
189
|
+
avg_stock_weight = np.mean(stock_weights)
|
|
190
|
+
returns = generate_returns(
|
|
191
|
+
n_simulations=n_sim,
|
|
192
|
+
n_years=n_years,
|
|
193
|
+
market_model=config.market_model,
|
|
194
|
+
stock_weight=avg_stock_weight,
|
|
195
|
+
random_seed=seed,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Initialize paths
|
|
199
|
+
wealth_paths = np.zeros((n_sim, n_years + 1))
|
|
200
|
+
wealth_paths[:, 0] = initial_wealth
|
|
201
|
+
|
|
202
|
+
spending_paths = np.zeros((n_sim, n_years)) if config.track_spending else None
|
|
203
|
+
|
|
204
|
+
time_to_ruin = np.full(n_sim, np.inf)
|
|
205
|
+
time_to_floor_breach = np.full(n_sim, np.inf) if spending_floor else None
|
|
206
|
+
|
|
207
|
+
# Simulate year by year
|
|
208
|
+
for year in range(n_years):
|
|
209
|
+
# Current wealth
|
|
210
|
+
current_wealth = wealth_paths[:, year]
|
|
211
|
+
|
|
212
|
+
# Spending (adjusted for inflation)
|
|
213
|
+
real_spending = spending_schedule[year]
|
|
214
|
+
nominal_spending = real_spending * (1 + inflation_rate) ** year
|
|
215
|
+
|
|
216
|
+
# Actual spending (can't spend more than we have)
|
|
217
|
+
actual_spending = np.minimum(nominal_spending, np.maximum(current_wealth, 0))
|
|
218
|
+
|
|
219
|
+
if spending_paths is not None:
|
|
220
|
+
spending_paths[:, year] = actual_spending
|
|
221
|
+
|
|
222
|
+
# Track floor breach
|
|
223
|
+
if time_to_floor_breach is not None and spending_floor:
|
|
224
|
+
floor_breach_mask = (actual_spending < spending_floor * (1 + inflation_rate) ** year)
|
|
225
|
+
floor_breach_mask &= np.isinf(time_to_floor_breach)
|
|
226
|
+
time_to_floor_breach[floor_breach_mask] = year
|
|
227
|
+
|
|
228
|
+
# Wealth after spending
|
|
229
|
+
wealth_after_spending = current_wealth - actual_spending
|
|
230
|
+
|
|
231
|
+
# Apply returns (only on positive wealth)
|
|
232
|
+
wealth_with_returns = wealth_after_spending * (1 + returns[:, year])
|
|
233
|
+
wealth_with_returns = np.maximum(wealth_with_returns, 0) # Can't go negative
|
|
234
|
+
|
|
235
|
+
wealth_paths[:, year + 1] = wealth_with_returns
|
|
236
|
+
|
|
237
|
+
# Track ruin (wealth hits zero)
|
|
238
|
+
ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
|
|
239
|
+
time_to_ruin[ruin_mask] = year + 1
|
|
240
|
+
|
|
241
|
+
# Calculate percentiles
|
|
242
|
+
wealth_percentiles = {}
|
|
243
|
+
spending_percentiles = {}
|
|
244
|
+
|
|
245
|
+
for p in config.percentiles:
|
|
246
|
+
key = f"P{p}"
|
|
247
|
+
wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
|
|
248
|
+
if spending_paths is not None:
|
|
249
|
+
spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
|
|
250
|
+
|
|
251
|
+
# Aggregate metrics
|
|
252
|
+
terminal_wealth = wealth_paths[:, -1]
|
|
253
|
+
success_rate = np.mean(np.isinf(time_to_ruin))
|
|
254
|
+
floor_breach_rate = 0.0
|
|
255
|
+
if time_to_floor_breach is not None:
|
|
256
|
+
floor_breach_rate = np.mean(~np.isinf(time_to_floor_breach))
|
|
257
|
+
|
|
258
|
+
return SimulationResult(
|
|
259
|
+
wealth_paths=wealth_paths[:, 1:], # Exclude initial wealth
|
|
260
|
+
spending_paths=spending_paths,
|
|
261
|
+
time_to_ruin=time_to_ruin,
|
|
262
|
+
time_to_floor_breach=time_to_floor_breach,
|
|
263
|
+
wealth_percentiles=wealth_percentiles,
|
|
264
|
+
spending_percentiles=spending_percentiles,
|
|
265
|
+
success_rate=success_rate,
|
|
266
|
+
floor_breach_rate=floor_breach_rate,
|
|
267
|
+
median_terminal_wealth=np.median(terminal_wealth),
|
|
268
|
+
mean_terminal_wealth=np.mean(terminal_wealth),
|
|
269
|
+
n_simulations=n_sim,
|
|
270
|
+
n_years=n_years,
|
|
271
|
+
random_seed=seed,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def run_simulation_with_policy(
|
|
276
|
+
initial_wealth: float,
|
|
277
|
+
spending_policy: "SpendingPolicy",
|
|
278
|
+
allocation_policy: "AllocationPolicy",
|
|
279
|
+
config: SimulationConfig,
|
|
280
|
+
spending_floor: float | None = None,
|
|
281
|
+
) -> SimulationResult:
|
|
282
|
+
"""Run simulation with dynamic spending and allocation policies.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
initial_wealth: Starting portfolio value
|
|
286
|
+
spending_policy: Policy determining annual spending
|
|
287
|
+
allocation_policy: Policy determining asset allocation
|
|
288
|
+
config: Simulation configuration
|
|
289
|
+
spending_floor: Minimum acceptable spending
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
SimulationResult with all paths and metrics
|
|
293
|
+
"""
|
|
294
|
+
n_sim = config.n_simulations
|
|
295
|
+
n_years = config.n_years
|
|
296
|
+
seed = config.random_seed
|
|
297
|
+
|
|
298
|
+
rng = np.random.default_rng(seed)
|
|
299
|
+
|
|
300
|
+
# Initialize paths
|
|
301
|
+
wealth_paths = np.zeros((n_sim, n_years + 1))
|
|
302
|
+
wealth_paths[:, 0] = initial_wealth
|
|
303
|
+
spending_paths = np.zeros((n_sim, n_years))
|
|
304
|
+
|
|
305
|
+
time_to_ruin = np.full(n_sim, np.inf)
|
|
306
|
+
time_to_floor_breach = np.full(n_sim, np.inf) if spending_floor else None
|
|
307
|
+
|
|
308
|
+
# Generate all random draws upfront
|
|
309
|
+
z = rng.standard_normal((n_sim, n_years))
|
|
310
|
+
|
|
311
|
+
# Simulate year by year
|
|
312
|
+
for year in range(n_years):
|
|
313
|
+
current_wealth = wealth_paths[:, year]
|
|
314
|
+
|
|
315
|
+
# Get spending from policy (vectorized)
|
|
316
|
+
spending = spending_policy.get_spending(
|
|
317
|
+
wealth=current_wealth,
|
|
318
|
+
year=year,
|
|
319
|
+
initial_wealth=initial_wealth,
|
|
320
|
+
)
|
|
321
|
+
spending_paths[:, year] = spending
|
|
322
|
+
|
|
323
|
+
# Track floor breach
|
|
324
|
+
if time_to_floor_breach is not None and spending_floor:
|
|
325
|
+
floor_breach_mask = (spending < spending_floor) & np.isinf(time_to_floor_breach)
|
|
326
|
+
time_to_floor_breach[floor_breach_mask] = year
|
|
327
|
+
|
|
328
|
+
# Get allocation from policy
|
|
329
|
+
stock_weight = allocation_policy.get_allocation(
|
|
330
|
+
wealth=current_wealth,
|
|
331
|
+
year=year,
|
|
332
|
+
initial_wealth=initial_wealth,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Calculate returns for this allocation
|
|
336
|
+
portfolio_return = config.market_model.expected_portfolio_return(stock_weight)
|
|
337
|
+
portfolio_vol = config.market_model.portfolio_volatility(stock_weight)
|
|
338
|
+
|
|
339
|
+
returns = portfolio_return - portfolio_vol**2 / 2 + portfolio_vol * z[:, year]
|
|
340
|
+
|
|
341
|
+
# Update wealth
|
|
342
|
+
wealth_after_spending = np.maximum(current_wealth - spending, 0)
|
|
343
|
+
wealth_paths[:, year + 1] = wealth_after_spending * (1 + returns)
|
|
344
|
+
|
|
345
|
+
# Track ruin
|
|
346
|
+
ruin_mask = (wealth_paths[:, year + 1] <= 0) & np.isinf(time_to_ruin)
|
|
347
|
+
time_to_ruin[ruin_mask] = year + 1
|
|
348
|
+
|
|
349
|
+
# Calculate percentiles
|
|
350
|
+
wealth_percentiles = {}
|
|
351
|
+
spending_percentiles = {}
|
|
352
|
+
|
|
353
|
+
for p in config.percentiles:
|
|
354
|
+
key = f"P{p}"
|
|
355
|
+
wealth_percentiles[key] = np.percentile(wealth_paths[:, 1:], p, axis=0)
|
|
356
|
+
spending_percentiles[key] = np.percentile(spending_paths, p, axis=0)
|
|
357
|
+
|
|
358
|
+
terminal_wealth = wealth_paths[:, -1]
|
|
359
|
+
|
|
360
|
+
return SimulationResult(
|
|
361
|
+
wealth_paths=wealth_paths[:, 1:],
|
|
362
|
+
spending_paths=spending_paths,
|
|
363
|
+
time_to_ruin=time_to_ruin,
|
|
364
|
+
time_to_floor_breach=time_to_floor_breach,
|
|
365
|
+
wealth_percentiles=wealth_percentiles,
|
|
366
|
+
spending_percentiles=spending_percentiles,
|
|
367
|
+
success_rate=np.mean(np.isinf(time_to_ruin)),
|
|
368
|
+
floor_breach_rate=np.mean(~np.isinf(time_to_floor_breach)) if time_to_floor_breach is not None else 0.0,
|
|
369
|
+
median_terminal_wealth=np.median(terminal_wealth),
|
|
370
|
+
mean_terminal_wealth=np.mean(terminal_wealth),
|
|
371
|
+
n_simulations=n_sim,
|
|
372
|
+
n_years=n_years,
|
|
373
|
+
random_seed=seed,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# Type hints for policies (avoid circular imports)
|
|
378
|
+
class SpendingPolicy:
|
|
379
|
+
"""Protocol for spending policies."""
|
|
380
|
+
|
|
381
|
+
def get_spending(
|
|
382
|
+
self,
|
|
383
|
+
wealth: np.ndarray,
|
|
384
|
+
year: int,
|
|
385
|
+
initial_wealth: float,
|
|
386
|
+
) -> np.ndarray:
|
|
387
|
+
"""Get spending amount for each simulation path."""
|
|
388
|
+
raise NotImplementedError
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class AllocationPolicy:
|
|
392
|
+
"""Protocol for allocation policies."""
|
|
393
|
+
|
|
394
|
+
def get_allocation(
|
|
395
|
+
self,
|
|
396
|
+
wealth: np.ndarray,
|
|
397
|
+
year: int,
|
|
398
|
+
initial_wealth: float,
|
|
399
|
+
) -> float | np.ndarray:
|
|
400
|
+
"""Get stock allocation (can be constant or per-path)."""
|
|
401
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Plotly visualization components for fundedness."""
|
|
2
|
+
|
|
3
|
+
from fundedness.viz.colors import COLORS
|
|
4
|
+
from fundedness.viz.comparison import create_strategy_comparison_chart
|
|
5
|
+
from fundedness.viz.fan_chart import create_fan_chart
|
|
6
|
+
from fundedness.viz.histogram import create_time_distribution_histogram
|
|
7
|
+
from fundedness.viz.survival import create_survival_curve
|
|
8
|
+
from fundedness.viz.tornado import create_tornado_chart
|
|
9
|
+
from fundedness.viz.waterfall import create_cefr_waterfall
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"COLORS",
|
|
13
|
+
"create_cefr_waterfall",
|
|
14
|
+
"create_fan_chart",
|
|
15
|
+
"create_strategy_comparison_chart",
|
|
16
|
+
"create_survival_curve",
|
|
17
|
+
"create_time_distribution_histogram",
|
|
18
|
+
"create_tornado_chart",
|
|
19
|
+
]
|
fundedness/viz/colors.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Color palette and styling constants for visualizations."""
|
|
2
|
+
|
|
3
|
+
# Primary colors
|
|
4
|
+
COLORS = {
|
|
5
|
+
# Blues for wealth/assets
|
|
6
|
+
"wealth_primary": "#3498db",
|
|
7
|
+
"wealth_secondary": "#2980b9",
|
|
8
|
+
"wealth_light": "#5dade2",
|
|
9
|
+
"wealth_dark": "#1a5276",
|
|
10
|
+
|
|
11
|
+
# Greens for spending/survival/positive outcomes
|
|
12
|
+
"success_primary": "#27ae60",
|
|
13
|
+
"success_secondary": "#2ecc71",
|
|
14
|
+
"success_light": "#58d68d",
|
|
15
|
+
"success_dark": "#1e8449",
|
|
16
|
+
|
|
17
|
+
# Reds for haircuts/negative outcomes
|
|
18
|
+
"danger_primary": "#e74c3c",
|
|
19
|
+
"danger_secondary": "#c0392b",
|
|
20
|
+
"danger_light": "#ec7063",
|
|
21
|
+
"danger_dark": "#922b21",
|
|
22
|
+
|
|
23
|
+
# Oranges for warnings/caution
|
|
24
|
+
"warning_primary": "#f39c12",
|
|
25
|
+
"warning_secondary": "#e67e22",
|
|
26
|
+
"warning_light": "#f5b041",
|
|
27
|
+
"warning_dark": "#b9770e",
|
|
28
|
+
|
|
29
|
+
# Purples for alternatives/special
|
|
30
|
+
"accent_primary": "#9b59b6",
|
|
31
|
+
"accent_secondary": "#8e44ad",
|
|
32
|
+
"accent_light": "#bb8fce",
|
|
33
|
+
"accent_dark": "#6c3483",
|
|
34
|
+
|
|
35
|
+
# Grays for neutral elements
|
|
36
|
+
"neutral_primary": "#7f8c8d",
|
|
37
|
+
"neutral_secondary": "#95a5a6",
|
|
38
|
+
"neutral_light": "#bdc3c7",
|
|
39
|
+
"neutral_dark": "#5d6d7e",
|
|
40
|
+
|
|
41
|
+
# Background colors
|
|
42
|
+
"background": "#ffffff",
|
|
43
|
+
"background_alt": "#f8f9fa",
|
|
44
|
+
|
|
45
|
+
# Text colors
|
|
46
|
+
"text_primary": "#2c3e50",
|
|
47
|
+
"text_secondary": "#7f8c8d",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Fan chart percentile colors (gradient from optimistic to pessimistic)
|
|
51
|
+
FAN_CHART_COLORS = {
|
|
52
|
+
"P90": "rgba(46, 204, 113, 0.3)", # Light green (optimistic)
|
|
53
|
+
"P75": "rgba(46, 204, 113, 0.4)",
|
|
54
|
+
"P50": "rgba(52, 152, 219, 0.8)", # Solid blue (median)
|
|
55
|
+
"P25": "rgba(231, 76, 60, 0.4)",
|
|
56
|
+
"P10": "rgba(231, 76, 60, 0.3)", # Light red (pessimistic)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Waterfall chart colors
|
|
60
|
+
WATERFALL_COLORS = {
|
|
61
|
+
"increase": COLORS["success_primary"],
|
|
62
|
+
"decrease": COLORS["danger_primary"],
|
|
63
|
+
"total": COLORS["wealth_primary"],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Strategy comparison colors (for withdrawal lab)
|
|
67
|
+
STRATEGY_COLORS = [
|
|
68
|
+
COLORS["wealth_primary"],
|
|
69
|
+
COLORS["success_primary"],
|
|
70
|
+
COLORS["accent_primary"],
|
|
71
|
+
COLORS["warning_primary"],
|
|
72
|
+
COLORS["danger_primary"],
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# Default Plotly template settings
|
|
76
|
+
TEMPLATE_SETTINGS = {
|
|
77
|
+
"template": "plotly_white",
|
|
78
|
+
"font_family": "Inter, system-ui, -apple-system, sans-serif",
|
|
79
|
+
"title_font_size": 18,
|
|
80
|
+
"axis_font_size": 12,
|
|
81
|
+
"legend_font_size": 11,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_plotly_layout_defaults() -> dict:
|
|
86
|
+
"""Get default layout settings for consistent styling."""
|
|
87
|
+
return {
|
|
88
|
+
"template": TEMPLATE_SETTINGS["template"],
|
|
89
|
+
"font": {
|
|
90
|
+
"family": TEMPLATE_SETTINGS["font_family"],
|
|
91
|
+
"size": TEMPLATE_SETTINGS["axis_font_size"],
|
|
92
|
+
"color": COLORS["text_primary"],
|
|
93
|
+
},
|
|
94
|
+
"title": {
|
|
95
|
+
"font": {
|
|
96
|
+
"size": TEMPLATE_SETTINGS["title_font_size"],
|
|
97
|
+
"color": COLORS["text_primary"],
|
|
98
|
+
},
|
|
99
|
+
"x": 0.5,
|
|
100
|
+
"xanchor": "center",
|
|
101
|
+
},
|
|
102
|
+
"paper_bgcolor": COLORS["background"],
|
|
103
|
+
"plot_bgcolor": COLORS["background"],
|
|
104
|
+
"margin": {"l": 60, "r": 40, "t": 60, "b": 60},
|
|
105
|
+
"hoverlabel": {
|
|
106
|
+
"bgcolor": COLORS["background"],
|
|
107
|
+
"font_size": 12,
|
|
108
|
+
"font_family": TEMPLATE_SETTINGS["font_family"],
|
|
109
|
+
},
|
|
110
|
+
}
|