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.
- planner_lab/__init__.py +9 -0
- planner_lab/adapters/__init__.py +74 -0
- planner_lab/adapters/csv_import/__init__.py +4 -0
- planner_lab/adapters/csv_import/importer.py +183 -0
- planner_lab/adapters/csv_import/mapping.py +66 -0
- planner_lab/adapters/fundedness_metric/__init__.py +3 -0
- planner_lab/adapters/fundedness_metric/metric.py +175 -0
- planner_lab/adapters/lifecycle/__init__.py +3 -0
- planner_lab/adapters/lifecycle/allocation.py +71 -0
- planner_lab/adapters/mcp_research/__init__.py +3 -0
- planner_lab/adapters/mcp_research/source.py +94 -0
- planner_lab/adapters/monteplan/__init__.py +3 -0
- planner_lab/adapters/monteplan/simulator.py +161 -0
- planner_lab/agents/__init__.py +6 -0
- planner_lab/agents/llm_critic.py +60 -0
- planner_lab/agents/memo_writer.py +186 -0
- planner_lab/agents/models.py +30 -0
- planner_lab/agents/orchestrator.py +64 -0
- planner_lab/agents/pipeline.py +292 -0
- planner_lab/agents/state.py +18 -0
- planner_lab/agents/structured.py +46 -0
- planner_lab/agents/tools.py +238 -0
- planner_lab/calculators/__init__.py +25 -0
- planner_lab/calculators/conversions.py +24 -0
- planner_lab/calculators/fi_timeline.py +56 -0
- planner_lab/calculators/funded_ratio.py +27 -0
- planner_lab/calculators/withdrawal.py +28 -0
- planner_lab/case_io.py +21 -0
- planner_lab/cli.py +334 -0
- planner_lab/critic/__init__.py +3 -0
- planner_lab/critic/checks.py +242 -0
- planner_lab/critic/run.py +40 -0
- planner_lab/hooks/__init__.py +3 -0
- planner_lab/hooks/compliance.py +47 -0
- planner_lab/memo/__init__.py +4 -0
- planner_lab/memo/disclaimer.py +9 -0
- planner_lab/memo/render.py +99 -0
- planner_lab/protocols.py +65 -0
- planner_lab/schemas/__init__.py +55 -0
- planner_lab/schemas/assumptions.py +80 -0
- planner_lab/schemas/case_file.py +172 -0
- planner_lab/schemas/critic.py +43 -0
- planner_lab/schemas/memo.py +68 -0
- planner_lab/schemas/results.py +156 -0
- planner_lab/telemetry.py +19 -0
- planner_lab-0.1.0.dist-info/METADATA +185 -0
- planner_lab-0.1.0.dist-info/RECORD +50 -0
- planner_lab-0.1.0.dist-info/WHEEL +4 -0
- planner_lab-0.1.0.dist-info/entry_points.txt +2 -0
- planner_lab-0.1.0.dist-info/licenses/LICENSE +21 -0
planner_lab/__init__.py
ADDED
|
@@ -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,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,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,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
|
+
)
|