planner-lab 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.
Files changed (50) hide show
  1. planner_lab/__init__.py +9 -0
  2. planner_lab/adapters/__init__.py +74 -0
  3. planner_lab/adapters/csv_import/__init__.py +4 -0
  4. planner_lab/adapters/csv_import/importer.py +183 -0
  5. planner_lab/adapters/csv_import/mapping.py +66 -0
  6. planner_lab/adapters/fundedness_metric/__init__.py +3 -0
  7. planner_lab/adapters/fundedness_metric/metric.py +175 -0
  8. planner_lab/adapters/lifecycle/__init__.py +3 -0
  9. planner_lab/adapters/lifecycle/allocation.py +71 -0
  10. planner_lab/adapters/mcp_research/__init__.py +3 -0
  11. planner_lab/adapters/mcp_research/source.py +94 -0
  12. planner_lab/adapters/monteplan/__init__.py +3 -0
  13. planner_lab/adapters/monteplan/simulator.py +161 -0
  14. planner_lab/agents/__init__.py +6 -0
  15. planner_lab/agents/llm_critic.py +60 -0
  16. planner_lab/agents/memo_writer.py +186 -0
  17. planner_lab/agents/models.py +30 -0
  18. planner_lab/agents/orchestrator.py +64 -0
  19. planner_lab/agents/pipeline.py +292 -0
  20. planner_lab/agents/state.py +18 -0
  21. planner_lab/agents/structured.py +46 -0
  22. planner_lab/agents/tools.py +238 -0
  23. planner_lab/calculators/__init__.py +25 -0
  24. planner_lab/calculators/conversions.py +24 -0
  25. planner_lab/calculators/fi_timeline.py +56 -0
  26. planner_lab/calculators/funded_ratio.py +27 -0
  27. planner_lab/calculators/withdrawal.py +28 -0
  28. planner_lab/case_io.py +21 -0
  29. planner_lab/cli.py +334 -0
  30. planner_lab/critic/__init__.py +3 -0
  31. planner_lab/critic/checks.py +242 -0
  32. planner_lab/critic/run.py +40 -0
  33. planner_lab/hooks/__init__.py +3 -0
  34. planner_lab/hooks/compliance.py +47 -0
  35. planner_lab/memo/__init__.py +4 -0
  36. planner_lab/memo/disclaimer.py +9 -0
  37. planner_lab/memo/render.py +99 -0
  38. planner_lab/protocols.py +65 -0
  39. planner_lab/schemas/__init__.py +55 -0
  40. planner_lab/schemas/assumptions.py +80 -0
  41. planner_lab/schemas/case_file.py +172 -0
  42. planner_lab/schemas/critic.py +43 -0
  43. planner_lab/schemas/memo.py +68 -0
  44. planner_lab/schemas/results.py +156 -0
  45. planner_lab/telemetry.py +19 -0
  46. planner_lab-0.1.0.dist-info/METADATA +185 -0
  47. planner_lab-0.1.0.dist-info/RECORD +50 -0
  48. planner_lab-0.1.0.dist-info/WHEEL +4 -0
  49. planner_lab-0.1.0.dist-info/entry_points.txt +2 -0
  50. planner_lab-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,9 @@
