Private Wealth Advisory Planner
You fill a fixed JSON schema for one client per task. Every number is reproducible from the advisory API plus the rules below — there is no judgment-call math. Match the conventions exactly; the grader compares fields to a gold answer computed the same way.
0. Workflow
- Read
input/prompt.txt,input/payloads/request_memo.md, andinput/payloads/answer_template.json. The memo gives the client ID, theanalysis_type, and (for Roth tasks) the planning horizon year. - Identify the family from
analysis_typein the template:roth_conversion_rmd,ilit_crummey_implementation,trust_comparison, orestate_liquidity_action_plan. - Pull every fact from the API, never from your own knowledge. Base URL is
whatever the harness exposes (env
API_BASE, usuallyhttp://127.0.0.1:8066). Tax constants and RMD factors come from/api/policies/taxand/api/rmd-factors— do not hardcode IRS numbers. - Compute with the formulas in this file. The bundled
scripts/advisory_lib.pyalready implements all four families and reproduces every train gold answer exactly — prefer importing it over re-deriving math by hand. - Emit only the JSON object the template requires (no prose, no markdown
fence). Set
task_idto the value the harness expects (e.g.test_007); if the prompt does not state it, mirror the task folder (train_NNN/test_NNN).
Fastest path:
import sys; sys.path.insert(0, "scripts") # adjust to where advisory_lib.py lives
import advisory_lib as A
print(A.solve_roth("CLT-1001", horizon_year=2046)) # or solve_ilit / solve_trust / solve_estate_plan
Read scripts/advisory_lib.py to see each rule in code, and
references/families.md for a per-field cheat sheet and worked numeric examples.
1. Source resolution — which record wins
The same fact can appear in several source documents that disagree (older CRM import vs. attorney memo vs. signed profile) and in the client header. Resolve by fact domain, using the system of record for that domain:
| Fact domain | Source of truth (precedence) | Reported as |
|---|---|---|
| Profile / goals / beneficiary count / income / marginal rate / age / filing / marital | SIGNED_PROFILE → ATTORNEY_MEMO → CRM_NOTE |
controlling_profile_source / controlling_goal_source / controlling_beneficiary_source = SIGNED_PROFILE |
| Retirement account balances, return, RMD start age, conversion years | /api/retirement-accounts (CUSTODIAN_EXPORT) |
controlling_account_source = CUSTODIAN_EXPORT |
| Insurance policy facts (death benefit, premium, dates, transfer flag) | /api/life-insurance, validated against SIGNED_PROFILE |
controlling_policy_source = SIGNED_PROFILE |
| Estate / asset valuation for trust funding | ATTORNEY_MEMO → SIGNED_PROFILE → CRM_NOTE |
controlling_asset_source = ATTORNEY_MEMO |
Why: the signed profile is the most recent, client-signed, and most complete
document, so it governs anything the household attested to. The custodian export is
the system of record for accounts. For trust funding the attorney memo is the
authoritative estate-asset figure, so controlling_asset_source is ATTORNEY_MEMO
even though the signed profile usually carries the same estate value.
The controlling_*_source enum values are essentially constant per family (the
table's right column). Report them as shown; don't try to "detect" the winner per
task.
2. Rounding, dates, output
- All USD amounts are JSON numbers rounded to cents (2 decimals). Use
round(x + 1e-9, 2)to avoid half-cent surprises. Never emit strings for numbers. - Dates are ISO
YYYY-MM-DDstrings. action_set(estate plan only) must be sorted alphabetically.- Output the JSON object and nothing else.
3. Roth conversion + RMD (roth_conversion_rmd)
Inputs: profile (income, marginal rate, age, planning year, filing) from
SIGNED_PROFILE; account (traditional/Roth balances, expected_return,
rmd_start_age, recommended_conversion_years) from the custodian export;
conversion_bracket_targets[filing] and estate_tax_exemption/factors from policy.
The horizon year comes from the memo.
Conversion sizing (fill the bracket):
annual_conversion_amount = conversion_bracket_targets[filing] - annual_non_ira_incomeconversion_years = recommended_conversion_years;first_conversion_year = planning_yeartotal_converted = annual_conversion_amount * conversion_yearstotal_conversion_tax = total_converted * marginal_tax_ratefirst_rmd_year = (planning_year - age) + rmd_start_age
Year-by-year simulation — strict order of operations per year, run from
planning_year through horizon_year inclusive. Run it twice: a baseline (no
conversions) and a conversion scenario.
- Convert (conversion scenario only, during the first
conversion_yearsyears): movemin(annual_conversion_amount, traditional)from traditional to Roth. - RMD (when
age_that_year >= rmd_start_ageand a factor exists):rmd = traditional / rmd_factor[age]; subtract from traditional; addrmd * marginal_tax_rateto that scenario's RMD-tax total. - Grow both buckets by
expected_returnat the end of the year.
Getting the order wrong (e.g. growing before the RMD, or taking the RMD before the conversion) shifts every downstream cent, so keep convert → RMD → grow.
Outputs: baseline_rmd_tax_through_horizon and
conversion_rmd_tax_through_horizon are the two RMD-tax totals;
rmd_tax_savings_through_horizon = baseline - conversion. The horizon balances
(projected_roth_balance_horizon, projected_traditional_balance_horizon) are the
conversion-scenario buckets after the horizon year.
Enums: primary_action = STAGED_ROTH_CONVERSION and suitability = SUITABLE
when there is bracket headroom (annual_conversion_amount > 0) and savings are
positive; NO_CONVERSION/DEFER if there is no headroom; DEFER/BORDERLINE if
conversions don't save tax. risk_flag = RMD_NEAR_TERM when the owner is already at
/ past rmd_start_age in the planning year, otherwise TAX_BRACKET_MANAGEMENT.
heir_tax_profile: MOSTLY_TAX_FREE if Roth ≥ 2× traditional at horizon,
MOSTLY_TAXABLE if traditional ≥ 2× Roth, else MIXED_TAXABLE_AND_TAX_FREE.
4. ILIT / Crummey (ilit_crummey_implementation)
Inputs: beneficiary_count from SIGNED_PROFILE; the policy
(death_benefit, annual_premium, planned_contribution_date,
is_existing_policy_transfer) from /api/life-insurance;
annual_gift_exclusion[planning_year] and estate_tax_rate from policy.
annual_exclusion_per_beneficiary = annual_gift_exclusion[planning_year]annual_exclusion_capacity = exclusion_per_beneficiary * beneficiary_countpremium_gap = max(0, annual_premium - annual_exclusion_capacity)notices_required = beneficiary_count;dedicated_bank_account_required = truedeath_benefitstraight from the policytax_liquidity_support = death_benefit * estate_tax_rateprojected_outside_estate_if_implemented = death_benefitfor a new policy;0whenis_existing_policy_transferis true (3-year lookback pulls it back into the estate for the first cycle).
Crummey date arithmetic from planned_contribution_date:
contribution_date = planned_contribution_datenotice_due_date = contribution_date + 7 dayswithdrawal_window_end = notice_due_date + 30 daysearliest_premium_payment_date = withdrawal_window_end + 1 day
Risk flag (also estate_inclusion_risk): let lookback = is_existing_policy_transfer, shortfall = premium_gap > 0.
THREE_YEAR_LOOKBACK_AND_EXCLUSION_SHORTFALL if both;
THREE_YEAR_LOOKBACK if only transfer; EXCLUSION_SHORTFALL if only shortfall;
else LOW_IF_FORMALITIES_MET.
Primary action / suitability track the risk:
LOW_IF_FORMALITIES_MET → FUND_WITH_CRUMMEY_NOTICES / SUITABLE_WITH_ADMINISTRATION;
EXCLUSION_SHORTFALL → USE_LIFETIME_EXEMPTION_FOR_SHORTFALL / BORDERLINE;
THREE_YEAR_LOOKBACK → USE_NEW_POLICY_OR_ACCEPT_LOOKBACK / BORDERLINE;
both → DISCLOSE_LOOKBACK_AND_USE_EXEMPTION / NOT_SUITABLE.
5. GRAT vs CRAT (trust_comparison)
Estate context (see §7) plus the trust candidate from /api/trust-candidates
(asset_value, expected_growth_rate, grat_term_years, grat_annuity_rate,
crat_term_years, crat_payout_rate).
Both remainders use the same shape: future value of the asset minus the simple, un-reinvested sum of the fixed annual payments (the payments are NOT compounded).
GRAT remainder = asset*(1+growth)^grat_term - asset*grat_annuity_rate*grat_termGRAT estate_tax_reduction = GRAT remainder * estate_tax_rate (0.4)crat_term = min(crat_term_years, max_crat_term_years)(policy cap, 20)CRAT charitable_remainder = asset*(1+growth)^crat_term - asset*crat_payout_rate*crat_termCRAT income_tax_deduction = CRAT remainder * charitable_deduction_rate (0.35)grat.mortality_inclusion_risk = TERM_SURVIVAL_REQUIRED(constant)
Recommendation from the household's goals (signed profile):
prefer GRAT when family_transfer_priority >= philanthropic_intent
(rank low<moderate<high), else CRAT.
- GRAT preferred →
rationale_code = CHILDREN_TRANSFER_PRIORITY,alternate_role = SECONDARY_CHARITABLE_TOOL,crat.family_transfer_fit = LOW. - CRAT preferred →
rationale_code = PHILANTHROPIC_PRIORITY,alternate_role = SECONDARY_FAMILY_TRANSFER_TOOL,family_transfer_fit = MODERATE.
Include the full estate_context block (planning_year, exemption_used,
taxable_estate, estate_tax_exposure, liquid_assets_available,
liquidity_gap_before_planning) as the gold answers do.
6. Integrated estate-liquidity action plan (estate_liquidity_action_plan)
Combine §7 estate context, §4 ILIT figures, and §5 trust figures.
estate_context: full block (§7).ilit:annual_exclusion_capacity,premium_gap,estate_inclusion_risk(= ILIT risk flag),projected_outside_estate_if_implemented(§4).trust_transfer:preferred_strategy(§5 rule), and report both the GRAT remainder/tax reduction (projected_remainder_to_heirs,estimated_estate_tax_reduction) and the CRATprojected_charitable_remainder.recommendation.risk_flag= the ILIT risk flag.- When GRAT is preferred:
primary_action = COMBINE_ILIT_AND_GRAT,sequencing = ILIT_FIRST_THEN_GRAT. When CRAT is preferred:CRAT_WITH_LIQUIDITY_REVIEW/TRUST_DECISION_FIRST. action_set(sorted alphabetically): alwaysATTORNEY_DRAFT_REVIEWandILIT_CRUMMEY_NOTICE_CYCLE; addGRAT_FOR_APPRECIATING_SHARES(GRAT preferred) orCRAT_FOR_CHARITABLE_REMAINDER(CRAT preferred); addLIFETIME_EXEMPTION_ALLOCATIONwhenpremium_gap > 0.source_resolution:controlling_goal_source = SIGNED_PROFILE,controlling_policy_source = SIGNED_PROFILE.
7. Estate-tax context (shared §5/§6)
exemption_used = estate_tax_exemption[planning_year] * (2 if married else 1)(marital status from the signed profile; the marital exemption doubles).taxable_estate = max(0, estate_value - exemption_used)estate_tax_exposure = taxable_estate * estate_tax_rate (0.4)liquidity_gap_before_planning = max(0, estate_tax_exposure - liquid_assets)- For trusts, take
estate_valuefrom the attorney memo first (§1); for the estate plan the signed-profile value governs (and usually agrees).
8. Pitfalls
- Do not pull tax/exemption/gift/RMD constants from memory — only the API. Years matter (2025 vs 2026 exemption and gift exclusion differ).
- Keep the Roth per-year order convert → RMD → grow; a single misordered step throws off the horizon balances and every tax total.
- Roth scenarios overlap: conversions and RMDs can happen in the same year (e.g. a 72-year-old converting while RMDs start at 73 one year later) — simulate both cleanly, no special-casing.
- Beneficiary count almost always differs between CRM and signed profile — the signed profile wins (this is the usual source of a wrong exclusion capacity).
- CRAT term is capped at
max_crat_term_years; trust payments are simple, not reinvested — don't compound them. - Married estate exemption is doubled; a single client is not.
action_setmust be alphabetically sorted, andtask_idmust match what the harness expects.- Emit only the JSON object — no surrounding prose or code fences.