
Wealth Management
- 3 installs
- 3 repo stars
- Updated August 5, 2026
- broomva/skills
wealth-management is a Claude skill for financial planning and investment analytics that runs portfolio projection, allocation optimization, and tax-efficient rebalancing.
About
wealth-management is a Claude skill for financial planning and investment analytics, built on a finance-substrate data layer. It runs portfolio-health dashboards, compound-growth and Monte Carlo projections, goal-based planning, allocation strategy, and tax-efficient rebalancing. A developer uses it to project long-term wealth, optimize asset allocation, and stress-test a portfolio. It is tuned for Colombian tax-optimized wealth building with COP/USD FX handling.
- Runs descriptive, predictive, and prescriptive portfolio analytics
- Projects compound growth and runs Monte Carlo scenarios
- Recommends allocation, rebalancing trades, and tax-efficient withdrawals
Wealth Management by the numbers
- 3 all-time installs (skills.sh)
- Ranked #847 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
wealth-management capabilities & compatibility
- Capabilities
- portfolio analysis · financial planning · asset allocation
- Use cases
- data analysis · trading
- Runs
- Runs locally
- Pricing
- Free
What wealth-management says it does
Long-term wealth building, investment analytics, and financial planning engine.
Runs descriptive (portfolio health), predictive (compound growth projections,
Concentration risk: any single position > 20% of portfolio
npx skills add https://github.com/broomva/skills --skill wealth-managementAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 3 |
| Last updated | August 5, 2026 |
| Repository | broomva/skills ↗ |
What it does
Project long-term wealth, optimize asset allocation, and run tax-efficient portfolio planning.
Who is it for?
Projecting long-term wealth and optimizing a portfolio, especially with Colombian tax rules.
Skip if: Real-time trade execution; it produces plans and recommended trades, not order placement.
When should I use this skill?
Projecting wealth growth, optimizing allocation, or planning tax-efficient contributions and withdrawals.
What you get
Net-worth projections, allocation targets, rebalancing trades, and goal-feasibility analysis.
- Portfolio health dashboard
- Year-by-year growth projection
- Target allocation and rebalancing trades
By the numbers
- Five skill modes (summary, project, goal, allocation, rebalance)
- Three risk profiles (conservative, moderate, aggressive)
- Flags any single position over 20% of the portfolio
Files
Wealth Management
Long-term wealth building, investment analytics, and financial planning engine. Builds on finance-substrate for data ingestion (bank certificates, patrimonio, TRM rates, salary history) and adds forward-looking projection, optimization, and scenario analysis.
Architecture
finance-substrate (data layer)
├── certificates.jsonl → current holdings, bank saldos
├── patrimonio_calc.py → net worth snapshot (R29/R30/R31)
├── tax_projection.py → annual tax liability
├── salary-history.jsonl → income trajectory
└── trm-history.jsonl → FX rates
↓
wealth-management (analytics layer)
├── Descriptive: portfolio health, allocation drift, performance
├── Predictive: compound growth, Monte Carlo, goal feasibility
└── Prescriptive: rebalancing trades, contribution strategy, withdrawal orderData Sources
From finance-substrate (automatic)
| Source | Data | Used by |
|---|---|---|
certificates.jsonl | Bank saldos, pension funds, cesantías, investment funds | All modes |
exogena.jsonl | Real estate (Marval), vehicle, stocks (Ecopetrol) | summary, project |
salary-history.jsonl | Income trajectory (monthly USD + TRM) | project, goal |
patrimonio_calc.py | Net worth aggregation (deduplication) | summary, project |
trm-history.jsonl | USD/COP exchange rates | FX conversion |
User-provided (portfolio input)
| Source | Format | Data |
|---|---|---|
| Investment holdings | JSON/CSV | Ticker, units, cost basis, account type |
| Target allocation | JSON | Asset class → target % |
| Goals | JSON | Name, target amount, target date, priority |
Portfolio data stored at ~/.wealth-management/portfolio.json.
Skill Modes
1. summary — Portfolio Health Dashboard (Descriptive)
Current-state analysis of all holdings aggregated from finance-substrate and user portfolio data.
Script: scripts/portfolio_summary.py --year 2025
Outputs:
- Net worth breakdown by asset class (cash, fixed income, equities, real estate, pension)
- Allocation pie: actual vs target %
- Concentration risk: any single position > 20% of portfolio
- Currency exposure: COP vs USD vs other
- Year-over-year growth: patrimonio líquido trajectory
- Liquidity analysis: liquid vs illiquid assets
2. project — Compound Growth Projection (Predictive)
Forward-looking wealth projection with configurable assumptions.
Script: scripts/project_wealth.py --years 20 --monthly-contribution-usd 2000
Inputs:
- Starting capital (from patrimonio or manual)
- Monthly/annual contribution amount
- Expected real return by asset class (default: equities 7%, bonds 3%, RE 5%)
- Inflation assumption (Colombia CPI: ~5-7%, US CPI: ~2-3%)
- Tax drag (from finance-substrate effective rate)
- TRM trend assumption (mean-reverting to historical average)
Outputs:
- Year-by-year table: contributions, growth, taxes, net value
- Milestones: when you hit $100M, $500M, $1B COP or $100K, $500K, $1M USD
- Contribution vs growth ratio over time (crossover point)
- Inflation-adjusted purchasing power
- Sensitivity table: ±2% return scenarios
Formulas:
FV = PV × (1 + r)^n + PMT × [((1+r)^n - 1) / r]
Real return = nominal - inflation - tax_drag
CAGR = (Ending / Beginning)^(1/Years) - 13. goal — Goal-Based Financial Planning (Predictive)
Reverse-engineer: given a target, what's needed?
Script: scripts/goal_planner.py --target-usd 500000 --target-date 2035
Inputs:
- Target amount (COP or USD)
- Target date
- Current savings (from patrimonio)
- Risk tolerance (conservative / moderate / aggressive)
- Income growth assumption
Outputs:
- Required monthly savings (COP + USD)
- Required return rate to meet goal with current savings only
- Probability of success (linked to Monte Carlo)
- Gap analysis: on track / behind / ahead
- Recommended asset allocation for the goal's time horizon
4. allocation — Asset Allocation Strategy (Prescriptive)
Recommend an optimal asset allocation based on risk profile and time horizon.
Script: scripts/allocate_assets.py --risk moderate --horizon 15
Framework: Modified Bogle Three-Fund + Colombian Extensions
| Risk Profile | Equities | Fixed Income | Real Estate | Cash/AFC |
|---|---|---|---|---|
| Conservative | 30% | 50% | 10% | 10% |
| Moderate | 55% | 25% | 10% | 10% |
| Aggressive | 75% | 10% | 10% | 5% |
Colombian-specific considerations:
- AFC cuenta as cash/fixed income (tax-deferred, housing-eligible)
- Pensión voluntaria (Skandia) = long-term equity proxy (10yr lock)
- Cesantías = forced savings (annual withdrawal allowed)
- Colombian equities (BVC) vs international via DolarApp/ARQ or US brokerage
- TRM hedging: maintain USD reserves for FX diversification
Outputs:
- Target allocation table
- Current vs target delta
- Rebalancing trades needed
- Tax impact of rebalancing (from finance-substrate tax projection)
5. rebalance — Tactical Rebalancing (Prescriptive)
Generate specific trades to bring portfolio back to target.
Script: scripts/rebalance.py --threshold 5
Inputs:
- Current holdings (from portfolio.json + certificates)
- Target allocation (from allocation mode or manual)
- Drift threshold (default: 5% absolute deviation triggers rebalance)
- Tax sensitivity (minimize realized gains)
Outputs:
- Trades to execute (buy/sell, amount, account)
- Tax impact estimate (short-term vs long-term gains)
- Priority order (tax-loss harvest first, then rebalance)
- "Do nothing" zones where drift is within tolerance
6. scenario — Monte Carlo & Stress Testing (Predictive)
Simulate portfolio outcomes under uncertainty.
Script: scripts/scenario_analysis.py --simulations 10000 --years 20
Scenarios:
- Monte Carlo: 10,000 simulations with log-normal returns, historical volatility
- Historical stress: 2008 GFC, 2020 COVID, 2022 rate hike, 1999 Colombian crisis
- COP devaluation: TRM shock (+30%, +50%)
- Stagflation: High inflation (10%) + low growth (0%) for 5 years
- Career disruption: 0 income for 6-12 months
Outputs:
- Success probability (% of simulations meeting goal)
- Percentile outcomes: P10, P25, P50, P75, P90
- Worst-case scenario: minimum portfolio value
- Sequence-of-returns risk: early vs late bear market impact
- Safe withdrawal rate for given success probability
7. optimize — Tax-Efficient Strategy (Prescriptive)
Maximize after-tax wealth growth using Colombian tax law.
Script: scripts/optimize_strategy.py --year 2025
Strategies analyzed: 1. Contribution ordering: AFC vs voluntaria vs libre inversión
- AFC: tax-deferred, 10yr lock or housing withdrawal
- Voluntaria: tax-deferred, 10yr lock or pension age
- Libre: no tax benefit, full liquidity
- Decision depends on marginal tax rate and cap utilization (1,340 UVT)
2. Account type placement: Which assets in which account?
- High-growth (equities) → tax-deferred (voluntaria/AFC) for tax-free compounding
- Income-producing (bonds, rendimientos) → taxable, claim INCR deduction
- International (USD equities) → DolarApp/ARQ for FX diversification
3. Withdrawal sequencing (for wealth distribution phase):
- Taxable accounts first (lower tax rate on capital gains)
- AFC for housing needs (tax-free withdrawal)
- Voluntaria after 10yr + pension age (tax-free)
- Cesantías annually (forced, taxable)
4. Tax-loss harvesting: Identify positions with unrealized losses to offset gains
Outputs:
- Optimal contribution plan (monthly amounts by account)
- Account placement recommendations
- 5-year after-tax growth comparison: optimized vs naive
- Marginal benefit table (extra $1M COP in each account → after-tax impact)
Integration with finance-substrate
wealth-management imports directly from finance-substrate scripts:
# Import patrimonio for current net worth
from patrimonio_calc import compute_patrimonio
# Import tax projection for effective rates
from tax_projection import project_tax
# Import budget for contribution capacity
from budget_planner import estimate_annual_tax
# Read salary trajectory
salary = load_jsonl("~/.finance-substrate/tax/salary-history.jsonl")Data Directory
~/.wealth-management/
├── portfolio.json # Current holdings (user-maintained)
├── targets.json # Target allocation profiles
├── goals.json # Financial goals with timelines
├── projections/ # Saved projection results
│ └── projection-YYYY-MM-DD.json
├── scenarios/ # Monte Carlo results
│ └── scenario-YYYY-MM-DD.json
└── history/ # Net worth snapshots over time
└── networth-history.jsonlReferences
Key Formulas
| Formula | Expression | Use |
|---|---|---|
| Future Value | FV = PV(1+r)^n + PMT[((1+r)^n - 1)/r] | Compound growth |
| CAGR | (FV/PV)^(1/n) - 1 | Historical return |
| Real Return | (1+nominal)/(1+inflation) - 1 | Purchasing power |
| Sharpe Ratio | (R_p - R_f) / σ_p | Risk-adjusted return |
| Safe Withdrawal | Annual spend / Portfolio value | Distribution phase |
| Tax Drag | r_nominal × effective_tax_rate | After-tax return |
| Rule of 72 | 72 / r | Years to double |
Colombian-Specific Parameters
| Parameter | Value | Source |
|---|---|---|
| UVT 2025 | $49,799 COP | DIAN |
| AFC/VP cap | 1,340 UVT ($66.7M) | Art. 336, Ley 2277/2022 |
| Pensión oblig. rate | 16% of IBC | Ley 797/2003 |
| Colombian CPI (2024) | ~6.1% | DANE |
| US CPI (2024) | ~2.9% | BLS |
| Avg TRM 2025 | ~4,052 COP/USD | datos.gov.co |
| BVC COLCAP return (5yr avg) | ~8-12% nominal | BVC |
| S&P 500 return (10yr avg) | ~12% nominal | Historical |
| CDT rates (2024) | ~10-13% nominal | Banks |
| FIC (fondos inversión colectiva) | ~7-10% nominal | Skandia/Davivienda |
Asset Class Expected Returns (Real, After Inflation)
| Asset Class | Conservative | Moderate | Aggressive |
|---|---|---|---|
| Colombian equities (BVC) | 3% | 5% | 7% |
| US equities (S&P 500) | 5% | 7% | 9% |
| Colombian CDT/bonds | 2% | 3% | 4% |
| Real estate (Colombia) | 3% | 5% | 6% |
| Pension voluntaria (Skandia) | 3% | 5% | 7% |
| AFC (Davivienda) | 1% | 2% | 2% |
| Cash/savings | 0% | 0% | 0% |
Compound: Autoany Integration (EGRI)
This skill is EGRI-aware. When a user's request implies iterative optimization of savings strategies, contribution plans, or investment horizons, the agent should scaffold a problem-spec and delegate to /autoany.
Optimization Triggers
Invoke /autoany when the user asks to:
- "Optimize contributions" — AFC vs pension vs brokerage split
- "Maximize probability" — probability of reaching a financial goal
- "Best allocation for my goals" — multi-horizon allocation
- "How should I split my savings" — tax-efficient contribution ordering
- "Run simulations" — with optimization intent (not just a single run)
- "Stress test my plan" — combined with finding a better plan
EGRI Problem-Spec Templates
| Template | Artifact | Evaluator | Score | Use When |
|---|---|---|---|---|
contribution-optimization | contribution_plan.yaml | scenario_analysis.py --egri | P(goal) | Optimizing savings splits |
horizon-evaluation | horizon_plan.yaml | eval_horizon.py --egri | Risk-adjusted P(all goals) | Multi-goal allocation |
Templates are at templates/egri/.
Delegation Flow
1. User request → agent detects optimization intent
2. Load personal context:
- patrimonio from finance-substrate (starting capital)
- salary trajectory (budget constraint)
- TRM rates (COP/USD conversion)
- existing goals from ~/.wealth-management/goals.json
3. Scaffold problem-spec from template
4. Invoke /autoany
5. EGRI loop: Proposer → Executor (scenario_analysis.py) → Evaluator → Selector
6. Return promoted plan + ledger summary
7. Show concrete action items:
- "Increase AFC contributions to $X/month"
- "Shift 10% from fixed income to equities in retirement bucket"EGRI Evaluator Bridge
scenario_analysis.py --egri outputs structured Outcome for autoany:
- Score:
probability_of_goal_pct(0-100) - Constraints:
median_max_drawdown_pct > -25,probability_of_ruin_pct <= 5 - Metrics: full Monte Carlo statistics for the proposer to learn from
Safety Constraints (enforced in EGRI loops)
- All simulations use historical/synthetic data only (no live data risk)
- Contribution plans are advisory — no automatic financial actions
- AFC + pensión voluntaria combined cap: 1,340 UVT (~$66.7M COP)
- Monthly contribution cannot exceed income
- Ruin probability must stay below 5%
- Budget: 20-40 trials max, 10-40 minutes total
Related Skills
- [finance-substrate](https://github.com/broomva/finance-substrate) — Data layer: bank certificates, patrimonio, tax projection, salary history, TRM rates
- [investment-management](https://github.com/broomva/investment-management) — Execution layer: security screening, scoring, market data, trade execution, factor analysis, backtesting
- [autoany](https://github.com/broomva/autoany) — EGRI framework for recursive improvement loops
Dependencies
- Python 3.10+
finance-substrateskill (data layer — certificates, patrimonio, tax, salary)autoany(optional, for EGRI optimization loops)numpy(optional, for Monte Carlo simulations)- No paid services. All data stays local.
File Structure
wealth-management/
├── SKILL.md # This file
├── skill.json # Schema definition (7 modes)
├── scripts/
│ ├── portfolio_summary.py # Mode 1: descriptive dashboard
│ ├── project_wealth.py # Mode 2: compound growth projection
│ ├── goal_planner.py # Mode 3: goal-based planning
│ ├── allocate_assets.py # Mode 4: asset allocation strategy
│ ├── rebalance.py # Mode 5: tactical rebalancing
│ ├── scenario_analysis.py # Mode 6: Monte Carlo & stress tests
│ └── optimize_strategy.py # Mode 7: tax-efficient strategy
├── references/
│ ├── compounding-formulas.md # Mathematical foundations
│ ├── colombian-investment-landscape.md # Local market reference
│ └── tax-efficiency-strategies.md # Withdrawal ordering, harvesting
├── templates/
│ └── egri/ # EGRI problem-spec templates (autoany)
│ ├── contribution-optimization.yaml # Savings split optimization
│ └── horizon-evaluation.yaml # Multi-goal horizon allocation
├── .control/
│ └── policy.yaml # Rebalancing thresholds, contribution caps
└── README.mdwealth-management
Wealth management, financial planning, and investment analytics skill for Colombian residents. Compounds on finance-substrate for tax-optimized wealth building.
Quick Start
# Install
npx skills add broomva/wealth-management -y -g
# Portfolio summary (uses finance-substrate data)
python3 scripts/portfolio_summary.py --year 2024
# 20-year compound growth projection
python3 scripts/project_wealth.py --years 20 --monthly-contribution-usd 2000
# Goal planning (when do I reach $500K USD?)
python3 scripts/goal_planner.py --target-usd 500000 --target-date 2040-01-01Modes
| Mode | Script | Analytics Type |
|---|---|---|
summary | portfolio_summary.py | Descriptive |
project | project_wealth.py | Predictive |
goal | goal_planner.py | Predictive |
allocation | allocate_assets.py | Prescriptive |
rebalance | rebalance.py | Prescriptive |
scenario | scenario_analysis.py | Predictive |
optimize | optimize_strategy.py | Prescriptive |
Dependencies
- Python 3.10+
finance-substrateskill (data layer)- No paid services. All data stays local.
Colombian Investment Landscape
Tax-Advantaged Accounts (Persona Natural)
AFC — Ahorro con Fomento a la Construcción (Art. 126-4 ET)
- Tax benefit: Contributions are renta exenta (deductible from income)
- Cap: Combined with vol. pension, ≤ 30% of gross income or 3,800 UVT
- Global cap: Subject to 1,340 UVT total exentas+deducciones limit
- Liquidity: 10-year minimum permanence for tax benefit to hold
- Exception: Housing-purpose withdrawals (vivienda) are tax-free at any time
- Best for: Medium-term savings for housing; parking cash with tax benefit
- Providers: Davivienda (most common), Bancolombia, BBVA
Pensión Voluntaria (Art. 126-1 ET)
- Tax benefit: Same as AFC — renta exenta
- Cap: Combined with AFC, same caps apply
- Liquidity: 10-year permanence OR pension age OR death/disability
- Penalty: Early withdrawal triggers retención contingente (7%)
- Best for: Long-term wealth building with tax deferral
- Providers: Skandia (most popular), Porvenir, Protección, Old Mutual
- Returns: Typically 5-12% nominal depending on fund profile
Pensión Obligatoria
- Rate: 16% of IBC (employer + employee split; independent pays full)
- IBC: 40% of gross income for independents
- Deductible: Full amount as INCR (Art. 55 ET)
- Not investable: Goes to pension fund (Skandia, Porvenir, etc.)
Cesantías
- Annual deposit: ~1 month of salary equivalent
- Withdrawal: For housing, education, or accumulated upon separation
- Returns: Fund-managed, typically 4-8% nominal
- Tax: Interest on cesantías is taxable
Investment Vehicles Available
Domestic
| Vehicle | Min. Investment | Expected Return | Tax Treatment |
|---|---|---|---|
| CDT (90 days) | ~$1M COP | 9-13% nominal | Rendimientos gravados (INCR applies) |
| CDT (360 days) | ~$1M COP | 10-14% nominal | Same |
| FIC (Fondos de Inversión Colectiva) | ~$100K COP | 7-12% nominal | Same |
| BVC stocks (COLCAP) | Any | Variable (avg 8-12% nom.) | Dividends taxed per Art. 242 ET |
| TES (government bonds) | ~$1M COP | 8-12% nominal | Rendimientos gravados |
| Real estate | High | 5-8% appreciation + 3-5% rental yield | Avalúo-based patrimonio |
| Fiducia (trust) | ~$50M COP | 8-12% nominal | Rendimientos gravados |
International (via DolarApp/ARQ or US brokerage)
| Vehicle | Access | Expected Return | Tax Treatment |
|---|---|---|---|
| US stocks | DolarApp, Interactive Brokers | 10-14% nominal | Foreign income (Art. 9 ET) |
| US ETFs (VTI, VOO) | Same | 10-12% nominal | Same |
| Crypto (BTC, ETH) | Exchanges | Variable | Ganancia ocasional if held >2 years |
| US bonds/treasuries | Same | 4-5% nominal | Foreign income |
Key Tax Considerations
Rendimientos Financieros
- Componente inflacionario (INCR): ~50.88% of rendimientos is non-taxable (2024)
- Banks report both gross and non-taxable portions
- Effective tax on rendimientos ≈ 16-17% for 33% bracket
Dividendos (Art. 242 ET, modified by Ley 2277/2022)
- First 300 UVT (~$14.9M for UVT $49,799) at 0%
- Above 300 UVT: 15% flat rate
- Sub-cédula separada (not part of cédula general)
Ganancias Ocasionales
- Assets held > 2 years: 15% flat rate
- Real estate: exempt first 13,000 UVT (~$647M) on primary residence sale
- Stocks: 10% rate
Foreign Income (Art. 9 ET)
- Colombian residents taxed on worldwide income
- Tax credits for taxes paid abroad (Art. 254 ET)
- TRM on transaction date for conversion
Inflation Environment
| Year | CPI Colombia | CPI USA | TRM avg |
|---|---|---|---|
| 2022 | 13.1% | 6.5% | 4,256 |
| 2023 | 9.3% | 3.4% | 4,325 |
| 2024 | 6.1% | 2.9% | 4,063 |
| 2025 | ~5.5% est. | ~2.5% est. | 4,052 |
Colombian real returns must be calculated against CPI Colombia (~5.5%), not US CPI.
A CDT at 12% nominal yields ~6.5% real. S&P 500 at 12% nominal in USD = ~9.5% real (US CPI), but FX risk applies.
Wealth Building Strategy Tiers
Tier 1: Tax Optimization (0-2 years)
- Max out AFC + vol. pension to 1,340 UVT cap
- Ensure 6-month emergency fund in liquid COP
- Pay off high-interest debt (TC > 24% EA)
Tier 2: Core Portfolio (2-5 years)
- Diversify beyond AFC/pension into FIC and CDTs
- Start international exposure via DolarApp/ARQ
- Build real estate equity if applicable
Tier 3: Growth (5-15 years)
- Increase equity allocation (BVC + US ETFs)
- Consider real estate investment
- Rebalance annually
Tier 4: Wealth Preservation (15+ years)
- Shift toward fixed income and real estate
- Plan withdrawal sequencing for tax efficiency
- Estate planning considerations
Compounding & Growth Formulas
Core Formulas
Future Value with Regular Contributions
FV = PV × (1 + r)^n + PMT × [((1+r)^n - 1) / r]
PV = present value (starting capital)
r = periodic return rate (annual or monthly)
n = number of periods
PMT = periodic contributionCompound Annual Growth Rate (CAGR)
CAGR = (FV / PV)^(1/n) - 1Real Return (Fisher Equation)
Real Return = (1 + nominal) / (1 + inflation) - 1
≈ nominal - inflation (for small values)Rule of 72
Years to double ≈ 72 / annual_return_pct
At 7% return: 72/7 ≈ 10.3 years to doubleRequired Monthly Savings
PMT = (FV - PV × (1+r_m)^n) × r_m / ((1+r_m)^n - 1)
r_m = monthly rate = (1 + annual_rate)^(1/12) - 1
n = monthsRisk-Adjusted Metrics
Sharpe Ratio
Sharpe = (R_p - R_f) / σ_p
R_p = portfolio return
R_f = risk-free rate (CDT 90 days or TES)
σ_p = portfolio standard deviationMaximum Drawdown
MDD = (Peak - Trough) / Peak × 100%Coefficient of Variation
CV = σ / μ
Lower CV = more consistent returnsTax Drag
After-Tax Return
R_after_tax = R_nominal × (1 - effective_tax_rate)
For Colombian persona natural:
- Rendimientos financieros: ~33% marginal rate (if in top bracket)
- But INCR (componente inflacionario) ~50.88% is non-taxable
- Effective tax on rendimientos: ~33% × 49.12% ≈ 16.2%Tax-Deferred Compounding Advantage
After n years:
Taxable: PV × (1 + r × (1-t))^n
Tax-deferred: PV × (1 + r)^n × (1 - t)
Advantage grows exponentially with n.
At r=7%, t=16%, n=20:
Taxable: PV × (1.0588)^20 = PV × 3.13
Tax-deferred: PV × (1.07)^20 × 0.84 = PV × 3.25
Advantage: 3.8% more wealthMonte Carlo Parameters
Log-Normal Distribution
Monthly return: r_m ~ N(μ, σ²)
μ = expected monthly return = (1 + annual_return)^(1/12) - 1
σ = monthly volatility = annual_vol / √12Historical Volatilities (Annualized)
| Asset Class | Volatility | Notes |
|---|---|---|
| Colombian equities (COLCAP) | 18-22% | Higher than developed markets |
| US equities (S&P 500) | 15-18% | Long-term average |
| Colombian bonds (TES) | 5-8% | Government bonds |
| CDT rates | 1-3% | Virtually no volatility |
| Real estate (Colombia) | 8-12% | Illiquid, smoothed |
| USD/COP (TRM) | 10-15% | FX risk for USD holders |
Success Probability Thresholds
| Probability | Interpretation |
|---|---|
| > 85% | High confidence — maintain strategy |
| 70-85% | Good — monitor and adjust if drift |
| 50-70% | Moderate — consider increasing savings or reducing target |
| < 50% | At risk — needs intervention (more savings, longer horizon, higher risk) |
#!/usr/bin/env python3
"""
Goal-based financial planner.
Given a target amount and date, computes required monthly savings,
probability of success, and recommended asset allocation.
Usage:
python3 goal_planner.py --target-usd 500000 --target-date 2035-12-31
python3 goal_planner.py --target-cop 1000000000 --target-date 2040-01-01 --risk moderate
"""
import argparse
import json
import math
import random
from datetime import date
from pathlib import Path
DATA_DIR = Path.home() / ".finance-substrate"
RISK_RETURNS = {
"conservative": {"real": 0.03, "vol": 0.08},
"moderate": {"real": 0.05, "vol": 0.12},
"aggressive": {"real": 0.07, "vol": 0.18},
}
def load_current_trm() -> float:
trm_file = DATA_DIR / "fx" / "trm-history.jsonl"
if not trm_file.exists():
return 4000.0
latest = None
with open(trm_file) as f:
for line in f:
line = line.strip()
if line:
rec = json.loads(line)
if latest is None or rec["date"] > latest["date"]:
latest = rec
return latest["valor"] if latest else 4000.0
def load_patrimonio() -> float:
cache = DATA_DIR / "cache" / "last_projection.json"
if cache.exists():
with open(cache) as f:
proj = json.load(f)
return proj.get("form_210", {}).get("patrimonio", {}).get("R31_patrimonio_liquido", 0)
return 0
def required_monthly_savings(target: float, current: float, years: float, annual_return: float) -> float:
"""Compute required monthly contribution to reach target."""
if years <= 0:
return max(0, target - current)
r_monthly = (1 + annual_return) ** (1 / 12) - 1
n = int(years * 12)
fv_current = current * (1 + r_monthly) ** n
gap = target - fv_current
if gap <= 0:
return 0 # Already on track with no contributions
if r_monthly == 0:
return gap / n
return gap * r_monthly / ((1 + r_monthly) ** n - 1)
def required_return(target: float, current: float, years: float, monthly_contrib: float) -> float:
"""Find the annual return needed to reach target with given contributions."""
if current >= target:
return 0
# Binary search for the required return
lo, hi = -0.05, 0.30
for _ in range(100):
mid = (lo + hi) / 2
r_monthly = (1 + mid) ** (1 / 12) - 1
n = int(years * 12)
fv = current * (1 + r_monthly) ** n
if r_monthly != 0:
fv += monthly_contrib * ((1 + r_monthly) ** n - 1) / r_monthly
else:
fv += monthly_contrib * n
if fv < target:
lo = mid
else:
hi = mid
return (lo + hi) / 2
def monte_carlo_probability(
target: float, current: float, monthly_contrib: float,
years: float, real_return: float, volatility: float,
simulations: int = 5000,
) -> dict:
"""Run Monte Carlo simulation to estimate probability of reaching target."""
n_months = int(years * 12)
monthly_return = (1 + real_return) ** (1 / 12) - 1
monthly_vol = volatility / math.sqrt(12)
successes = 0
final_values = []
random.seed(42) # Reproducible
for _ in range(simulations):
value = current
for _ in range(n_months):
r = random.gauss(monthly_return, monthly_vol)
value = value * (1 + r) + monthly_contrib
value = max(0, value) # Floor at zero
final_values.append(value)
if value >= target:
successes += 1
final_values.sort()
return {
"probability_pct": round(successes / simulations * 100, 1),
"p10": round(final_values[int(simulations * 0.10)]),
"p25": round(final_values[int(simulations * 0.25)]),
"p50": round(final_values[int(simulations * 0.50)]),
"p75": round(final_values[int(simulations * 0.75)]),
"p90": round(final_values[int(simulations * 0.90)]),
"worst": round(final_values[0]),
"best": round(final_values[-1]),
}
def main():
parser = argparse.ArgumentParser(description="Goal-based financial planner")
parser.add_argument("--target-usd", type=float, default=0)
parser.add_argument("--target-cop", type=float, default=0)
parser.add_argument("--target-date", required=True, help="YYYY-MM-DD")
parser.add_argument("--current-savings", type=float, default=0, help="Current savings COP (0=auto)")
parser.add_argument("--monthly-savings", type=float, default=0, help="Current monthly savings COP")
parser.add_argument("--risk", choices=["conservative", "moderate", "aggressive"], default="moderate")
parser.add_argument("--simulations", type=int, default=5000)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
trm = load_current_trm()
profile = RISK_RETURNS[args.risk]
# Target
if args.target_cop > 0:
target_cop = args.target_cop
elif args.target_usd > 0:
target_cop = args.target_usd * trm
else:
print("Error: provide --target-usd or --target-cop", file=__import__("sys").stderr)
raise SystemExit(1)
target_usd = target_cop / trm
# Timeline
target_dt = date.fromisoformat(args.target_date)
today = date.today()
years = (target_dt - today).days / 365.25
if years <= 0:
print("Error: target date must be in the future", file=__import__("sys").stderr)
raise SystemExit(1)
# Current savings
current = args.current_savings if args.current_savings > 0 else load_patrimonio()
if current <= 0:
current = 29_000_000
# Required savings
req_monthly = required_monthly_savings(target_cop, current, years, profile["real"])
req_return = required_return(target_cop, current, years, args.monthly_savings) if args.monthly_savings > 0 else None
# Monte Carlo
mc = monte_carlo_probability(
target_cop, current, args.monthly_savings or req_monthly,
years, profile["real"], profile["vol"], args.simulations,
)
# Gap analysis
if args.monthly_savings > 0:
# Project with current savings rate
r_m = (1 + profile["real"]) ** (1 / 12) - 1
n = int(years * 12)
projected = current * (1 + r_m) ** n
if r_m != 0:
projected += args.monthly_savings * ((1 + r_m) ** n - 1) / r_m
gap = target_cop - projected
status = "on_track" if gap <= 0 else ("behind" if gap > target_cop * 0.1 else "close")
else:
projected = 0
gap = 0
status = "planning"
result = {
"goal": {
"target_cop": round(target_cop),
"target_usd": round(target_usd),
"target_date": args.target_date,
"years_remaining": round(years, 1),
},
"current": {
"savings_cop": round(current),
"savings_usd": round(current / trm),
"monthly_savings_cop": round(args.monthly_savings),
},
"required": {
"monthly_cop": round(req_monthly),
"monthly_usd": round(req_monthly / trm),
"annual_cop": round(req_monthly * 12),
},
"risk_profile": args.risk,
"expected_return": profile["real"],
"monte_carlo": mc,
"gap_analysis": {
"projected_cop": round(projected) if projected else None,
"gap_cop": round(gap) if gap else None,
"status": status,
},
"required_return_no_savings": round(required_return(target_cop, current, years, 0) * 100, 1),
}
if req_return is not None:
result["required_return_with_savings"] = round(req_return * 100, 1)
if args.json:
print(json.dumps(result, indent=2))
return
# Human-readable
print(f"\n{'='*70}")
print(f" GOAL PLANNER — {args.target_date}")
print(f"{'='*70}")
print(f" Target: ${target_cop:>15,.0f} COP (${target_usd:>10,.0f} USD)")
print(f" Time horizon: {years:.1f} years")
print(f" Current savings: ${current:>15,.0f} COP (${current/trm:>10,.0f} USD)")
print(f" Risk profile: {args.risk.capitalize()} ({profile['real']*100:.0f}% expected real return)")
print()
print(f" ── REQUIRED SAVINGS ──────────────────────────────────────────")
print(f" Monthly: ${req_monthly:>12,.0f} COP (${req_monthly/trm:>8,.0f} USD)")
print(f" Annual: ${req_monthly*12:>12,.0f} COP")
print()
if args.monthly_savings > 0 and status != "planning":
print(f" ── GAP ANALYSIS ──────────────────────────────────────────────")
print(f" Current monthly: ${args.monthly_savings:>12,.0f} COP")
print(f" Projected value: ${projected:>12,.0f} COP")
if gap > 0:
print(f" Gap: ${gap:>12,.0f} COP (BEHIND)")
else:
print(f" Surplus: ${abs(gap):>12,.0f} COP (ON TRACK)")
print()
print(f" ── RETURN ANALYSIS ───────────────────────────────────────────")
rr = required_return(target_cop, current, years, 0) * 100
print(f" Return needed (no contributions): {rr:.1f}%")
if req_return is not None:
print(f" Return needed (with current savings): {req_return*100:.1f}%")
print()
print(f" ── MONTE CARLO ({args.simulations:,d} simulations) ─────────────────────")
print(f" Probability of success: {mc['probability_pct']:.1f}%")
print(f" P10 (pessimistic): ${mc['p10']:>15,.0f}")
print(f" P25: ${mc['p25']:>15,.0f}")
print(f" P50 (median): ${mc['p50']:>15,.0f}")
print(f" P75: ${mc['p75']:>15,.0f}")
print(f" P90 (optimistic): ${mc['p90']:>15,.0f}")
print()
# Recommended profiles
print(f" ── SAVINGS BY RISK PROFILE ────────────────────────────────────")
for rp, rv in RISK_RETURNS.items():
ms = required_monthly_savings(target_cop, current, years, rv["real"])
marker = " <<<" if rp == args.risk else ""
print(f" {rp.capitalize():<14s} ({rv['real']*100:.0f}% return): ${ms:>12,.0f} COP/month{marker}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Portfolio health dashboard — descriptive analytics.
Aggregates holdings from finance-substrate (certificates, patrimonio, exogena)
and user portfolio data into a comprehensive wealth summary.
Usage:
python3 portfolio_summary.py --year 2025
python3 portfolio_summary.py --year 2025 --json
"""
import argparse
import json
import sys
from dataclasses import dataclass, asdict, field
from pathlib import Path
DATA_DIR = Path.home() / ".finance-substrate"
WM_DIR = Path.home() / ".wealth-management"
CERTS_FILE = DATA_DIR / "tax" / "certificates.jsonl"
EXOGENA_FILE = DATA_DIR / "tax" / "exogena.jsonl"
SALARY_FILE = DATA_DIR / "tax" / "salary-history.jsonl"
# Import patrimonio calculator from finance-substrate
FINANCE_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "finance-substrate" / "scripts"
if str(FINANCE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(FINANCE_SCRIPTS))
try:
from patrimonio_calc import compute_patrimonio
except ImportError:
compute_patrimonio = None
@dataclass
class AssetClass:
name: str
value_cop: float = 0
value_usd: float = 0
pct: float = 0
items: list = field(default_factory=list)
def load_current_trm() -> float:
trm_file = DATA_DIR / "fx" / "trm-history.jsonl"
if not trm_file.exists():
return 4000.0
latest = None
with open(trm_file) as f:
for line in f:
line = line.strip()
if line:
rec = json.loads(line)
if latest is None or rec["date"] > latest["date"]:
latest = rec
return latest["valor"] if latest else 4000.0
def load_certificates(year: int) -> list:
if not CERTS_FILE.exists():
return []
certs = []
with open(CERTS_FILE) as f:
for line in f:
line = line.strip()
if not line:
continue
rec = json.loads(line)
if rec.get("year") == year:
certs.append(rec)
return certs
def load_exogena(year: int) -> list:
if not EXOGENA_FILE.exists():
return []
recs = []
with open(EXOGENA_FILE) as f:
for line in f:
line = line.strip()
if not line:
continue
rec = json.loads(line)
if rec.get("year") == year:
recs.append(rec)
return recs
def load_salary_summary(year: int) -> dict:
if not SALARY_FILE.exists():
return {"total_usd": 0, "total_cop": 0, "payments": 0, "avg_rate": 0}
total_usd = 0
total_cop = 0
payments = 0
rates = []
with open(SALARY_FILE) as f:
for line in f:
line = line.strip()
if not line:
continue
rec = json.loads(line)
if rec["date"].startswith(str(year)):
total_usd += rec.get("amount_usd", 0)
total_cop += rec.get("gross_cop", 0)
payments += 1
if rec.get("exchange_rate"):
rates.append(rec["exchange_rate"])
return {
"total_usd": total_usd,
"total_cop": total_cop,
"payments": payments,
"avg_rate": sum(rates) / len(rates) if rates else 0,
}
def load_user_portfolio() -> list:
"""Load user-maintained portfolio holdings."""
portfolio_file = WM_DIR / "portfolio.json"
if not portfolio_file.exists():
return []
with open(portfolio_file) as f:
return json.load(f)
def classify_holdings(certs: list, exogena: list, user_portfolio: list, trm: float) -> dict:
"""Classify all holdings into asset classes."""
classes = {
"cash": AssetClass(name="Cash & Savings (COP)"),
"cash_usd": AssetClass(name="Cash & Savings (USD)"),
"fixed_income": AssetClass(name="Fixed Income (CDT, FIC, AFC)"),
"equities": AssetClass(name="Equities (Stocks, Funds)"),
"pension": AssetClass(name="Pension (Obligatoria + Voluntaria)"),
"real_estate": AssetClass(name="Real Estate"),
"other": AssetClass(name="Other"),
}
# From certificates
for cert in certs:
ts = cert.get("tax_summary", {})
entity = cert.get("entity", "Unknown")
# Bank accounts → cash
cuenta = ts.get("patrimonio_cuenta", ts.get("patrimonio_cuentas", 0))
if cuenta:
classes["cash"].value_cop += cuenta
classes["cash"].items.append({"entity": entity, "value": cuenta, "type": "cuenta bancaria"})
# AFC → fixed income (tax-deferred)
afc = ts.get("aportes_afc_r35", 0)
if afc:
classes["fixed_income"].value_cop += afc
classes["fixed_income"].items.append({"entity": entity, "value": afc, "type": "AFC"})
# Pension voluntaria → pension
vol = ts.get("patrimonio_voluntaria", 0)
if vol:
classes["pension"].value_cop += vol
classes["pension"].items.append({"entity": entity, "value": vol, "type": "pension voluntaria"})
# Cesantías → pension
ces = ts.get("patrimonio_cesantias", 0)
if ces:
classes["pension"].value_cop += ces
classes["pension"].items.append({"entity": entity, "value": ces, "type": "cesantias"})
# Acciones → equities
acc = ts.get("patrimonio_acciones", 0)
if acc:
classes["equities"].value_cop += acc
classes["equities"].items.append({"entity": entity, "value": acc, "type": "acciones"})
# Fondos → equities
fondos = ts.get("patrimonio_fondos", 0)
if fondos:
classes["equities"].value_cop += fondos
classes["equities"].items.append({"entity": entity, "value": fondos, "type": "FIC"})
# Deudas (negative)
deuda = ts.get("deuda_patrimonio", 0)
if deuda:
classes["other"].value_cop -= deuda
classes["other"].items.append({"entity": entity, "value": -deuda, "type": "deuda TC"})
# From exogena
for rec in exogena:
detail = rec.get("detail", "").lower()
value = rec.get("amount", rec.get("value", 0))
reporter = rec.get("reporter_name", "")
if "vehiculo" in detail or "avaluo" in detail:
classes["real_estate"].value_cop += value
classes["real_estate"].items.append({"entity": reporter, "value": value, "type": "vehiculo"})
elif "cuenta" in detail and "cobrar" in detail and value > 10_000_000:
# Large receivables (e.g. Marval real estate)
if "marval" in reporter.lower():
classes["real_estate"].value_cop += value
classes["real_estate"].items.append({"entity": reporter, "value": value, "type": "inmueble"})
else:
classes["other"].value_cop += value
classes["other"].items.append({"entity": reporter, "value": value, "type": "cuenta por cobrar"})
elif "inversion" in detail or "fondo" in detail:
classes["equities"].value_cop += value
classes["equities"].items.append({"entity": reporter, "value": value, "type": "inversion"})
# From user portfolio
for holding in user_portfolio:
cls = holding.get("asset_class", "other")
value = holding.get("value_cop", holding.get("value_usd", 0) * trm)
if cls in classes:
classes[cls].value_cop += value
classes[cls].items.append({
"entity": holding.get("name", "User holding"),
"value": value,
"type": holding.get("type", "manual"),
})
# Compute totals and percentages
total = sum(c.value_cop for c in classes.values())
for c in classes.values():
c.value_usd = round(c.value_cop / trm) if trm > 0 else 0
c.pct = round(c.value_cop / total * 100, 1) if total > 0 else 0
return {"classes": classes, "total_cop": total, "total_usd": round(total / trm)}
def main():
parser = argparse.ArgumentParser(description="Portfolio health dashboard")
parser.add_argument("--year", type=int, default=2024)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
trm = load_current_trm()
certs = load_certificates(args.year)
exogena = load_exogena(args.year)
user_portfolio = load_user_portfolio()
salary = load_salary_summary(args.year)
holdings = classify_holdings(certs, exogena, user_portfolio, trm)
classes = holdings["classes"]
# Concentration risk
concentration_warnings = []
total = holdings["total_cop"]
for cls_name, cls in classes.items():
for item in cls.items:
if total > 0 and item["value"] / total > 0.25:
concentration_warnings.append({
"item": f"{item['entity']} ({item['type']})",
"value": item["value"],
"pct": round(item["value"] / total * 100, 1),
})
# Liquidity analysis
liquid = classes["cash"].value_cop + classes["cash_usd"].value_cop
monthly_expenses = salary["total_cop"] / 12 * 0.6 if salary["total_cop"] > 0 else 15_000_000
liquid_months = liquid / monthly_expenses if monthly_expenses > 0 else 0
# Currency exposure
cop_exposure = sum(c.value_cop for name, c in classes.items() if name != "cash_usd")
usd_exposure = classes["cash_usd"].value_cop
result = {
"year": args.year,
"trm": round(trm, 2),
"total_cop": round(total),
"total_usd": holdings["total_usd"],
"allocation": {name: {"value_cop": round(c.value_cop), "pct": c.pct, "items": len(c.items)}
for name, c in classes.items() if c.value_cop != 0},
"concentration_warnings": concentration_warnings,
"liquidity": {
"liquid_cop": round(liquid),
"monthly_expenses_est": round(monthly_expenses),
"months_covered": round(liquid_months, 1),
},
"currency_exposure": {
"cop_pct": round(cop_exposure / total * 100, 1) if total > 0 else 100,
"usd_pct": round(usd_exposure / total * 100, 1) if total > 0 else 0,
},
"income": salary,
}
if args.json:
print(json.dumps(result, indent=2))
return
# Human-readable
print(f"\n{'='*70}")
print(f" PORTFOLIO SUMMARY — AG {args.year}")
print(f"{'='*70}")
print(f" Net worth: ${total:>15,.0f} COP (${total/trm:>10,.0f} USD)")
print(f" TRM: {trm:,.2f}")
if salary["total_usd"] > 0:
print(f" Income: ${salary['total_usd']:>10,.0f} USD ({salary['payments']} payments)")
print()
print(f" ── ASSET ALLOCATION ──────────────────────────────────────────")
print(f" {'Asset Class':<35s} {'Value (COP)':>15s} {'USD':>10s} {'%':>6s}")
print(f" {'-'*68}")
for name, cls in sorted(classes.items(), key=lambda x: -x[1].value_cop):
if cls.value_cop == 0 and not cls.items:
continue
print(f" {cls.name:<35s} ${cls.value_cop:>14,.0f} ${cls.value_usd:>9,.0f} {cls.pct:>5.1f}%")
for item in cls.items:
print(f" {item['entity'][:40]:<40s} ${item['value']:>12,.0f} ({item['type']})")
print(f" {'-'*68}")
print(f" {'TOTAL':<35s} ${total:>14,.0f} ${total/trm:>9,.0f} 100.0%")
print()
if concentration_warnings:
print(f" ── CONCENTRATION RISK ────────────────────────────────────────")
for w in concentration_warnings:
print(f" WARNING: {w['item']} = {w['pct']}% of portfolio (>${total*0.25:,.0f})")
print()
print(f" ── LIQUIDITY ─────────────────────────────────────────────────")
print(f" Liquid assets: ${liquid:>12,.0f} COP")
print(f" Est. monthly expenses: ${monthly_expenses:>10,.0f} COP")
print(f" Months covered: {liquid_months:>5.1f}")
if liquid_months < 6:
print(f" WARNING: Emergency fund below 6-month target ({liquid_months:.1f} months)")
print()
print(f" ── CURRENCY EXPOSURE ─────────────────────────────────────────")
cop_pct = cop_exposure / total * 100 if total > 0 else 100
usd_pct = usd_exposure / total * 100 if total > 0 else 0
print(f" COP: {cop_pct:>5.1f}% | USD: {usd_pct:>5.1f}%")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Compound growth wealth projector.
Projects long-term wealth accumulation with configurable assumptions
for return rates, inflation, contributions, and tax drag.
Reads starting capital from finance-substrate patrimonio data.
Usage:
python3 project_wealth.py --years 20 --monthly-contribution-usd 2000
python3 project_wealth.py --years 30 --risk aggressive --json
python3 project_wealth.py --years 10 --starting-capital 200000000
"""
import argparse
import json
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
DATA_DIR = Path.home() / ".finance-substrate"
WM_DIR = Path.home() / ".wealth-management"
SALARY_FILE = DATA_DIR / "tax" / "salary-history.jsonl"
# ────────────────────────────────────────────────────────────────────
# Risk profiles: expected real returns (after inflation, before tax)
# ────────────────────────────────────────────────────────────────────
RISK_PROFILES = {
"conservative": {
"label": "Conservative",
"expected_real_return": 0.03,
"volatility": 0.08,
"allocation": {"equities": 30, "fixed_income": 50, "real_estate": 10, "cash_afc": 10},
},
"moderate": {
"label": "Moderate",
"expected_real_return": 0.05,
"volatility": 0.12,
"allocation": {"equities": 55, "fixed_income": 25, "real_estate": 10, "cash_afc": 10},
},
"aggressive": {
"label": "Aggressive",
"expected_real_return": 0.07,
"volatility": 0.18,
"allocation": {"equities": 75, "fixed_income": 10, "real_estate": 10, "cash_afc": 5},
},
}
@dataclass
class YearProjection:
year: int
age: int
starting_value: float
contribution: float
growth: float
tax_drag: float
ending_value: float
ending_real: float # inflation-adjusted
cumulative_contributions: float
cumulative_growth: float
contribution_pct: float # % of ending value from contributions
def load_current_trm() -> float:
trm_file = DATA_DIR / "fx" / "trm-history.jsonl"
if not trm_file.exists():
return 4000.0
latest = None
with open(trm_file) as f:
for line in f:
line = line.strip()
if line:
rec = json.loads(line)
if latest is None or rec["date"] > latest["date"]:
latest = rec
return latest["valor"] if latest else 4000.0
def load_patrimonio_liquido(year: int) -> float:
"""Try to load patrimonio from finance-substrate cache."""
cache = DATA_DIR / "cache" / "last_projection.json"
if cache.exists():
with open(cache) as f:
proj = json.load(f)
return proj.get("form_210", {}).get("patrimonio", {}).get("R31_patrimonio_liquido", 0)
return 0
def load_salary_trajectory() -> dict:
"""Load salary history to estimate income growth."""
if not SALARY_FILE.exists():
return {"monthly_usd": 0, "years": {}}
years = {}
with open(SALARY_FILE) as f:
for line in f:
line = line.strip()
if not line:
continue
rec = json.loads(line)
yr = rec["date"][:4]
if yr not in years:
years[yr] = {"total_usd": 0, "count": 0}
years[yr]["total_usd"] += rec.get("amount_usd", 0)
years[yr]["count"] += 1
latest_yr = max(years.keys()) if years else None
monthly = 0
if latest_yr and years[latest_yr]["count"] > 0:
monthly = years[latest_yr]["total_usd"] / years[latest_yr]["count"]
return {"monthly_usd": monthly, "years": years}
def project(
starting_capital: float,
annual_contribution: float,
years: int,
real_return: float,
tax_drag_pct: float,
inflation: float,
starting_age: int = 30,
) -> list:
"""Run compound growth projection."""
results = []
value = starting_capital
cumulative_contrib = 0
cumulative_growth = 0
after_tax_return = real_return * (1 - tax_drag_pct / 100)
for y in range(1, years + 1):
growth = value * after_tax_return
tax_on_growth = abs(growth * tax_drag_pct / 100) if growth > 0 else 0
net_growth = growth
value_before = value
value = value + net_growth + annual_contribution
cumulative_contrib += annual_contribution
cumulative_growth += net_growth
# Inflation-adjusted value
real_value = value / ((1 + inflation) ** y)
contrib_pct = (cumulative_contrib / value * 100) if value > 0 else 0
results.append(YearProjection(
year=y,
age=starting_age + y,
starting_value=round(value_before),
contribution=round(annual_contribution),
growth=round(net_growth),
tax_drag=round(tax_on_growth),
ending_value=round(value),
ending_real=round(real_value),
cumulative_contributions=round(cumulative_contrib),
cumulative_growth=round(cumulative_growth),
contribution_pct=round(contrib_pct, 1),
))
return results
def find_milestones(projections: list, currency: str = "COP") -> list:
"""Find when portfolio crosses key milestones."""
if currency == "COP":
targets = [100_000_000, 500_000_000, 1_000_000_000, 2_000_000_000, 5_000_000_000]
labels = ["$100M", "$500M", "$1B", "$2B", "$5B"]
else:
targets = [50_000, 100_000, 250_000, 500_000, 1_000_000]
labels = ["$50K", "$100K", "$250K", "$500K", "$1M"]
milestones = []
for target, label in zip(targets, labels):
for p in projections:
if p.ending_value >= target:
milestones.append({
"label": label,
"year": p.year,
"age": p.age,
"value": p.ending_value,
})
break
return milestones
def crossover_year(projections: list) -> int:
"""Find when cumulative growth exceeds cumulative contributions."""
for p in projections:
if p.cumulative_growth > p.cumulative_contributions:
return p.year
return 0
def main():
parser = argparse.ArgumentParser(description="Compound wealth projector")
parser.add_argument("--years", type=int, default=20, help="Projection horizon")
parser.add_argument("--monthly-contribution-usd", type=float, default=0,
help="Monthly contribution in USD (0 = auto from budget available)")
parser.add_argument("--monthly-contribution-cop", type=float, default=0,
help="Monthly contribution in COP")
parser.add_argument("--starting-capital", type=float, default=0,
help="Starting capital in COP (0 = from patrimonio)")
parser.add_argument("--risk", choices=["conservative", "moderate", "aggressive"],
default="moderate", help="Risk profile")
parser.add_argument("--tax-drag", type=float, default=2.0, help="Annual tax drag %%")
parser.add_argument("--inflation", type=float, default=5.5, help="Annual inflation %%")
parser.add_argument("--age", type=int, default=30, help="Current age")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
profile = RISK_PROFILES[args.risk]
trm = load_current_trm()
# Starting capital
if args.starting_capital > 0:
starting = args.starting_capital
else:
starting = load_patrimonio_liquido(2025)
if starting <= 0:
starting = 29_000_000 # Fallback estimate
# Monthly contribution
if args.monthly_contribution_cop > 0:
monthly_cop = args.monthly_contribution_cop
elif args.monthly_contribution_usd > 0:
monthly_cop = args.monthly_contribution_usd * trm
else:
# Auto: estimate from salary available (assume 20% savings rate)
salary = load_salary_trajectory()
if salary["monthly_usd"] > 0:
monthly_cop = salary["monthly_usd"] * trm * 0.20
else:
monthly_cop = 5_000_000 # Default ~$1,300 USD
annual_contribution = monthly_cop * 12
projections = project(
starting_capital=starting,
annual_contribution=annual_contribution,
years=args.years,
real_return=profile["expected_real_return"],
tax_drag_pct=args.tax_drag,
inflation=args.inflation / 100,
starting_age=args.age,
)
milestones_cop = find_milestones(projections, "COP")
milestones_usd = find_milestones(
[YearProjection(**{**asdict(p), "ending_value": round(p.ending_value / trm)})
for p in projections],
"USD"
)
crossover = crossover_year(projections)
result = {
"parameters": {
"starting_capital_cop": round(starting),
"starting_capital_usd": round(starting / trm),
"monthly_contribution_cop": round(monthly_cop),
"monthly_contribution_usd": round(monthly_cop / trm),
"annual_contribution_cop": round(annual_contribution),
"risk_profile": args.risk,
"expected_real_return": profile["expected_real_return"],
"tax_drag_pct": args.tax_drag,
"inflation_pct": args.inflation,
"horizon_years": args.years,
"trm": round(trm, 2),
},
"projections": [asdict(p) for p in projections],
"milestones_cop": milestones_cop,
"milestones_usd": milestones_usd,
"crossover_year": crossover,
"final_value_cop": projections[-1].ending_value if projections else 0,
"final_value_usd": round(projections[-1].ending_value / trm) if projections else 0,
"final_real_cop": projections[-1].ending_real if projections else 0,
"total_contributed": projections[-1].cumulative_contributions if projections else 0,
"total_growth": projections[-1].cumulative_growth if projections else 0,
}
if args.json:
print(json.dumps(result, indent=2))
return
# Human-readable output
params = result["parameters"]
print(f"\n{'='*75}")
print(f" WEALTH PROJECTION — {args.years} Years ({profile['label']} Profile)")
print(f"{'='*75}")
print(f" Starting capital: ${starting:>15,.0f} COP (${starting/trm:>10,.0f} USD)")
print(f" Monthly contribution: ${monthly_cop:>15,.0f} COP (${monthly_cop/trm:>10,.0f} USD)")
print(f" Expected real return: {profile['expected_real_return']*100:.1f}%")
print(f" Tax drag: {args.tax_drag:.1f}%")
print(f" Inflation: {args.inflation:.1f}%")
print(f" TRM: {trm:,.2f}")
print()
# Year-by-year table (key years only)
show_years = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30]
show_years = [y for y in show_years if y <= args.years]
if args.years not in show_years:
show_years.append(args.years)
print(f" {'Yr':>3s} {'Age':>4s} {'Value (COP)':>18s} {'Value (USD)':>14s} {'Real (COP)':>16s} {'Contrib%':>8s}")
print(f" {'-'*69}")
for p in projections:
if p.year in show_years:
print(f" {p.year:>3d} {p.age:>4d} ${p.ending_value:>17,.0f} ${p.ending_value/trm:>13,.0f} ${p.ending_real:>15,.0f} {p.contribution_pct:>7.1f}%")
print()
# Milestones
if milestones_cop:
print(f" ── MILESTONES (COP) ──────────────────────────────────────────")
for m in milestones_cop:
print(f" {m['label']:>8s} → Year {m['year']:>2d} (age {m['age']})")
print()
if milestones_usd:
print(f" ── MILESTONES (USD) ──────────────────────────────────────────")
for m in milestones_usd:
print(f" {m['label']:>8s} → Year {m['year']:>2d} (age {m['age']})")
print()
if crossover > 0:
print(f" ── CROSSOVER ─────────────────────────────────────────────────")
print(f" Growth exceeds contributions at Year {crossover}")
print(f" After this point, your money works harder than you do.")
print()
# Summary
final = projections[-1]
print(f" ── SUMMARY ───────────────────────────────────────────────────")
print(f" Final value: ${final.ending_value:>15,.0f} COP (${final.ending_value/trm:>10,.0f} USD)")
print(f" Inflation-adjusted: ${final.ending_real:>15,.0f} COP")
print(f" Total contributed: ${final.cumulative_contributions:>15,.0f} COP")
print(f" Total growth: ${final.cumulative_growth:>15,.0f} COP")
print(f" Growth multiple: {final.ending_value / starting:.1f}x")
print(f" CAGR (nominal): {((final.ending_value / starting) ** (1/args.years) - 1) * 100:.1f}%")
# Sensitivity
print()
print(f" ── SENSITIVITY (Final value at different returns) ─────────")
for delta in [-2, -1, 0, 1, 2]:
alt_return = profile["expected_real_return"] + delta / 100
if alt_return <= 0:
continue
alt = project(starting, annual_contribution, args.years, alt_return, args.tax_drag, args.inflation / 100, args.age)
marker = " <<<" if delta == 0 else ""
print(f" {alt_return*100:>5.1f}% return → ${alt[-1].ending_value:>15,.0f} COP{marker}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
Monte Carlo & Stress Testing (Mode 6) — wealth-management skill.
Simulate portfolio outcomes under uncertainty. Runs Monte Carlo simulations
with configurable parameters, historical stress tests, and custom scenarios.
Outputs structured results consumable by autoany EGRI evaluator loops.
Usage:
python3 scenario_analysis.py --years 20 --simulations 10000
python3 scenario_analysis.py --years 30 --risk aggressive --goal-cop 2000000000
python3 scenario_analysis.py --stress-test 2008-gfc --allocation-file allocation.json
python3 scenario_analysis.py --years 20 --simulations 10000 --egri --goal-cop 1000000000
python3 scenario_analysis.py --years 20 --json
EGRI evaluator mode (structured JSON output for autoany):
python3 scenario_analysis.py --egri --goal-cop 1000000000 --years 20 \
--allocation-file contribution_plan.yaml
"""
from __future__ import annotations
import argparse
import json
import math
import random
import sys
from dataclasses import dataclass, asdict
from datetime import date
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Optional dependencies
# ---------------------------------------------------------------------------
_yaml = None
try:
import yaml as _yaml
except ImportError:
pass
# ---------------------------------------------------------------------------
# Constants & data directories
# ---------------------------------------------------------------------------
DATA_DIR = Path.home() / ".finance-substrate"
WM_DIR = Path.home() / ".wealth-management"
# Asset class expected returns and volatility (annualized, real after inflation)
ASSET_CLASSES = {
"us_equities": {"return": 0.07, "vol": 0.16, "label": "US Equities (S&P 500)"},
"intl_equities": {"return": 0.05, "vol": 0.18, "label": "Intl Equities"},
"col_equities": {"return": 0.05, "vol": 0.22, "label": "Colombian Equities (BVC)"},
"us_bonds": {"return": 0.02, "vol": 0.06, "label": "US Bonds"},
"col_fixed_income": {"return": 0.03, "vol": 0.04, "label": "Colombian CDT/FIC"},
"real_estate": {"return": 0.04, "vol": 0.12, "label": "Real Estate"},
"gold": {"return": 0.01, "vol": 0.15, "label": "Gold"},
"crypto": {"return": 0.10, "vol": 0.60, "label": "Crypto (BTC/ETH)"},
"pension_vol": {"return": 0.05, "vol": 0.10, "label": "Pensión Voluntaria"},
"afc": {"return": 0.02, "vol": 0.03, "label": "AFC (Davivienda)"},
"cash": {"return": 0.00, "vol": 0.01, "label": "Cash"},
}
# Risk profile allocations
RISK_PROFILES = {
"conservative": {
"us_equities": 0.15, "intl_equities": 0.05, "col_equities": 0.05,
"us_bonds": 0.20, "col_fixed_income": 0.25, "real_estate": 0.10,
"pension_vol": 0.10, "afc": 0.05, "cash": 0.05,
},
"moderate": {
"us_equities": 0.30, "intl_equities": 0.10, "col_equities": 0.05,
"us_bonds": 0.10, "col_fixed_income": 0.15, "real_estate": 0.10,
"pension_vol": 0.10, "afc": 0.05, "cash": 0.05,
},
"aggressive": {
"us_equities": 0.40, "intl_equities": 0.15, "col_equities": 0.05,
"us_bonds": 0.05, "col_fixed_income": 0.05, "real_estate": 0.05,
"crypto": 0.10, "pension_vol": 0.10, "afc": 0.03, "cash": 0.02,
},
}
# Correlation matrix (simplified — key pairs)
# In practice, asset correlations shift in crises (correlation = 1 during crashes)
CORRELATIONS = {
("us_equities", "intl_equities"): 0.75,
("us_equities", "col_equities"): 0.50,
("us_equities", "us_bonds"): -0.20,
("us_equities", "gold"): -0.05,
("us_equities", "crypto"): 0.40,
("us_equities", "real_estate"): 0.60,
("col_equities", "col_fixed_income"): 0.20,
("crypto", "gold"): 0.10,
}
# Historical stress scenarios: monthly returns over crisis period
STRESS_SCENARIOS = {
"2008-gfc": {
"label": "2008 Global Financial Crisis",
"duration_months": 18,
"shocks": {
"us_equities": -0.50, "intl_equities": -0.55, "col_equities": -0.40,
"us_bonds": 0.10, "col_fixed_income": -0.05, "real_estate": -0.30,
"gold": 0.15, "crypto": -0.80, "pension_vol": -0.20,
"afc": 0.01, "cash": 0.02,
},
},
"2020-covid": {
"label": "2020 COVID-19 Crash",
"duration_months": 3,
"shocks": {
"us_equities": -0.34, "intl_equities": -0.35, "col_equities": -0.45,
"us_bonds": 0.05, "col_fixed_income": -0.02, "real_estate": -0.15,
"gold": 0.03, "crypto": -0.50, "pension_vol": -0.15,
"afc": 0.01, "cash": 0.01,
},
},
"2022-rates": {
"label": "2022 Rate Hiking Cycle",
"duration_months": 12,
"shocks": {
"us_equities": -0.20, "intl_equities": -0.22, "col_equities": -0.15,
"us_bonds": -0.15, "col_fixed_income": -0.05, "real_estate": -0.10,
"gold": -0.02, "crypto": -0.65, "pension_vol": -0.10,
"afc": 0.02, "cash": 0.03,
},
},
"1999-colombia": {
"label": "1999 Colombian Financial Crisis",
"duration_months": 24,
"shocks": {
"us_equities": 0.10, "intl_equities": 0.05, "col_equities": -0.60,
"us_bonds": 0.08, "col_fixed_income": -0.20, "real_estate": -0.40,
"gold": 0.05, "crypto": 0, "pension_vol": -0.30,
"afc": -0.10, "cash": -0.05,
},
},
"cop-devaluation-30": {
"label": "COP Devaluation (+30% TRM)",
"duration_months": 6,
"shocks": {
"us_equities": 0.15, "intl_equities": 0.12, "col_equities": -0.20,
"us_bonds": 0.05, "col_fixed_income": -0.05, "real_estate": -0.10,
"gold": 0.10, "crypto": 0.05, "pension_vol": -0.08,
"afc": -0.02, "cash": -0.15,
},
},
"stagflation": {
"label": "Stagflation (High Inflation + Low Growth, 5 years)",
"duration_months": 60,
"shocks": {
"us_equities": -0.15, "intl_equities": -0.20, "col_equities": -0.25,
"us_bonds": -0.20, "col_fixed_income": -0.10, "real_estate": 0.10,
"gold": 0.40, "crypto": -0.30, "pension_vol": -0.15,
"afc": -0.05, "cash": -0.30,
},
},
"career-disruption": {
"label": "Career Disruption (0 income for 12 months)",
"duration_months": 12,
"shocks": {
"us_equities": 0, "intl_equities": 0, "col_equities": 0,
"us_bonds": 0, "col_fixed_income": 0, "real_estate": 0,
"gold": 0, "crypto": 0, "pension_vol": 0,
"afc": 0, "cash": 0,
},
"income_multiplier": 0,
},
}
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class ScenarioResult:
"""Results from a Monte Carlo simulation or stress test."""
scenario_type: str # "monte_carlo" or "stress_test"
scenario_name: str
years: int
simulations: int
allocation: dict[str, float]
starting_capital_cop: float
monthly_contribution_cop: float
goal_cop: float | None
# Monte Carlo outcomes
probability_of_goal_pct: float | None
percentiles: dict[str, float] # p5, p10, p25, p50, p75, p90, p95
mean_final_value: float
worst_case: float
best_case: float
# Risk metrics
median_max_drawdown_pct: float
probability_of_ruin_pct: float # P(portfolio < 0)
safe_withdrawal_rate_pct: float | None
sequence_risk_early_bear_pct: float # P(goal) if bear market in first 5 years
sequence_risk_late_bear_pct: float # P(goal) if bear market in last 5 years
# Stress test specific
stress_impact_pct: float | None
stress_recovery_months: int | None
def to_egri_outcome(self) -> dict:
"""Format as autoany EGRI Outcome for evaluator consumption."""
score = self.probability_of_goal_pct or 0
constraints_passed = True
violations = []
if self.median_max_drawdown_pct < -25:
constraints_passed = False
violations.append(f"median_max_drawdown={self.median_max_drawdown_pct:.1f}% exceeds -25% limit")
if self.probability_of_ruin_pct > 5:
constraints_passed = False
violations.append(f"ruin_probability={self.probability_of_ruin_pct:.1f}% exceeds 5% limit")
return {
"score": score,
"constraints_passed": constraints_passed,
"violations": violations,
"metrics": {
"probability_of_goal_pct": self.probability_of_goal_pct,
"median_final_value": self.percentiles.get("p50", 0),
"p10_final_value": self.percentiles.get("p10", 0),
"p90_final_value": self.percentiles.get("p90", 0),
"median_max_drawdown_pct": self.median_max_drawdown_pct,
"probability_of_ruin_pct": self.probability_of_ruin_pct,
"safe_withdrawal_rate_pct": self.safe_withdrawal_rate_pct,
"sequence_risk_early_bear_pct": self.sequence_risk_early_bear_pct,
"sequence_risk_late_bear_pct": self.sequence_risk_late_bear_pct,
},
}
# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------
def load_current_trm() -> float:
trm_file = DATA_DIR / "fx" / "trm-history.jsonl"
if not trm_file.exists():
return 4000.0
latest = None
with open(trm_file) as f:
for line in f:
line = line.strip()
if line:
rec = json.loads(line)
if latest is None or rec["date"] > latest["date"]:
latest = rec
return latest["valor"] if latest else 4000.0
def load_patrimonio() -> float:
cache = DATA_DIR / "cache" / "last_projection.json"
if cache.exists():
with open(cache) as f:
proj = json.load(f)
return proj.get("form_210", {}).get("patrimonio", {}).get("R31_patrimonio_liquido", 0)
return 0
def load_allocation_file(path: str) -> dict[str, float]:
"""Load allocation from JSON or YAML file (autoany artifact format)."""
with open(path) as f:
if path.endswith((".yaml", ".yml")):
if _yaml:
data = _yaml.safe_load(f)
else:
data = json.load(f) # fallback
else:
data = json.load(f)
# Support both flat dict and nested "allocation" key
if "allocation" in data:
return data["allocation"]
if "weights" in data:
return data["weights"]
return {k: v for k, v in data.items() if isinstance(v, (int, float))}
# ---------------------------------------------------------------------------
# Monte Carlo simulation engine
# ---------------------------------------------------------------------------
def run_monte_carlo(
allocation: dict[str, float],
starting_capital: float,
monthly_contribution: float,
years: int,
simulations: int = 10000,
goal: float | None = None,
seed: int = 42,
) -> ScenarioResult:
"""Run Monte Carlo simulation with given allocation."""
random.seed(seed)
n_months = years * 12
# Compute portfolio expected return and volatility from allocation
port_return = sum(
allocation.get(ac, 0) * ASSET_CLASSES[ac]["return"]
for ac in ASSET_CLASSES
)
# Portfolio vol (simplified — ignores correlations for speed, adds correlation penalty)
port_var = sum(
allocation.get(ac, 0) ** 2 * ASSET_CLASSES[ac]["vol"] ** 2
for ac in ASSET_CLASSES
)
# Add cross-correlation terms
for (a1, a2), corr in CORRELATIONS.items():
w1 = allocation.get(a1, 0)
w2 = allocation.get(a2, 0)
if w1 > 0 and w2 > 0:
port_var += 2 * w1 * w2 * corr * ASSET_CLASSES[a1]["vol"] * ASSET_CLASSES[a2]["vol"]
port_vol = math.sqrt(max(0, port_var))
monthly_return = (1 + port_return) ** (1/12) - 1
monthly_vol = port_vol / math.sqrt(12)
final_values = []
max_drawdowns = []
successes = 0
ruins = 0
# For sequence-of-returns risk
early_bear_successes = 0
early_bear_total = 0
late_bear_successes = 0
late_bear_total = 0
for sim in range(simulations):
value = starting_capital
peak = value
max_dd = 0
has_early_bear = False
has_late_bear = False
path = [value]
for month in range(n_months):
# Log-normal returns
r = random.gauss(monthly_return, monthly_vol)
value = value * (1 + r) + monthly_contribution
value = max(0, value)
path.append(value)
# Track drawdown
if value > peak:
peak = value
dd = (value - peak) / peak if peak > 0 else 0
max_dd = min(max_dd, dd)
# Detect bear market (>20% drawdown)
if dd < -0.20:
year_of_sim = month // 12
if year_of_sim < 5:
has_early_bear = True
if year_of_sim >= years - 5:
has_late_bear = True
final_values.append(value)
max_drawdowns.append(max_dd)
if value <= 0:
ruins += 1
if goal is not None and value >= goal:
successes += 1
if has_early_bear:
early_bear_successes += 1
if has_late_bear:
late_bear_successes += 1
if has_early_bear:
early_bear_total += 1
if has_late_bear:
late_bear_total += 1
# Sort for percentiles
final_values.sort()
max_drawdowns.sort()
def pct(arr, p):
idx = int(len(arr) * p / 100)
idx = max(0, min(idx, len(arr) - 1))
return round(arr[idx])
# Safe withdrawal rate (4% rule variant — find rate where 95% of simulations survive)
swr = None
if years >= 10:
for rate_bps in range(600, 0, -10): # 6% down to 0.1%
rate = rate_bps / 10000
annual_withdrawal = starting_capital * rate
monthly_wd = annual_withdrawal / 12
survived = 0
random.seed(seed + 999)
for _ in range(min(simulations, 2000)):
val = starting_capital
pk = val
for month in range(n_months):
r = random.gauss(monthly_return, monthly_vol)
val = val * (1 + r) - monthly_wd
if val <= 0:
break
if val > 0:
survived += 1
if survived / min(simulations, 2000) >= 0.95:
swr = rate * 100
break
return ScenarioResult(
scenario_type="monte_carlo",
scenario_name="Monte Carlo Simulation",
years=years,
simulations=simulations,
allocation=allocation,
starting_capital_cop=starting_capital,
monthly_contribution_cop=monthly_contribution,
goal_cop=goal,
probability_of_goal_pct=round(successes / simulations * 100, 1) if goal else None,
percentiles={
"p5": pct(final_values, 5),
"p10": pct(final_values, 10),
"p25": pct(final_values, 25),
"p50": pct(final_values, 50),
"p75": pct(final_values, 75),
"p90": pct(final_values, 90),
"p95": pct(final_values, 95),
},
mean_final_value=round(sum(final_values) / len(final_values)),
worst_case=round(final_values[0]),
best_case=round(final_values[-1]),
median_max_drawdown_pct=round(max_drawdowns[len(max_drawdowns) // 2] * 100, 1),
probability_of_ruin_pct=round(ruins / simulations * 100, 2),
safe_withdrawal_rate_pct=round(swr, 2) if swr else None,
sequence_risk_early_bear_pct=(
round(early_bear_successes / early_bear_total * 100, 1)
if early_bear_total > 0 else None
),
sequence_risk_late_bear_pct=(
round(late_bear_successes / late_bear_total * 100, 1)
if late_bear_total > 0 else None
),
stress_impact_pct=None,
stress_recovery_months=None,
)
# ---------------------------------------------------------------------------
# Stress test engine
# ---------------------------------------------------------------------------
def run_stress_test(
scenario_key: str,
allocation: dict[str, float],
starting_capital: float,
monthly_contribution: float = 0,
) -> ScenarioResult:
"""Run a historical stress scenario on the portfolio."""
scenario = STRESS_SCENARIOS[scenario_key]
duration = scenario["duration_months"]
shocks = scenario["shocks"]
income_mult = scenario.get("income_multiplier", 1.0)
# Compute portfolio-level shock
portfolio_shock = sum(
allocation.get(ac, 0) * shocks.get(ac, 0)
for ac in ASSET_CLASSES
)
# Simulate month-by-month decline
value = starting_capital
min_value = value
path = [value]
for month in range(duration):
# Distribute shock linearly across months
monthly_shock = portfolio_shock / duration
value = value * (1 + monthly_shock) + monthly_contribution * income_mult
value = max(0, value)
path.append(value)
min_value = min(min_value, value)
# Simulate recovery (assume normal returns resume)
port_return = sum(
allocation.get(ac, 0) * ASSET_CLASSES[ac]["return"]
for ac in ASSET_CLASSES
)
monthly_return = (1 + port_return) ** (1/12) - 1
recovery_months = 0
recovery_value = value
while recovery_value < starting_capital and recovery_months < 120:
recovery_value = recovery_value * (1 + monthly_return) + monthly_contribution
recovery_months += 1
impact_pct = (value - starting_capital) / starting_capital * 100
return ScenarioResult(
scenario_type="stress_test",
scenario_name=scenario["label"],
years=duration // 12,
simulations=1,
allocation=allocation,
starting_capital_cop=starting_capital,
monthly_contribution_cop=monthly_contribution,
goal_cop=None,
probability_of_goal_pct=None,
percentiles={
"min_during_stress": round(min_value),
"end_of_stress": round(value),
},
mean_final_value=round(value),
worst_case=round(min_value),
best_case=round(value),
median_max_drawdown_pct=round((min_value - starting_capital) / starting_capital * 100, 1),
probability_of_ruin_pct=100.0 if value <= 0 else 0.0,
safe_withdrawal_rate_pct=None,
sequence_risk_early_bear_pct=None,
sequence_risk_late_bear_pct=None,
stress_impact_pct=round(impact_pct, 1),
stress_recovery_months=recovery_months if recovery_value >= starting_capital else None,
)
# ---------------------------------------------------------------------------
# Display
# ---------------------------------------------------------------------------
def print_monte_carlo(result: ScenarioResult):
"""Pretty-print Monte Carlo results."""
r = result
trm = load_current_trm()
print(f"\n{'='*72}")
print(f" MONTE CARLO — {r.simulations:,d} simulations × {r.years} years")
print(f"{'='*72}")
print(f" Starting capital: ${r.starting_capital_cop:>15,.0f} COP (${r.starting_capital_cop/trm:>10,.0f} USD)")
print(f" Monthly contribution: ${r.monthly_contribution_cop:>15,.0f} COP")
print()
# Allocation
print(f" ── ALLOCATION ────────────────────────────────────────────────")
for ac, weight in sorted(r.allocation.items(), key=lambda x: -x[1]):
if weight > 0:
label = ASSET_CLASSES.get(ac, {}).get("label", ac)
print(f" {label:<30s} {weight*100:>5.1f}%")
print()
# Goal
if r.goal_cop and r.probability_of_goal_pct is not None:
print(f" ── GOAL ──────────────────────────────────────────────────────")
print(f" Target: ${r.goal_cop:>15,.0f} COP (${r.goal_cop/trm:>10,.0f} USD)")
bar_len = int(r.probability_of_goal_pct / 2)
bar = "█" * bar_len + "░" * (50 - bar_len)
print(f" Probability: {r.probability_of_goal_pct:>5.1f}% [{bar}]")
print()
# Outcome distribution
p = r.percentiles
print(f" ── OUTCOME DISTRIBUTION ──────────────────────────────────────")
print(f" Worst case: ${r.worst_case:>15,.0f} COP")
print(f" P5 (pessimistic): ${p.get('p5', 0):>15,.0f} COP")
print(f" P10: ${p.get('p10', 0):>15,.0f} COP")
print(f" P25: ${p.get('p25', 0):>15,.0f} COP")
print(f" P50 (median): ${p.get('p50', 0):>15,.0f} COP")
print(f" P75: ${p.get('p75', 0):>15,.0f} COP")
print(f" P90 (optimistic): ${p.get('p90', 0):>15,.0f} COP")
print(f" P95: ${p.get('p95', 0):>15,.0f} COP")
print(f" Best case: ${r.best_case:>15,.0f} COP")
print(f" Mean: ${r.mean_final_value:>15,.0f} COP")
print()
# Risk
print(f" ── RISK METRICS ──────────────────────────────────────────────")
print(f" Median Max Drawdown: {r.median_max_drawdown_pct:>8.1f}%")
print(f" Probability of Ruin: {r.probability_of_ruin_pct:>8.2f}%")
if r.safe_withdrawal_rate_pct:
print(f" Safe Withdrawal Rate: {r.safe_withdrawal_rate_pct:>8.2f}% (95% survival)")
print()
# Sequence risk
if r.sequence_risk_early_bear_pct is not None or r.sequence_risk_late_bear_pct is not None:
print(f" ── SEQUENCE-OF-RETURNS RISK ──────────────────────────────────")
if r.sequence_risk_early_bear_pct is not None:
print(f" P(goal | bear in first 5yr): {r.sequence_risk_early_bear_pct:>5.1f}%")
if r.sequence_risk_late_bear_pct is not None:
print(f" P(goal | bear in last 5yr): {r.sequence_risk_late_bear_pct:>5.1f}%")
def print_stress_test(result: ScenarioResult):
"""Pretty-print stress test results."""
r = result
trm = load_current_trm()
print(f"\n ── STRESS TEST: {r.scenario_name} ──")
print(f" Starting: ${r.starting_capital_cop:>15,.0f} COP")
print(f" After shock: ${r.mean_final_value:>15,.0f} COP")
print(f" Impact: {r.stress_impact_pct:>+8.1f}%")
print(f" Max DD: {r.median_max_drawdown_pct:>8.1f}%")
if r.stress_recovery_months is not None:
print(f" Recovery: {r.stress_recovery_months:>8d} months")
else:
print(f" Recovery: NOT RECOVERED (within 10 years)")
if r.probability_of_ruin_pct > 0:
print(f" RUIN: YES — portfolio wiped out")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Monte Carlo & Stress Testing — wealth-management skill Mode 6",
)
parser.add_argument("--years", type=int, default=20, help="Projection horizon")
parser.add_argument("--simulations", type=int, default=10000, help="Monte Carlo runs")
parser.add_argument("--risk", choices=["conservative", "moderate", "aggressive"], default="moderate")
parser.add_argument("--allocation-file", help="Load allocation from JSON/YAML (autoany artifact)")
parser.add_argument("--starting-capital", type=float, default=0, help="Starting capital COP (0=auto)")
parser.add_argument("--monthly-contribution", type=float, default=0, help="Monthly contribution COP")
parser.add_argument("--monthly-contribution-usd", type=float, default=0, help="Monthly contribution USD")
parser.add_argument("--goal-cop", type=float, default=0, help="Target portfolio value COP")
parser.add_argument("--goal-usd", type=float, default=0, help="Target portfolio value USD")
parser.add_argument("--stress-test", help="Run specific stress test (e.g. 2008-gfc, 2020-covid)")
parser.add_argument("--all-stress", action="store_true", help="Run all stress scenarios")
parser.add_argument("--seed", type=int, default=42, help="Random seed for reproducibility")
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--egri", action="store_true",
help="Output as EGRI Outcome (for autoany evaluator)")
args = parser.parse_args()
trm = load_current_trm()
# Load allocation
if args.allocation_file:
allocation = load_allocation_file(args.allocation_file)
else:
allocation = RISK_PROFILES[args.risk]
# Starting capital
capital = args.starting_capital
if capital <= 0:
capital = load_patrimonio()
if capital <= 0:
capital = 50_000_000 # Default ~$12.5K USD
# Monthly contribution
monthly = args.monthly_contribution
if monthly <= 0 and args.monthly_contribution_usd > 0:
monthly = args.monthly_contribution_usd * trm
# Goal
goal = None
if args.goal_cop > 0:
goal = args.goal_cop
elif args.goal_usd > 0:
goal = args.goal_usd * trm
results = {}
# Run Monte Carlo
mc_result = run_monte_carlo(
allocation=allocation,
starting_capital=capital,
monthly_contribution=monthly,
years=args.years,
simulations=args.simulations,
goal=goal,
seed=args.seed,
)
results["monte_carlo"] = asdict(mc_result)
# Run stress tests
stress_results = {}
if args.stress_test:
if args.stress_test in STRESS_SCENARIOS:
sr = run_stress_test(args.stress_test, allocation, capital, monthly)
stress_results[args.stress_test] = asdict(sr)
else:
print(f"Unknown stress test: {args.stress_test}", file=sys.stderr)
print(f"Available: {', '.join(STRESS_SCENARIOS.keys())}", file=sys.stderr)
raise SystemExit(1)
elif args.all_stress:
for key in STRESS_SCENARIOS:
sr = run_stress_test(key, allocation, capital, monthly)
stress_results[key] = asdict(sr)
if stress_results:
results["stress_tests"] = stress_results
# Output
if args.egri:
outcome = mc_result.to_egri_outcome()
print(json.dumps(outcome, indent=2))
elif args.json:
print(json.dumps(results, indent=2))
else:
print_monte_carlo(mc_result)
if stress_results:
print(f"\n{'='*72}")
print(f" STRESS TESTS")
print(f"{'='*72}")
for key, sr_dict in stress_results.items():
sr = ScenarioResult(**sr_dict)
print_stress_test(sr)
print()
# Save results
results_dir = WM_DIR / "scenarios"
results_dir.mkdir(parents=True, exist_ok=True)
ts = date.today().isoformat()
result_file = results_dir / f"scenario-{ts}.json"
with open(result_file, "w") as f:
json.dump(results, f, indent=2)
if __name__ == "__main__":
main()
{
"name": "wealth-management",
"version": "0.1.0",
"description": "Wealth management, financial planning, and investment analytics. Compounds on finance-substrate for tax-optimized wealth building with descriptive, predictive, and prescriptive analytics.",
"entrypoint": "scripts/portfolio_summary.py",
"inputs_schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["summary", "project", "goal", "allocation", "rebalance", "scenario", "optimize"],
"description": "Skill mode: summary (portfolio health), project (compound growth), goal (target planning), allocation (asset mix), rebalance (trades), scenario (Monte Carlo), optimize (tax-efficient strategy)"
},
"year": {
"type": "integer",
"description": "Tax/analysis year (default: current year)"
},
"years": {
"type": "integer",
"description": "Projection horizon in years (for project/goal/scenario modes)",
"default": 20
},
"monthly_contribution_usd": {
"type": "number",
"description": "Monthly investment contribution in USD"
},
"monthly_contribution_cop": {
"type": "number",
"description": "Monthly investment contribution in COP"
},
"target_amount_usd": {
"type": "number",
"description": "Goal target amount in USD (for goal mode)"
},
"target_amount_cop": {
"type": "number",
"description": "Goal target amount in COP (for goal mode)"
},
"target_date": {
"type": "string",
"format": "date",
"description": "Goal target date YYYY-MM-DD (for goal mode)"
},
"risk_profile": {
"type": "string",
"enum": ["conservative", "moderate", "aggressive"],
"default": "moderate",
"description": "Risk tolerance for allocation and projection assumptions"
},
"drift_threshold": {
"type": "number",
"default": 5.0,
"description": "Rebalancing trigger: % absolute deviation from target (for rebalance mode)"
},
"simulations": {
"type": "integer",
"default": 10000,
"description": "Number of Monte Carlo simulations (for scenario mode)"
},
"inflation_cop": {
"type": "number",
"default": 5.5,
"description": "Assumed annual Colombian CPI inflation %"
},
"inflation_usd": {
"type": "number",
"default": 2.5,
"description": "Assumed annual US CPI inflation %"
},
"output": {
"type": "string",
"description": "Output file path (default: stdout)"
},
"json": {
"type": "boolean",
"default": false,
"description": "Output as JSON instead of human-readable"
}
},
"required": ["mode"],
"allOf": [
{
"if": { "properties": { "mode": { "const": "project" } } },
"then": { "required": ["years"] }
},
{
"if": { "properties": { "mode": { "const": "goal" } } },
"then": { "required": ["target_date"] }
},
{
"if": { "properties": { "mode": { "const": "allocation" } } },
"then": { "required": ["risk_profile"] }
},
{
"if": { "properties": { "mode": { "const": "scenario" } } },
"then": { "required": ["years"] }
}
]
}
}
# EGRI Problem Spec: Contribution Optimization
# Optimizes how monthly savings are split across tax-advantaged and
# taxable vehicles to maximize probability of reaching financial goals.
# Uses scenario_analysis.py (Monte Carlo) as evaluator.
#
# Trigger: user asks "how should I split my savings", "optimize contributions",
# "AFC vs pension vs brokerage", "maximize probability of goal"
#
# Usage: /autoany --spec templates/egri/contribution-optimization.yaml
name: contribution-optimization
objective:
metric: probability_of_goal_pct
type: scalar
direction: maximize
baseline: null # Established by running evaluator on current contribution plan
constraints:
- "afc_annual_cop <= 66_731_060" # 1,340 UVT cap (2025)
- "pension_vol_annual_cop <= 66_731_060" # Combined cap with AFC
- "afc_plus_pension_annual <= 66_731_060" # Combined cap
- "total_monthly <= monthly_budget_cop" # Can't save more than income allows
- "total_allocation_pct == 100" # All savings accounted for
- "median_max_drawdown_pct > -25" # Risk constraint
- "probability_of_ruin_pct <= 5" # Can't risk going broke
artifacts:
mutable:
- path: "contribution_plan.yaml"
type: config
description: >
Monthly contribution splits: how much goes to AFC (Davivienda),
pensión voluntaria (Skandia), libre inversión (brokerage),
crypto allocation, and emergency fund.
Also includes the asset allocation within each vehicle.
immutable:
- path: "~/.finance-substrate/tax/salary-history.jsonl"
reason: "Income trajectory — determines budget constraint"
- path: "~/.finance-substrate/cache/last_projection.json"
reason: "Current patrimonio (starting capital)"
- path: "scripts/scenario_analysis.py"
reason: "Monte Carlo evaluator — immutable per EGRI Law 3"
evaluator:
script: >
python3 scripts/scenario_analysis.py
--allocation-file {artifact}
--years {years}
--goal-cop {goal}
--simulations 10000
--egri
inputs:
- "{artifact}"
- "salary-history.jsonl"
- "last_projection.json"
outputs:
score: "probability_of_goal_pct"
metrics:
- "median_final_value"
- "p10_final_value"
- "median_max_drawdown_pct"
- "probability_of_ruin_pct"
- "safe_withdrawal_rate_pct"
constraints:
- "median_max_drawdown_pct > -25"
- "probability_of_ruin_pct <= 5"
trusted: true
baseline_score: null
execution:
backend: local
command: >
python3 scripts/scenario_analysis.py
--allocation-file {artifact}
--years 20
--goal-cop 2000000000
--simulations 10000
--egri
timeout_s: 60
sandbox: false
budget:
max_trials: 20
time_per_trial_s: 60
total_time_s: 600
promotion:
policy: keep_if_improves
threshold: 1.0 # Minimum 1% probability improvement to promote
require_constraint_check: true
autonomy:
mode: sandbox
escalation_triggers:
- "evaluator_score_degrades_3_consecutive_trials"
- "constraint_violation_detected"
- "probability_of_ruin_pct exceeds 3"
search:
proposer: llm
strategy_notes: >
Optimize contribution splits using Colombian tax law as prior:
- AFC is most flexible (housing withdrawal exemption)
- Pensión voluntaria has best tax benefit if held 10yr+
- Libre inversión (brokerage) provides liquidity
- Max AFC+VP combined benefit is 1,340 UVT (~$66.7M COP)
- Start by maximizing tax-advantaged contributions, then tune allocation
- Consider the user's time horizon: shorter → more AFC, longer → more VP
- Adjust asset allocation within each vehicle (equities in VP, bonds in AFC)
- Read the ledger: which vehicle/allocation combos drove probability up?
- If probability plateaus, try adding a small crypto or gold allocation
- Remember: the goal is P(goal) subject to ruin constraint, not max return
ledger:
format: jsonl
path: "~/.wealth-management/egri/contribution-optimization/ledger.jsonl"
domain:
preset: generic
notes: "Colombian tax-optimized contribution plan optimization via Monte Carlo"
meta:
created_by: "wealth-management skill"
version: "0.1.0"
# EGRI Problem Spec: Investment Horizon Evaluation
# Optimizes asset allocation across multiple time-horizon buckets
# (short-term, medium-term, long-term) to simultaneously maximize
# probability of reaching all financial goals.
#
# Trigger: user asks "how should I allocate for my goals", "optimize by horizon",
# "retirement vs housing fund allocation", "multi-goal portfolio"
#
# Usage: /autoany --spec templates/egri/horizon-evaluation.yaml
name: horizon-evaluation
objective:
metric: risk_adjusted_goal_probability
type: scalar
direction: maximize
baseline: null
constraints:
- "total_allocation_pct == 100"
- "short_term_bucket_vol <= 0.08" # Short-term money shouldn't be volatile
- "no_bucket_has_zero_allocation"
- "afc_plus_pension_annual <= 66_731_060" # 1,340 UVT cap
- "probability_of_ruin_pct <= 5"
artifacts:
mutable:
- path: "horizon_plan.yaml"
type: config
description: >
Multi-horizon allocation plan. Defines goals with target amounts
and dates, then allocates capital + monthly contributions across
time-horizon buckets with per-bucket asset allocation.
Structure:
goals:
- name: housing
target_cop: 300000000
target_date: 2029-01-01
bucket: short_term
- name: education
target_cop: 500000000
target_date: 2036-01-01
bucket: medium_term
- name: retirement
target_cop: 5000000000
target_date: 2055-01-01
bucket: long_term
buckets:
short_term: # 0-5 years
allocation: { col_fixed_income: 0.60, afc: 0.30, cash: 0.10 }
monthly_cop: 2000000
medium_term: # 5-15 years
allocation: { us_equities: 0.40, col_fixed_income: 0.30, pension_vol: 0.20, gold: 0.10 }
monthly_cop: 3000000
long_term: # 15+ years
allocation: { us_equities: 0.50, intl_equities: 0.20, crypto: 0.10, real_estate: 0.10, pension_vol: 0.10 }
monthly_cop: 5000000
immutable:
- path: "~/.finance-substrate/tax/salary-history.jsonl"
reason: "Income trajectory"
- path: "~/.finance-substrate/cache/last_projection.json"
reason: "Current patrimonio"
- path: "scripts/scenario_analysis.py"
reason: "Monte Carlo evaluator"
evaluator:
script: "python3 scripts/eval_horizon.py --plan {artifact} --egri"
inputs:
- "{artifact}"
outputs:
score: "risk_adjusted_goal_probability"
metrics:
- "per_goal_probability"
- "aggregate_probability"
- "weighted_sharpe"
- "worst_bucket_drawdown"
constraints:
- "short_term_bucket_vol <= 0.08"
- "probability_of_ruin_pct <= 5"
trusted: false # Requires validation
baseline_score: null
execution:
backend: local
command: "python3 scripts/eval_horizon.py --plan {artifact} --egri"
timeout_s: 120
sandbox: false
budget:
max_trials: 40
time_per_trial_s: 120
total_time_s: 2400
promotion:
policy: keep_if_improves
threshold: 0.5 # Minimum 0.5% aggregate probability improvement
require_constraint_check: true
autonomy:
mode: sandbox
escalation_triggers:
- "evaluator_score_degrades_3_consecutive_trials"
- "any_single_goal_probability_below_30"
- "budget_75_percent_exhausted_without_improvement"
search:
proposer: llm
strategy_notes: >
Optimize multi-horizon allocation using time-based risk principles:
- Short-term (0-5yr): prioritize capital preservation (CDTs, AFC, cash)
- Medium-term (5-15yr): balanced growth (60/40 equity/bond mix)
- Long-term (15yr+): maximize growth (heavy equities, some crypto/RE)
- The aggregate score is probability-weighted: P(all goals) matters more
than P(any single goal)
- Consider goal priority: retirement may be more important than housing
- Adjust monthly contribution splits between buckets
- Try graduated risk: start aggressive in long-term bucket, shift conservative
as goal date approaches (glide path)
- AFC is ideal for the housing goal (tax-free housing withdrawal)
- Pensión voluntaria is ideal for retirement (tax-free after 10yr + pension age)
- Read ledger: which bucket adjustments had the biggest impact?
ledger:
format: jsonl
path: "~/.wealth-management/egri/horizon-evaluation/ledger.jsonl"
domain:
preset: generic
notes: "Multi-horizon goal optimization via per-bucket Monte Carlo simulation"
meta:
created_by: "wealth-management skill"
version: "0.1.0"
Related skills
FAQ
What analytics modes does it have?
Descriptive portfolio health, predictive compound-growth and Monte Carlo projections, and prescriptive allocation, rebalancing, and withdrawal strategy.
What data does it use?
It builds on finance-substrate for bank certificates, net-worth snapshots, salary history, and TRM FX rates, plus user-provided holdings.