1
+ """planner-lab: provider-neutral framework for auditable financial planning agents."""
2
+
3
+ from planner_lab.schemas.case_file import CaseFile
4
+ from planner_lab.schemas.critic import CriticReport
5
+ from planner_lab.schemas.memo import PlanningMemo
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = ["CaseFile", "CriticReport", "PlanningMemo", "__version__"]
@@ -0,0 +1,74 @@
1
+ """Lazy loaders for optional integrations.
2
+
3
+ Each loader imports its adapter only on request and raises AdapterUnavailableError
4
+ with the exact extra to install when the dependency is missing. Core code calls
5
+ these loaders; it never imports adapter modules directly.
6
+ """
7
+
8
+ from planner_lab.protocols import (
9
+ CashflowImporter,
10
+ FinancialHealthMetric,
11
+ PortfolioAnalyticsEngine,
12
+ ResearchSource,
13
+ ScenarioSimulator,
14
+ )
15
+
16
+
17
+ class AdapterUnavailableError(RuntimeError):
18
+ pass
19
+
20
+
21
+ def get_cashflow_importer(fmt: str = "generic") -> CashflowImporter:
22
+ from planner_lab.adapters.csv_import.importer import CsvCashflowImporter
23
+ from planner_lab.adapters.csv_import.mapping import PRESETS
24
+
25
+ if fmt not in PRESETS:
26
+ raise AdapterUnavailableError(f"unknown CSV format {fmt!r}; choose from {sorted(PRESETS)}")
27
+ return CsvCashflowImporter(PRESETS[fmt])
28
+
29
+
30
+ def get_simulator(name: str = "montecarlo") -> ScenarioSimulator:
31
+ if name == "montecarlo":
32
+ try:
33
+ from planner_lab.adapters.monteplan.simulator import MontePlanSimulator
34
+ except ImportError as e:
35
+ raise AdapterUnavailableError(
36
+ "Monte Carlo simulation requires the 'planning' extra: "
37
+ "uv sync --extra planning (needs Python >= 3.11)"
38
+ ) from e
39
+ return MontePlanSimulator()
40
+ raise AdapterUnavailableError(f"unknown simulator {name!r}")
41
+
42
+
43
+ def get_research_source(url: str) -> ResearchSource:
44
+ try:
45
+ from planner_lab.adapters.mcp_research.source import MCPResearchSource
46
+ except ImportError as e:
47
+ raise AdapterUnavailableError(
48
+ "Research sources require the 'mcp' extra: uv sync --extra mcp"
49
+ ) from e
50
+ return MCPResearchSource(url)
51
+
52
+
53
+ def get_health_metric(name: str = "funded") -> FinancialHealthMetric:
54
+ if name == "funded":
55
+ try:
56
+ from planner_lab.adapters.fundedness_metric.metric import FundednessMetric
57
+ except ImportError as e:
58
+ raise AdapterUnavailableError(
59
+ "Financial-health metrics require the 'planning' extra: uv sync --extra planning"
60
+ ) from e
61
+ return FundednessMetric()
62
+ raise AdapterUnavailableError(f"unknown health metric {name!r}")
63
+
64
+
65
+ def get_portfolio_engine(name: str = "lifecycle") -> PortfolioAnalyticsEngine:
66
+ if name == "lifecycle":
67
+ try:
68
+ from planner_lab.adapters.lifecycle.allocation import LifecycleAllocationEngine
69
+ except ImportError as e:
70
+ raise AdapterUnavailableError(
71
+ "Portfolio analytics require the 'portfolio' extra: uv sync --extra portfolio"
72
+ ) from e
73
+ return LifecycleAllocationEngine()
74
+ raise AdapterUnavailableError(f"unknown portfolio engine {name!r}")
@@ -0,0 +1,4 @@
1
+ from planner_lab.adapters.csv_import.importer import CsvCashflowImporter
2
+ from planner_lab.adapters.csv_import.mapping import PRESETS, ColumnMapping
3
+
4
+ __all__ = ["ColumnMapping", "CsvCashflowImporter", "PRESETS"]
@@ -0,0 +1,183 @@
1
+ """Derive annual cash flow from a transactions CSV. Stdlib only.
2
+
3
+ Positive amounts are money in, negative are money out. Transfers between the
4
+ user's own accounts (category set or payee prefix, per mapping) are excluded
5
+ from income and expenses: credit-card payments and brokerage buys are not
6
+ spending; spending was already counted at the card/merchant transaction level.
7
+
8
+ The window is the last 12 complete calendar months ending at the month of the
9
+ latest transaction. Fewer complete months are annualized by scaling, with a
10
+ warning. Transaction income is post-tax, so it maps to annual_take_home;
11
+ annual_gross_income is left unset.
12
+
13
+ Known limitation: some apps' query-style exports include both split parents
14
+ and split children; exact-duplicate rows trigger a warning, but prefer
15
+ per-account register exports.
16
+ """
17
+
18
+ import csv
19
+ import datetime
20
+ from pathlib import Path
21
+
22
+ from planner_lab.adapters.csv_import.mapping import ColumnMapping
23
+ from planner_lab.schemas.case_file import CashFlow
24
+ from planner_lab.schemas.results import CashflowImportResult
25
+
26
+ _DATE_FORMATS = ("%m/%d/%Y", "%m/%d/%y")
27
+ _CURRENCY_CHARS = "$€£"
28
+
29
+
30
+ def _parse_amount(raw: str) -> float:
31
+ """Normalize amount strings: currency symbols, thousands separators,
32
+ parentheses negatives, leading formula-guard apostrophes, empty cells."""
33
+ text = raw.strip().strip('"').lstrip("'").strip()
34
+ if not text:
35
+ return 0.0
36
+ negative = text.startswith("(") and text.endswith(")")
37
+ if negative:
38
+ text = text[1:-1]
39
+ for char in _CURRENCY_CHARS:
40
+ text = text.replace(char, "")
41
+ text = text.replace(",", "").strip()
42
+ if not text:
43
+ return 0.0
44
+ value = float(text)
45
+ return -abs(value) if negative else value
46
+
47
+
48
+ def _parse_date(raw: str) -> datetime.date:
49
+ text = raw.strip().strip('"').lstrip("'")
50
+ try:
51
+ return datetime.date.fromisoformat(text)
52
+ except ValueError:
53
+ pass
54
+ for fmt in _DATE_FORMATS:
55
+ try:
56
+ return datetime.datetime.strptime(text, fmt).date()
57
+ except ValueError:
58
+ continue
59
+ raise ValueError(f"unparseable date {raw!r}; expected ISO or US month/day/year")
60
+
61
+
62
+ def _month_start(day: datetime.date) -> datetime.date:
63
+ return day.replace(day=1)
64
+
65
+
66
+ def _shift_months(day: datetime.date, months: int) -> datetime.date:
67
+ total = day.year * 12 + (day.month - 1) + months
68
+ return datetime.date(total // 12, total % 12 + 1, 1)
69
+
70
+
71
+ class CsvCashflowImporter:
72
+ name = "csv"
73
+
74
+ def __init__(self, mapping: ColumnMapping):
75
+ self._mapping = mapping
76
+
77
+ def _row_amount(self, row: dict[str, str]) -> float:
78
+ m = self._mapping
79
+ if m.amount is not None:
80
+ return _parse_amount(row[m.amount])
81
+ assert m.inflow is not None and m.outflow is not None
82
+ return _parse_amount(row[m.inflow]) - abs(_parse_amount(row[m.outflow]))
83
+
84
+ def _is_transfer(self, row: dict[str, str]) -> bool:
85
+ m = self._mapping
86
+ if m.category is not None:
87
+ category = (row.get(m.category) or "").strip()
88
+ if category in m.transfer_categories:
89
+ return True
90
+ if m.payee is not None and m.transfer_payee_prefixes:
91
+ payee = (row.get(m.payee) or "").strip()
92
+ if payee.startswith(m.transfer_payee_prefixes):
93
+ return True
94
+ return False
95
+
96
+ def import_cashflow(self, path: Path) -> CashflowImportResult:
97
+ m = self._mapping
98
+ warnings: list[str] = []
99
+ rows: list[tuple[datetime.date, float, bool, tuple[str, ...]]] = []
100
+ with open(path, encoding="utf-8-sig", newline="") as f:
101
+ reader = csv.DictReader(f)
102
+ headers = reader.fieldnames or []
103
+ missing = [h for h in m.required_headers() if h not in headers]
104
+ if missing:
105
+ raise ValueError(f"CSV is missing expected column(s) {missing}; found {headers}")
106
+ for row in reader:
107
+ raw_date = (row.get(m.date) or "").strip()
108
+ if not raw_date:
109
+ continue
110
+ day = _parse_date(raw_date)
111
+ amount = self._row_amount(row)
112
+ dedupe_key = tuple(str(row.get(col) or "") for col in headers)
113
+ rows.append((day, amount, self._is_transfer(row), dedupe_key))
114
+ if not rows:
115
+ raise ValueError(f"{path} contains no transactions")
116
+
117
+ seen: set[tuple[str, ...]] = set()
118
+ duplicates = 0
119
+ for _, _, _, key in rows:
120
+ if key in seen:
121
+ duplicates += 1
122
+ seen.add(key)
123
+ if duplicates:
124
+ warnings.append(
125
+ f"{duplicates} exact-duplicate row(s) found; split-transaction exports "
126
+ "can double-count. Totals include them; verify the export format."
127
+ )
128
+
129
+ latest = max(day for day, _, _, _ in rows)
130
+ window_end = _month_start(latest) # exclusive: complete months only
131
+ window_start = _shift_months(window_end, -12)
132
+ in_window = [r for r in rows if window_start <= r[0] < window_end]
133
+ if not in_window:
134
+ raise ValueError(
135
+ "no transactions fall inside a complete calendar month; "
136
+ "the export is too short to derive annual cash flow"
137
+ )
138
+
139
+ earliest = min(day for day, _, _, _ in in_window)
140
+ months_covered = min(
141
+ (window_end.year * 12 + window_end.month) - (earliest.year * 12 + earliest.month),
142
+ 12,
143
+ )
144
+
145
+ income = expenses = transfers = 0.0
146
+ for _, amount, is_transfer, _ in in_window:
147
+ if is_transfer:
148
+ transfers += abs(amount)
149
+ elif amount >= 0:
150
+ income += amount
151
+ else:
152
+ expenses += -amount
153
+ if months_covered < 12:
154
+ scale = 12 / months_covered
155
+ income *= scale
156
+ expenses *= scale
157
+ transfers *= scale
158
+ warnings.append(
159
+ f"only {months_covered} complete month(s) of data; totals were "
160
+ f"annualized by scaling with 12/{months_covered}"
161
+ )
162
+
163
+ savings = income - expenses
164
+ if savings < 0:
165
+ warnings.append(
166
+ f"expenses exceed income by ${-savings:,.0f}/year; annual_savings recorded as 0"
167
+ )
168
+ savings = 0.0
169
+
170
+ return CashflowImportResult(
171
+ cash_flow=CashFlow(
172
+ annual_take_home=round(income, 2),
173
+ annual_expenses=round(expenses, 2),
174
+ annual_savings=round(savings, 2),
175
+ ),
176
+ window_start=window_start,
177
+ window_end=window_end,
178
+ months_covered=months_covered,
179
+ total_inflow=round(income, 2),
180
+ total_outflow=round(expenses, 2),
181
+ excluded_transfer_amount=round(transfers, 2),
182
+ warnings=warnings,
183
+ )
@@ -0,0 +1,66 @@
1
+ """Column mappings for transaction-CSV exports from common budgeting apps.
2
+
3
+ Header names are community-documented conventions; the importer matches by
4
+ header name, never by position. Transfers between the user's own accounts are
5
+ excluded from income/expense totals via category names or payee prefixes,
6
+ following each app's own cash-flow convention.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ColumnMapping:
14
+ date: str
15
+ amount: str | None = None # single signed column, XOR the inflow/outflow pair
16
+ inflow: str | None = None
17
+ outflow: str | None = None
18
+ category: str | None = None
19
+ account: str | None = None
20
+ payee: str | None = None
21
+ transfer_categories: frozenset[str] = field(default_factory=frozenset)
22
+ transfer_payee_prefixes: tuple[str, ...] = ()
23
+
24
+ def __post_init__(self) -> None:
25
+ has_amount = self.amount is not None
26
+ has_pair = self.inflow is not None and self.outflow is not None
27
+ if has_amount == has_pair:
28
+ raise ValueError(
29
+ "mapping needs either a signed 'amount' column or an "
30
+ "inflow/outflow pair, not both or neither"
31
+ )
32
+
33
+ def required_headers(self) -> list[str]:
34
+ headers = [self.date]
35
+ headers += [c for c in (self.amount, self.inflow, self.outflow) if c is not None]
36
+ return headers
37
+
38
+
39
+ PRESETS: dict[str, ColumnMapping] = {
40
+ "generic": ColumnMapping(date="Date", amount="Amount", category="Category", payee="Payee"),
41
+ "monarch": ColumnMapping(
42
+ date="Date",
43
+ amount="Amount",
44
+ category="Category",
45
+ account="Account",
46
+ payee="Merchant",
47
+ transfer_categories=frozenset({"Transfer", "Credit Card Payment", "Buy", "Sell"}),
48
+ ),
49
+ "actual": ColumnMapping(
50
+ date="Date",
51
+ amount="Amount",
52
+ category="Category",
53
+ account="Account",
54
+ payee="Payee",
55
+ transfer_payee_prefixes=("Transfer",),
56
+ ),
57
+ "ynab": ColumnMapping(
58
+ date="Date",
59
+ inflow="Inflow",
60
+ outflow="Outflow",
61
+ category="Category",
62
+ account="Account",
63
+ payee="Payee",
64
+ transfer_payee_prefixes=("Transfer : ",),
65
+ ),
66
+ }
@@ -0,0 +1,3 @@
1
+ from planner_lab.adapters.fundedness_metric.metric import FundednessMetric
2
+
3
+ __all__ = ["FundednessMetric"]
@@ -0,0 +1,175 @@
1
+ """FinancialHealthMetric adapter for the fundedness package (CEFR).
2
+
3
+ CEFR = certainty-equivalent funded ratio: assets after tax, liquidity, and
4
+ reliability haircuts divided by the present value of future spending streams.
5
+
6
+ Mapping notes (verified against fundedness 0.2.4 source):
7
+ - Asset values are passed GROSS; the engine applies tax haircuts internally,
8
+ so pre-netting traditional balances would double-tax them.
9
+ - fundedness Liability objects are spending streams, not debt balances. The
10
+ retirement spending target becomes a CPI-linked essential-spending stream;
11
+ case debts with a minimum payment become fixed streams until paid; debts
12
+ without a payment become a one-year lump so they are never silently ignored.
13
+ - Liabilities are discounted at a fixed 2% real rate (the package default,
14
+ a liability-matching convention). Discounting at the portfolio's expected
15
+ return would flatter optimistic scenarios on both sides of the ratio.
16
+ - concentration_level is always DIVERSIFIED: the case file carries only a
17
+ free-text concentration note, so single-stock risk is not modeled here.
18
+ """
19
+
20
+ import math
21
+
22
+ from fundedness import Asset, BalanceSheet, Household, Liability, Person, compute_cefr
23
+ from fundedness.models.assets import AccountType, AssetClass, LiquidityClass
24
+ from fundedness.models.liabilities import InflationLinkage, LiabilityType
25
+
26
+ from planner_lab.schemas.assumptions import AssumptionSet
27
+ from planner_lab.schemas.case_file import CaseFile
28
+ from planner_lab.schemas.results import MetricResult
29
+
30
+ _REAL_DISCOUNT_RATE = 0.02
31
+
32
+ # our account_type -> (fundedness AccountType, LiquidityClass, default AssetClass)
33
+ _ACCOUNT_MAP: dict[str, tuple[AccountType, LiquidityClass, AssetClass]] = {
34
+ "taxable": (AccountType.TAXABLE, LiquidityClass.TAXABLE_INDEX, AssetClass.STOCKS),
35
+ "traditional": (AccountType.TAX_DEFERRED, LiquidityClass.RETIREMENT, AssetClass.STOCKS),
36
+ "roth": (AccountType.TAX_EXEMPT, LiquidityClass.RETIREMENT, AssetClass.STOCKS),
37
+ "hsa": (AccountType.HSA, LiquidityClass.RETIREMENT, AssetClass.STOCKS),
38
+ "cash": (AccountType.TAXABLE, LiquidityClass.CASH, AssetClass.CASH),
39
+ "education": (AccountType.TAX_EXEMPT, LiquidityClass.RETIREMENT, AssetClass.STOCKS),
40
+ "other": (AccountType.TAXABLE, LiquidityClass.TAXABLE_INDEX, AssetClass.STOCKS),
41
+ }
42
+
43
+
44
+ def _build_assets(case: CaseFile) -> list[Asset]:
45
+ """One Asset per account, or up to three per non-cash account split by the
46
+ portfolio's stock/bond/cash weights when a portfolio is present (fundedness
47
+ haircuts differ by asset class; all-stocks would overstate the haircut)."""
48
+ assets: list[Asset] = []
49
+ portfolio = case.portfolio
50
+ for account in case.balance_sheet.accounts:
51
+ account_type, liquidity, default_class = _ACCOUNT_MAP[account.account_type]
52
+ if portfolio is None or default_class is AssetClass.CASH:
53
+ assets.append(
54
+ Asset(
55
+ name=account.name,
56
+ value=account.balance,
57
+ account_type=account_type,
58
+ asset_class=default_class,
59
+ liquidity_class=liquidity,
60
+ )
61
+ )
62
+ continue
63
+ slices = (
64
+ (AssetClass.STOCKS, portfolio.stock_pct, "stocks"),
65
+ (AssetClass.BONDS, portfolio.bond_pct, "bonds"),
66
+ (AssetClass.CASH, portfolio.cash_pct, "cash"),
67
+ )
68
+ for asset_class, weight, label in slices:
69
+ if weight <= 0:
70
+ continue
71
+ assets.append(
72
+ Asset(
73
+ name=f"{account.name} ({label})",
74
+ value=account.balance * weight,
75
+ account_type=account_type,
76
+ asset_class=asset_class,
77
+ liquidity_class=liquidity,
78
+ )
79
+ )
80
+ return assets
81
+
82
+
83
+ def _build_liabilities(case: CaseFile, current_age: int, retirement_age: int) -> list[Liability]:
84
+ target = case.retirement_spending_target()
85
+ if target is None:
86
+ raise ValueError(
87
+ "case file has no retirement spending target: set "
88
+ "goals[kind=retirement].annual_amount_today or cash_flow.annual_expenses"
89
+ )
90
+ liabilities = [
91
+ Liability(
92
+ name="retirement_spending",
93
+ liability_type=LiabilityType.ESSENTIAL_SPENDING,
94
+ annual_amount=target,
95
+ start_year=max(retirement_age - current_age, 0),
96
+ end_year=None,
97
+ inflation_linkage=InflationLinkage.CPI,
98
+ )
99
+ ]
100
+ for debt in case.balance_sheet.liabilities:
101
+ if debt.balance <= 0:
102
+ continue
103
+ if debt.minimum_annual_payment:
104
+ liabilities.append(
105
+ Liability(
106
+ name=debt.name,
107
+ liability_type=LiabilityType.DEBT,
108
+ annual_amount=debt.minimum_annual_payment,
109
+ start_year=0,
110
+ end_year=math.ceil(debt.balance / debt.minimum_annual_payment),
111
+ inflation_linkage=InflationLinkage.NONE,
112
+ )
113
+ )
114
+ else:
115
+ liabilities.append(
116
+ Liability(
117
+ name=debt.name,
118
+ liability_type=LiabilityType.DEBT,
119
+ annual_amount=debt.balance,
120
+ start_year=0,
121
+ end_year=1,
122
+ inflation_linkage=InflationLinkage.NONE,
123
+ )
124
+ )
125
+ return liabilities
126
+
127
+
128
+ def _build_household(case: CaseFile, assumptions: AssumptionSet) -> Household:
129
+ current_year = case.created.year
130
+ members = []
131
+ for person in case.household.persons:
132
+ age = person.age_in(current_year)
133
+ members.append(
134
+ Person(
135
+ name=person.name,
136
+ age=age,
137
+ retirement_age=person.planned_retirement_age or 65,
138
+ life_expectancy=max(assumptions.plan_end_age, age + 1),
139
+ )
140
+ )
141
+ first = case.household.persons[0]
142
+ current_age = first.age_in(current_year)
143
+ retirement_age = first.planned_retirement_age or 65
144
+ return Household(
145
+ members=members,
146
+ balance_sheet=BalanceSheet(assets=_build_assets(case)),
147
+ liabilities=_build_liabilities(case, current_age, retirement_age),
148
+ )
149
+
150
+
151
+ class FundednessMetric:
152
+ name = "funded"
153
+
154
+ def compute(self, case: CaseFile, assumptions: AssumptionSet) -> MetricResult:
155
+ household = _build_household(case, assumptions)
156
+ current_age = case.household.persons[0].age_in(case.created.year)
157
+ result = compute_cefr(
158
+ household=household,
159
+ planning_horizon=max(assumptions.plan_end_age - current_age, 1),
160
+ real_discount_rate=_REAL_DISCOUNT_RATE,
161
+ base_inflation=assumptions.inflation,
162
+ )
163
+ return MetricResult(
164
+ metric_name="cefr",
165
+ value=result.cefr,
166
+ components={
167
+ "gross_assets": result.gross_assets,
168
+ "total_tax_haircut": result.total_tax_haircut,
169
+ "total_liquidity_haircut": result.total_liquidity_haircut,
170
+ "total_reliability_haircut": result.total_reliability_haircut,
171
+ "net_assets": result.net_assets,
172
+ "liability_pv": result.liability_pv,
173
+ },
174
+ interpretation=result.get_interpretation(),
175
+ )
@@ -0,0 +1,3 @@
1
+ from planner_lab.adapters.lifecycle.allocation import LifecycleAllocationEngine
2
+
3
+ __all__ = ["LifecycleAllocationEngine"]
@@ -0,0 +1,71 @@
1
+ """PortfolioAnalyticsEngine adapter for the lifecycle-allocation package.
2
+
3
+ Produces a Merton-style model-implied stock share with human-capital
4
+ adjustment, as a DIAGNOSTIC benchmark against the current allocation — never a
5
+ recommendation to trade. lifecycle-allocation's rates are real by default,
6
+ matching this project's real-rate convention.
7
+ """
8
+
9
+ from lifecycle_allocation import (
10
+ InvestorProfile,
11
+ recommended_stock_share,
12
+ )
13
+ from lifecycle_allocation import (
14
+ MarketAssumptions as LifecycleMarketAssumptions, # monteplan defines the same class name
15
+ )
16
+
17
+ from planner_lab.schemas.assumptions import AssumptionSet
18
+ from planner_lab.schemas.case_file import CaseFile
19
+ from planner_lab.schemas.results import PortfolioDiagnostics
20
+
21
+ _REAL_RISK_FREE = 0.02 # the case file carries no risk-free assumption; documented constant
22
+ _DEFAULT_RISK_TOLERANCE = 5 # 1-10 scale; the case file carries no risk-preference field
23
+ _DIAGNOSTIC_NOTE = (
24
+ "Diagnostic comparison of a lifecycle-model benchmark against the current "
25
+ "allocation; not a recommendation to change the allocation."
26
+ )
27
+
28
+
29
+ class LifecycleAllocationEngine:
30
+ name = "lifecycle"
31
+
32
+ def analyze(self, case: CaseFile, assumptions: AssumptionSet) -> PortfolioDiagnostics:
33
+ person = case.household.persons[0]
34
+ age = person.age_in(case.created.year)
35
+ retirement_age = person.planned_retirement_age or 65
36
+ if retirement_age <= age:
37
+ retirement_age = age + 1
38
+ wealth = case.balance_sheet.investable_assets
39
+ if wealth <= 0:
40
+ raise ValueError("lifecycle allocation needs investable assets > 0")
41
+
42
+ profile = InvestorProfile(
43
+ age=age,
44
+ retirement_age=retirement_age,
45
+ investable_wealth=wealth,
46
+ after_tax_income=case.cash_flow.annual_take_home,
47
+ risk_tolerance=_DEFAULT_RISK_TOLERANCE,
48
+ )
49
+ market = LifecycleMarketAssumptions(
50
+ mu=assumptions.expected_return_real,
51
+ r=_REAL_RISK_FREE,
52
+ sigma=assumptions.return_volatility,
53
+ real=True,
54
+ )
55
+ result = recommended_stock_share(profile, market)
56
+
57
+ findings: dict[str, float] = {
58
+ "alpha_recommended": result.alpha_recommended,
59
+ "alpha_star": result.alpha_star,
60
+ "alpha_unconstrained": result.alpha_unconstrained,
61
+ "human_capital": result.human_capital,
62
+ "hw_ratio": float(result.components["hw_ratio"]),
63
+ "gamma": float(result.components["gamma"]),
64
+ }
65
+ if case.portfolio is not None:
66
+ findings["current_stock_pct"] = case.portfolio.stock_pct
67
+ return PortfolioDiagnostics(
68
+ engine_name=self.name,
69
+ findings=findings,
70
+ notes=[result.explain, _DIAGNOSTIC_NOTE],
71
+ )
@@ -0,0 +1,3 @@
1
+ from planner_lab.adapters.mcp_research.source import MCPResearchSource, ResearchToolError
2
+
3
+ __all__ = ["MCPResearchSource", "ResearchToolError"]