How Vesper works
Updated for revision vesper-web-00022. Everything below matches exactly what the current deployed product does. When product decisions change, this page changes.
Who Vesper is for — the muni thesis in plain terms
Municipal bonds used to be perceived as a product for top-bracket investors only — useful if you were paying 35–37% federal tax, irrelevant otherwise. That perception is out of date. With current muni yields and the rate environment, the break-even is meaningfully lower than most advisors and clients think.
Anyone in the 22% federal bracket or higher typically comes out ahead on an after-tax basis with a muni ladder versus comparable taxable bonds. The math:
Client in the 22% federal bracket. Muni ladder yields 3.50% YTW (reasonable current muni rate). TEY = 3.50% / (1 − 0.22) = 4.49% A comparable-duration Treasury at 4.00%? Muni wins by 49bps after tax. Move up to the 24% bracket: TEY = 3.50% / (1 − 0.24) = 4.61% → muni wins by 61bps. Add a 6% state tax (in-state muni) for the 24% federal client: TEY = 3.50% / (1 − (0.24 + 0.06 − 0.24 × 0.06)) = 4.92% Muni wins by 92bps — nearly a full percentage point of take-home yield.
So when you're pitching Vesper to clients, "it's not just for the Rockefellers" is a fair framing. Anyone with enough of an income to hit the 22%+ bracket and enough taxable-account savings to warrant a ladder is a plausible candidate. That's a much larger total addressable market than the traditional <37%-bracket narrative implies.
What Vesper does
Vesper automates municipal bond ladder construction for registered investment advisors (RIAs). A muni ladder is a portfolio of municipal bonds with staggered maturities — typically one bond per year out to some horizon — so the client gets a predictable stream of interest payments and principal repayments. Muni interest is exempt from federal income tax (and often state tax too), which is why high-bracket clients use them.
Building one manually: an advisor screens thousands of available bonds against the client's tax bracket, state, risk tolerance, and horizon, picks one bond per rung, checks for concentration in individual issuers or sectors, and writes up the rationale. It's typically 3–6 weeks of work.
Vesper compresses that to minutes. The advisor enters the client's profile once, sets a few constraints for each ladder, and a constraint-based solver (Google OR-Tools CP-SAT) picks bonds that maximize after-tax yield while respecting every constraint.
The math that matters: Tax-Equivalent Yield (TEY)
Munis pay lower headline yields than taxable bonds, but the interest is tax-free. TEY is the apples-to-apples comparison — the yield a taxable bond would have to pay to deliver the same after-tax income.
TEY = muni_yield / (1 − marginal_tax_rate) Example — 37% federal bracket, 3.00% muni: TEY = 0.03 / (1 − 0.37) = 4.76% A 4.50% corporate bond pays less take-home than this 3% muni.
For a client who lives in the state a muni is issued from, interest is also exempt from state tax. Vesper combines federal + state rates (with the federal deduction for state taxes approximated):
TEY_in_state = muni_yield / (1 − (fed + state − fed × state))
Every bond in a ladder's result page shows both its YTW (pre-tax) and TEY (what that YTW translates to after the client's specific taxes).
Adding a client
A client record is the anchor for every ladder you'll build for that person. The tax profile is the load-bearing part; everything else is metadata.
Separately, rates ≥32% trigger the AMT filter in the optimizer: AMT-subject (private-activity) munis are excluded because clients at 32%+ are materially more likely to be subject to the Alternative Minimum Tax, where that interest becomes taxable. Nothing about being below 32% means munis don't work; it just means the AMT filter isn't applied.
- Conservative — AA min rating, 5-year horizon, 5 years call protection, 20% max per issuer, 30% max per sector. Capital preservation; typical for retirees drawing income.
- Moderate — A min rating, 10-year horizon, 3 years call protection, 20% per issuer, 30% per sector. Balanced; typical RIA default.
- Aggressive — BBB min rating (lowest tier of investment grade), 20-year horizon, 0 years call protection, 20% per issuer, 30% per sector. Yield-seeking but still IG. Appropriate for high-bracket accumulators comfortable at the edge of investment grade.
The ladder form itself has a fourth option — High Yield — that isn't a client tier because it's meant to be an explicit per-ladder choice for satellite allocations, not a default posture. See the ladder section below.
The advisor can override any default on a per-ladder basis.The client workspace
Client list at /dashboard/clients shows every client you've added: Name (clickable), State, Fed/State tax rates, Investment amount, Risk tolerance, Email. Row hover highlights for scanability. Adding a new client uses the form at the top of the same page.
Client detail page at /dashboard/clients/[id] — click any client name to land here. Four cards show everything about that client: Tax profile (federal, state, combined-effective rate, filing status, AMT filter status), Investment profile (default amount, risk tolerance), Contact (email, phone), Notes. Below the cards is that client's ladder history — every ladder ever built for them with status, total invested, avg YTW / TEY, and links into each result page. A prominent "Build ladder for [First Name]" CTA pre-selects this client on the new-ladder form.
Building a ladder
The form is split into two numbered boxes to keep the workflow unambiguous:
- 1. Pick a client. Just the client dropdown. Nothing pre-selected — forces an explicit choice so advisors managing many clients don't accidentally build a ladder for the wrong person by clicking "New Ladder" from a nav link. If you arrived via a "Build ladder for X" button on a client detail page or a failed-ladder retry, the client is pre-selected from the URL.
- 2. Constraints for [Client] — appears only after step 1. The heading shows who the ladder is for. All constraint fields default from the client's tier (with risk-profile dropdown at the top to switch to any of the four presets). The Generate button lives here; no button exists until you've picked a client.
Defaults come from the client's risk tolerance but every field is overridable.
- Conservative / Moderate / Aggressive — same as the three client tiers described above, all investment grade.
- High Yield — BB+ minimum rating (crosses below investment grade into the high-yield muni space), 15-year horizon, 0 years call protection, and tighter concentration caps: 10% per issuer (vs. 20% default) and 20% per sector (vs. 30% default). The tightened concentration compensates for the higher default risk on any individual HY bond by pushing the solver toward more issuers and more sectors. Appropriate for satellite yield allocations — not meant to be a whole-portfolio posture.
Handling thin HY inventory — the three-part answer
High-yield muni inventory is famously thin. For a given state, in a given sector, in a given maturity year, there are often only a handful of bonds available — sometimes zero. Any ladder tool that can't handle this fails constantly on the HY use case. Vesper handles it with three tools that compose together:
- The High Yield risk profile doesn't just drop the rating floor to BB+. It also tightens per-issuer concentration to 10% and per-sector to 20%, forcing the solver toward more issuers and more sectors. This compensates for the higher default risk on any individual HY pick.
- Rung tolerance (0-3 years) lets a bond maturing in, say, 2028 fill the 2027 or 2029 rung when no 2028 bond is available at the required rating. Ladder shape preserved (one bond per rung, no double-counting) but the candidate pool per rung is much wider. For the HY use case, tolerance of 1 or 2 is typical.
- Partial ladders with gap reporting are the fallback. When some rung still has zero candidates even after tolerance and profile relaxation, the solver fills the rungs it can and returns the gaps explicitly. The result page shows which rungs are unfilled and offers concrete next steps — widen tolerance further, lower rating, reduce in-state minimum, etc. — instead of a blanket "infeasible" error.
In practice: if you're building a 10-year California HY ladder and three rungs come up short, you'll see a 7-rung ladder with an amber banner listing the unfilled years and the relaxations that would likely close the gaps. That's a useful tool even when the universe is sparse, instead of a binary fail.
How the optimizer actually picks bonds
Under the hood: Google OR-Tools CP-SAT, a constraint-programming solver that finds the optimal combination of decision variables subject to every constraint you give it. For Vesper the variables are (1) which bonds to select and (2) how much par to allocate to each.
1. Hard filters (prefilter)
Before the solver even runs, the universe is filtered. Bonds are dropped if they fail any of:
- Rating below the minimum-rating floor
- Maturity outside the [today, today + horizon] window
- Callable with a call date before the min-call-protection cutoff (when callables are included)
- Sector in the excluded-sectors list
- CUSIP in the excluded-CUSIPs list
- AMT-subject, when the client's federal rate is ≥ 32% (configured via
AMT_FEDERAL_RATE_THRESHOLDinoptimizer/constants.py)
2. Scoring → top 200 candidates
Surviving bonds are scored 0–100 and the top 200 move on to the solver. The weights are defined in constants.py and shown on every ladder's result page:
- Yield-to-worst — 40%
- Credit rating — 25%
- In-state / preferred-state — 20%
- Liquidity score — 10%
- Preferred sector — 5%
3. Solver constraints
- Total par allocated = investment amount (exact equality)
- At least one bond per rung year
- Equal-weight rung totals within ±20% of target (when enabled)
- Per-issuer total ≤ max-concentration-per-issuer × investment
- Per-sector total ≤ max-concentration-per-sector × investment
- In-state total ≥ in-state-min-pct × investment (when enabled)
- If a bond is selected, its allocation ≥ max(its min_denomination, $25,000). The $25k floor is a portfolio-level minimum — prevents $5k "filler" positions that an advisor would never actually buy because the transaction costs destroy the economics
4. Objective function
maximize( Σ par_i × YTW_i + unique_issuers_selected × (investment / 100) )
Plain English: pick the combination that gets the most yield-weighted return, with a small bonus for including more distinct issuers. The bonus size is roughly "each unique issuer is worth ~$5,000 of yield on a $500k ladder" — calibrated so diversification nudges the solver without dominating the yield objective.
5. Possible outcomes
- optimal — CP-SAT proved no better solution exists under the constraints
- feasible — a working solution was found but the 30-second timeout hit before proving optimality. In practice these are nearly always optimal or very close; the solver just didn't have time to verify
- partial ladder — some rungs had no candidates in their tolerance window even at the advisor's chosen settings, so the solver filled the rungs it could and returned a ladder with gaps. The result page shows which rungs went unfilled and suggests specific constraint relaxations (widen tolerance, lower rating, reduce in-state minimum). Better signal than a blanket failure
- infeasible — no rung had any candidates at all, meaning the universe couldn't produce even a partial ladder. The result page tells the advisor to relax constraints and try again
When a ladder fails: diagnostics + one-click retry
An infeasible combination of constraints returns a FAILED ladder instead of silently producing nothing. The FAILED result page is designed to tell the advisor exactly what was asked for and why it didn't work — never a generic "try relaxing constraints" message.
- Left column — "What this ladder asked for." Every constraint that actually went into the solver: client identity including federal bracket, horizon, rating floor, concentration caps, rung tolerance, in-state minimum, AMT filter status. Advisor sees the full setup at a glance.
- Right column — "Most likely why it failed." An ordered list of diagnoses specific to this ladder's settings. Examples: "60% in-state minimum on a sparse state muni universe is a hard constraint. Try reducing to 30-40%." / "Rung tolerance is 0 — with 20 rungs, each year needs an exact-maturity bond. Set tolerance to 1 or 2." / "20-year horizon needs a bond in every year." Not boilerplate — the diagnoses are triggered by the actual values that were set.
- "Retry with relaxations" — primary CTA at the bottom. Opens the new-ladder form with the suggested loosened values already pre-filled (e.g., in-state dropped to 30%, tolerance bumped to 2, horizon shortened). Secondary "Build from scratch" button starts fresh for the same client without relaxations. Third button returns to the client detail page.
What you see on every ladder result page
Every completed ladder (or partial ladder) renders four sections, in this order:
- Metric cards — Invested, Avg YTW, Avg TEY, Annual income. At-a-glance portfolio summary.
- Partial-ladder banner (amber, only shown if there are gaps) — lists the rung years that couldn't be filled, notes the rung tolerance that was in effect, and offers three specific relaxations (widen tolerance, lower rating, reduce in-state minimum).
- Taxable-equivalent break-even banner (indigo) — a plain-language statement of "a comparable taxable bond would need to yield X.XX% to deliver the same after-tax income as this ladder for [client]." Also shows the uplift over pre-tax yield (TEY − YTW). Designed to be the slide an advisor hands a client to justify the ladder.
- "How this was optimized" panel — everything the solver actually did, exposed: the objective function in plain English, the candidate scoring weights that determined which 200 bonds reached the solver, the hard filters applied (with AMT flagged explicitly when the client's federal rate triggered it), and the hard constraints with actual per-ladder values. Nothing about the optimization is hidden.
- Bonds grouped by rung year — each rung with its total $, then a table of the bonds in it (CUSIP, issuer, sector, rating, coupon, YTW, TEY, par).
What's not in this version yet
Honest disclosure so expectations match reality:
- Synthetic bond universe. The 500 bonds in Vesper today are generated with realistic distributions but are not real CUSIPs. Real EMMA / paid-API ingestion is on the roadmap, but production data isn't live yet.
- No PDF export. @react-pdf is installed but no templates are wired. Advisors can't yet hand a client a polished ladder report.
- Create-only CRUD. Clients and ladders can be created but not edited or deleted. List-only review for now.
- No quota/billing enforcement. Plan tiers exist in the schema (Starter / Professional / Enterprise with 3/8/15 ladders per month) but nothing enforces them.
- No audit log writes. Schema for audit entries exists, nothing emits them yet.
- Test-user only auth. The Google OAuth app is in Testing status (capped at 100 whitelisted emails). Publishing requires Google verification, a 1–2 week review.
Where to find the numbers
All "magic numbers" that shape ladder results live in optimizer/constants.py in the repo at rk-stacks/vesper. That file is the single place to adjust AMT thresholds, scoring weights, solver timeouts, and default in-state percentages.
The tax-year bucket for the AMT threshold (currently 0.32 for 2024/2025 brackets) should be reviewed annually as brackets shift.