Skip to main content
Glama

asset-management

gate

TL;DR: uvx --from git+https://github.com/disin7c9/asset-management asset-management --demo β€” a drawdown-first portfolio brief on a bundled example book, one command, no setup. USD-only, long-only stock/ETF; you keep your own transaction log.

Track a personal stock/ETF portfolio and get suggestions you can audit. Python computes every number; the optional AI narrates β€” and is structurally unable to write a figure of its own.

πŸ”’ The number fence

Many AI finance tools let the model produce the numbers. Here the model may only place {{token}} placeholders: a deterministic renderer substitutes figures from the validated core and refuses the entire narration if the model typed even one digit itself:

 the model wrote                             β”‚  the reader gets
─────────────────────────────────────────────┼─────────────────────────────────────────────
 "Your deepest stretch fell                  β”‚  "Your deepest stretch fell -9.84% from
  {{max_drawdown}} from its peak, and an     β”‚   its peak, and an ulcer index of 2.38%
  ulcer index of {{ulcer}} says the ride     β”‚   says the ride stayed shallow."
  stayed shallow."                           β”‚  βœ” every figure substituted from the
                                             β”‚    validated core
─────────────────────────────────────────────┼─────────────────────────────────────────────
 "Your portfolio fell 12% but recovered      β”‚  (no summary at all)
  nicely β€” don't worry."                     β”‚  ✘ REFUSED β€” one model-typed digit voids
                                             β”‚    the entire note; the brief prints
                                             β”‚    without it

Run both sides yourself β€” it drives the real production fence, no API key needed: uv run python scripts/demo_fence.py

Related MCP server: plaid-mcp

🎁 What you get

  • A drawdown-first brief of your real holdings β€” how far you fell from your peak, how long underwater, what came back β€” with a bootstrap confidence band on every sampled risk statistic (returns are accounting identities, so they honestly carry none).

  • Deterministic buy/sell suggestions toward a target you choose, each line paired to the named rule that produced it β€” you learn the rule rather than trust a bot.

  • Optional, fenced AI narration, and a read-only Claude Desktop addon ("chat with your portfolio") over the same validated core.

Holdings are derived from an append-only transaction log (date, ticker, action, quantity, price, fee per row) β€” never stored β€” so the same input always produces the same output. Prices are fetched with a provider fallback (Yahoo Finance primary; Tiingo secondary, via a free API key) and an on-disk cache; every displayed number is traceable to its source, and figures that can't be computed honestly (too short a window, no real solution) print n/a rather than a fabricated number. Stock splits are adjusted automatically (share counts are reconciled with the split-adjusted price history), so a split during your holding period doesn't distort the returns.

πŸ“‘ Contents

πŸš€ Start here

⚑ Try it in 60 seconds (bundled fake portfolio, no setup)

uvx --from git+https://github.com/disin7c9/asset-management asset-management --demo

or from a clone: uv sync && uv run python -m app --demo (needs Python 3.12 and uv).

The full tour (still the bundled book, ~a minute online) β€” the two commands below show the tool's characteristic features end to end: a preset target is proposed, then validated against a known 60-40 reference with a walk-forward held-out verdict, and threshold-band rebalance suggestions are laid out with the named rule behind every line, plus per-fund facts:

uvx --from git+https://github.com/disin7c9/asset-management asset-management --demo --allocate moderate --allocate-out demo_target.csv
uvx --from git+https://github.com/disin7c9/asset-management asset-management --demo --backtest --target demo_target.csv --benchmark 60-40 --rebalance bands --metadata

Note what it doesn't say: the verdict reads like "no clear drawdown difference from 60-40; the paired bootstrap does not confirm the gap" when the evidence is thin β€” never "beats the benchmark". When the output earns your trust, the four steps below point it at your own money.

πŸ’΅ Use it with your own money β€” four steps

Step 1 β€” your book (the input)

Everything is derived from one transaction log. The CSV format is Ghostfolio's own CSV-import schema (so a book you keep here imports straight into Ghostfolio too) β€” columns Date, Code, DataSource, Currency, Price, Quantity, Action, Fee, Note. Action is one of buy, sell, dividend, fee, interest, deposit, withdraw. Cash flows (deposit/withdraw) use a CASH code and put the amount in the Price column. This tool is USD-only (long-only stock/ETF): Currency must be USD β€” a non-USD row is refused with an error naming the row, never silently booked as dollars 1:1. Empty cells in numeric columns are treated as zero. Non-ISO dates are rejected with a clear error. UTF-8 BOM is tolerated. The bundled example (data/sample_data/transactions.csv) shows every row type.

Already use Ghostfolio? Point the input straight at a Ghostfolio JSON export (Portfolio β†’ Activities β†’ β‹― β†’ Export) β€” the loader detects it and reads it directly, no conversion step. It reads the activities (a dividend's cash = quantity Γ— unitPrice; Ghostfolio's UTC timestamps are rounded back to your local date) and skips non-USD, crypto, and non-security (ITEM/LIABILITY) rows with a warning (USD-only, long-only equity/ETF for now). Brokers without a native Ghostfolio account can run a community converter such as Export-To-Ghostfolio (26 brokers β†’ a Ghostfolio JSON) first.

uv run python -m app --book your.csv                     # or ghostfolio-export.json β€” auto-detected
uv run python -m app --book your.csv --dry-run           # preview an import BEFORE trusting it:
                                                         # format, events, skipped rows with reasons,
                                                         # derived holdings β€” fetches nothing

Set your default once in a gitignored .env at the repo root β€” ASSET_BOOK=path/to/your.csv (and optionally ASSET_TARGET=path/to/target.csv) β€” and a bare python -m app becomes your brief. Explicit flags always win. There is no silent built-in default: without --book or ASSET_BOOK, a book-dependent action errors out and a bare run prints a hint β€” the bundled example is opt-in (--demo), never assumed.

Step 2 β€” warm the cache (once)

The core is offline-first: prices come from an on-disk cache, refreshed when you run online. After a fresh clone, fill it once:

uv run python -m app --book your.csv --warm        # your tickers + the benchmark references
uv run python -m app --book your.csv --warm full   # + the ~375-ETF discovery universe (slow) β€”
                                                   # only needed for offline --discover

After that, --offline runs and the Claude Desktop addon serve entirely from this cache (--cache-dir / ASSET_CACHE_DIR override the location).

Optional but recommended: add a free Tiingo API key to .env β€” TIINGO_API_KEY=... β€” to enable the second price source. Yahoo Finance throttles bursts of requests now and then; with a key the fetch falls back to Tiingo instead of reporting tickers missing. Without one, the tool fetches from Yahoo only.

Step 3 β€” choose a target

Most of the decision features (--rebalance, --backtest, the walk-forward checks) work toward a target allocation β€” a small CSV (Ticker,Weight) that you own and edit. Three ways to get one, by where you're starting from:

  • You already hold a portfolio β†’ --dump-target target.csv writes your current allocation; edit it toward the mix you want. (A target is a complete spec β€” see Rebalance modes for the exit semantics.)

  • Start from a risk posture β†’ --onboard asks three plain questions in the terminal (horizon / loss response / cash buffer) and builds the matched preset; or pick it yourself with --allocate conservative|moderate|aggressive. Save with --allocate-out target.csv. Both run with no book at all β€” before your first trade, every role fills from the curated universe. These are strategic role-bucket templates (stocks / bonds / diversifiers split by posture, core-satellite within each bucket): a role you already hold resolves to your largest fund in it, a role you're missing is filled with a sensible default ETF from the curated universe.

  • Explore and build it yourself β†’ --discover suggests screened ETFs for the roles you're light in; --screen QQQM,SCHD judges any candidate against your book (cost, liquidity, age, overlap, and whether it actually diversified your worst drawdowns). Then write the CSV by hand.

Two simpler re-weighting rules also exist β€” --allocate equal_weight (1/N; the robust baseline) and --allocate inverse_vol (each holding contributes roughly the same risk rather than the same dollars; cap any single weight with --allocate-cap 0.30). Deliberately not included: return-forecasting optimizers (mean-variance / max-Sharpe) β€” they overfit, so they stay behind an edge gate for a later version.

Validate the target before following it:

uv run python -m app --backtest --target target.csv --benchmark 60-40   # or all-weather | permanent

simulates your target and the reference over their common history (notional $10k, drawdown-first legs with CIs) and adds a walk-forward held-out verdict: shallower / deeper / inconclusive / insufficient β€” never "beats". The verdict is judged on the Ulcer index (whole-window drawdown pain β€” how deep and how long), with CDaR (the worst-tail average) required not to contradict it and a paired-bootstrap confidence interval to confirm; max drawdown is reported as context, not the decider β€” one worst event is too noisy to decide anything on a short history. When it can't call it, it says inconclusive and names exactly which gate blocked it.

Propose, simulate, and act are separate steps, enforced: --allocate/--onboard only propose (and optionally write the file) and may not be combined with --backtest/--rebalance in one command. You review the file, then simulate it, then ask for orders β€” each in its own command. A strategy never silently becomes trades.

Step 4 β€” the weekly brief

One command covers the routine check-in β€” the status brief, suggested actions when a band is breached, the backtest + benchmark verdict, and fund facts:

uv run python -m app --book your.csv --backtest --target target.csv --benchmark permanent --rebalance bands --metadata        # + --narrate --save --send to taste
  • Pick the --benchmark reference nearest your posture (60-40 / all-weather / permanent).

  • bands acts only when a holding drifts past its band (the "5/25 rule") and ignores --new-cash β€” on a week you're depositing, swap in --rebalance cash_flow_only --new-cash 500 (invest into underweights, never sell; tax-friendly).

  • Panels are composable β€” every flag adds a panel and they stack (see the Reference).

The same report leaves three ways from one build: plain text on stdout (always), markdown to reports/<asof>.md (--save), and an HTML email (--send; RESEND_API_KEY + REPORT_TO in .env). A failed sink never crashes the run β€” the brief still prints and the failure is logged β€” but if a sink you requested fails, the process exits non-zero so a scheduler notices. A weekly Monday brief is just this on cron:

# 08:00 every Monday β€” email the brief and archive the markdown.
# cron's PATH is minimal and won't find uv β€” point PATH at it (see `which uv`):
PATH=/home/you/.local/bin:/usr/bin:/bin
0 8 * * 1  cd /path/to/asset-management && uv run python -m app --book your.csv --backtest --target target.csv --benchmark permanent --rebalance bands --metadata --save --send >> reports/cron.log 2>&1

(cron only fires while the machine is on at that moment β€” on WSL, Windows Task Scheduler running wsl.exe is the always-fires alternative.) Every key the tool reads is listed in Configuration.

On Windows without WSL, everything above works unchanged (uv brings its own Python β€” install it with irm https://astral.sh/uv/install.ps1 | iex, clone, write .env, run). Only the scheduler differs:

# the Windows counterpart of the cron line above, from your clone.
# -StartWhenAvailable: a Monday the machine was asleep runs on wake instead of skipping.
Register-ScheduledTask -TaskName "asset-brief" `
  -Trigger (New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 8:00am) `
  -Settings (New-ScheduledTaskSettingsSet -StartWhenAvailable) `
  -Action (New-ScheduledTaskAction -Execute "$env:USERPROFILE\.local\bin\uv.exe" `
    -Argument "run python -m app --book your.csv --backtest --target target.csv --benchmark permanent --rebalance bands --metadata --save --send" `
    -WorkingDirectory "C:\path\to\asset-management")

πŸ” Why you can trust the numbers

🚫 Not financial advice β€” structurally, not as fine print

The shape of the output is what makes this a description rather than advice:

  • every suggestion is paired to the named rule that fired ("5/25 band breached β€” trim X"), never a bare "buy X";

  • every sampled risk metric carries a confidence interval, and too-little-data says so (inconclusive, insufficient, n/a) instead of pretending;

  • benchmark verdicts only say shallower / deeper / inconclusive / insufficient β€” the vocabulary has no "beats";

  • anything claiming an edge must pass a walk-forward (out-of-sample) gate before it may surface a suggestion β€” in-sample-only numbers are refused by design;

  • the tool is read-only: it never trades, and the AI can never write to your ledger.

βœ… Correctness is a claim you can check

  • the gate runs the full test suite + mypy --strict + ruff on every push to main and every PR β€” the badge at the top is that gate, live;

  • holdings, market value, and P&L are reconciled to the cent against Ghostfolio, and Sharpe/Sortino/drawdown to 4 decimals against quantstats β†’ reconcile/RESULTS.md;

  • every formula is written down in MATH.md;

  • the number fence is a script you can poke yourself: scripts/demo_fence.py.

πŸ”¬ Validation β€” reconciled against two independent tools

The numbers are cross-checked against two independent tools on the bundled example data (harness in reconcile/):

  • ghostfolio reconstructs holdings, market value, and P&L from the same transaction log and matches to the cent.

  • quantstats independently computes Sharpe, Sortino, and max drawdown from the return series, matching to 4 decimals.

Every formula the tool computes β€” returns, the drawdown family (Ulcer / CDaR), risk-adjusted ratios, bootstrap confidence bands, and the allocation/screening math β€” is defined in one place: MATH.md.

Together they validate the whole pipeline: ghostfolio confirms the holdings/value reconstruction; quantstats confirms the risk/return formulas. Full comparison in reconcile/RESULTS.md.

uv run --with quantstats python reconcile/reconcile_quantstats.py   # metric cross-check
# end-to-end (ghostfolio): see reconcile/RESULTS.md

πŸ’¬ Chat with your portfolio β€” the Claude Desktop addon (read-only MCP)

Expose your portfolio to an AI assistant (Claude Desktop, Claude Code, …) as read-only tools it can call β€” so you can "chat with your portfolio" while every number still comes from the validated core, not the model. The server is read-only β€” no write tools, bound to your ASSET_BOOK book (no file-path args) β€” and offline except two bounded, opt-out fetches: a cold cache auto-warms the core set once (your tickers + benchmark refs, ~30–60s), and screening/proposing a ticker that isn't cached fetches it on demand. Set ASSET_MCP_OFFLINE=1 to disable both (for an already-warmed cache; pointed at a cold one it just degrades to honest n/a). Eight tools:

  • portfolio_summary β€” holdings, P&L, and annualized returns.

  • risk_report β€” drawdown-first risk: max drawdown (depth/dates/recovery), Ulcer, CDaR, Sharpe/Sortino/Calmar, all with bootstrap confidence intervals.

  • rebalance_check β€” buy/sell/hold suggestions toward your ASSET_TARGET (it suggests, never trades; refuses to size over a partially-cached book).

  • securities_facts β€” published fund facts per holding (expense ratio, AUM, volume, age, category).

  • discover_gaps β€” suggest NEW ETFs for the roles you hold ≀3% of (propose-only; needs --warm full).

  • screen_candidate β€” judge a NEW candidate ticker against your book (diversifier/cost/liquidity/age/overlap, each with a reason).

  • propose_allocation β€” a strategic target for a posture (conservative/moderate/aggressive) over your book + the universe, validated against a reference with the same walk-forward, Ulcer-first held-out verdict β€” propose-only, numbers from the core, never a recommendation.

  • starter_allocation β€” new to this? answer three plain risk questions β†’ a starting posture and its validated proposal (the onboarding path into propose_allocation).

Install (Claude Desktop): Settings β†’ Developer β†’ Edit Config, and add:

{
  "mcpServers": {
    "asset-management": {
      "command": "uvx",
      "args": ["--from",
               "https://github.com/disin7c9/asset-management/releases/download/v2.11.4/asset_management-2.11.4-py3-none-any.whl",
               "asset-management-mcp"],
      "env": {
        "ASSET_BOOK": "C:\\path\\to\\your\\transactions.csv",
        "ASSET_TARGET": "C:\\path\\to\\your\\target.csv",
        "TIINGO_API_KEY": "your-free-tiingo-key(optional_secondary_source)"
      }
    }
  }
}

Restart Claude Desktop. You need uv on your PATH and nothing else β€” uvx resolves Python and the locked dependencies itself. The first launch is slow (it builds a ~500 MB environment: pandas / NumPy / PyArrow), so give it a minute; every launch after starts in seconds. The first tool call then warms the price cache once (~30–60s), and that cache lives in your home folder, so it survives reinstalls. Works on the free plan. The URL pins a release β€” nothing changes under you between launches; to upgrade, swap both version numbers for the newest release.

Every env entry is optional β€” delete what you don't use. Drop ASSET_BOOK to explore the bundled demo portfolio on fake data first. ASSET_TARGET is what rebalance_check compares your holdings against (without it, that one tool errors with a hint). TIINGO_API_KEY (free account) adds the second price source for when Yahoo throttles. Rarer: ASSET_CACHE_DIR (move the price cache), ASSET_MCP_OFFLINE=1 (never fetch β€” for an already-warmed cache). No LLM key goes here: the assistant reading these tools is the narrator; the server itself never calls a model.

The .mcpb bundle β€” Claude Code only, for now. Build it with uv run python scripts/build_mcpb.py β†’ dist/asset-management-<version>.mcpb, and install it in one click via Settings β†’ Extensions. Its tools work in Claude Code. They do not work in Claude Desktop's chat window: Desktop does not offer tools from sideloaded extensions to the model β€” they appear in the tool menu, keep their permission toggles, and are simply never called. Directory-installed extensions and config-registered servers both work, which is why the config route above is the one to use for chat.

In chat: open the + menu for ready-made starters β€” Portfolio checkup, What's my drawdown?, Should I rebalance?, Fill my gaps, Find my starting allocation, Propose a posture β€” each one pre-loads the figures-only framing. The server also publishes portfolio://guarantees (its four enforced guarantees, versioned, shipped with the code): attach it from the same + menu β€” or, in clients that let the model read resources itself, just ask "can I trust these numbers?" and it answers from the manifest instead of improvising.

πŸ›Ÿ If something goes wrong

  • Claude says it can only see one tool, or none. Go to Settings β†’ Connectors β†’ asset-management and switch the tools on. A newly registered server arrives with its tools blocked, and approving one at a permission prompt enables only that one. Claude then honestly reports the short list it was handed β€” it has no way to know the rest exist, so it will tell you the server "only has one tool." It has eight.

  • The tools appear in the menu, but Claude never calls them. You installed the .mcpb bundle. Claude Desktop's chat window does not offer tools from sideloaded extensions to the model β€” the menu and the permission toggles are drawn from the extension itself, so everything looks connected while the model is never told the tools exist. Remove the extension and use the config route above.

  • The server never appears at all. Claude Desktop resolves "command" on your PATH and can't find uvx. Install uv and restart Desktop, or put the absolute path in "command" (Windows: C:\Users\<you>\.local\bin\uvx.exe).

  • It sits disconnected on the very first launch. That launch is building the environment (~500 MB). Give it a minute, then restart Claude Desktop. Later launches take seconds.

  • The first launch dies with os error 32 ("another process is using the file"). Windows: while uv builds the environment, another process β€” usually the antivirus scanner β€” briefly holds a freshly written file in uv's cache, and the install loses the race. Run the uvx command from the config once in a terminal yourself (if it trips again, just rerun it β€” a retry costs nothing there). When it sits silent, the environment is built: Ctrl-C, restart Desktop. Every launch after reuses the built environment and never races.

  • Every figure comes back n/a. The price cache is cold and ASSET_MCP_OFFLINE=1 is forbidding the fetch that would warm it. Drop that variable, or warm the cache once from the CLI: uvx --from git+https://github.com/disin7c9/asset-management asset-management --book your.csv --warm.

  • (.mcpb only) No MCP config found for extension … skipping. Desktop won't launch the server until the Configure form is saved β€” and Save stays disabled until you change something. Toggle any field, save, restart.

Or run the server directly / register it with Claude Code:

uv run python -m app.mcp_server                                    # serve over stdio (set ASSET_BOOK in .env)
asset-management-mcp                                              # the same server as an installed entry point
claude mcp add asset-management -- uv run python -m app.mcp_server  # register, then /mcp to use it

The server runs no LLM itself β€” an assistant calls it; this is not financial advice.

πŸ“– Reference

The report is composable panels, not exclusive modes β€” combine flags and the panels stack. What each action needs:

action

needs

what it does

status brief (default)

--book

your holdings + returns + drawdown/risk

--rebalance MODE

--book + --target

buy/sell suggestions toward the target (--new-cash sizes a deposit)

--allocate RULE

--book

propose a target β€” re-weight your holdings (equal_weight/inverse_vol) or build a strategic role template (conservative/moderate/aggressive); write it with --allocate-out

--onboard

--book (or --demo)

step 0 for a new user: answer 3 plain risk questions in the terminal β†’ the matched posture builds its --allocate preset automatically (propose-only; save with --allocate-out)

--dry-run

--book (or --demo)

preview an import before trusting it: detected format, events parsed, rows skipped/flagged with reasons, and the holdings they derive to β€” fetches nothing, computes no brief

--metadata

--book

published fund facts per holding (expense ratio, AUM, volume, age, category), cached 7 days

--screen TICKERS

--book + prices

judge NEW candidates vs your book: diversifier (incl. your red days + worst drawdown), cost, liquidity, age, concentration, leveraged/inverse auto-reject, holdings-overlap dedup β€” each verdict with its reason. Add --target for the walk-forward role check: did a 5% sleeve reduce drawdown pain (Ulcer, with a CDaR check) on a held-out window? "Inconclusive" names the gate that blocked it. Propose-only; a PASS is "sane, cheap, liquid, genuinely different", never a prediction

--discover [roles]

--book + prices

suggest new ETFs for the roles you hold ≀3% of, run through the same screen β€” propose-only (see Discovery)

--backtest

--target

notional rebalance-vs-buy-and-hold β€” no --book; prints the simulation alone

--backtest --benchmark REF

--target

validate a target vs a canonical reference (60-40 / all-weather / permanent) β€” drawdown-first legs + the walk-forward, Ulcer-first held-out verdict

--narrate

--book + an LLM key

a plain-language SUMMARY at the top of the brief; the model writes only the words, every number is substituted and verified from the core (opt-in, off by default β€” see Narration)

All flags, grouped:

# input & cache
uv run python -m app --demo                        # zero-setup test drive on the bundled example book
uv run python -m app --book your.csv               # your book (CSV or Ghostfolio JSON, auto-detected)
uv run python -m app --book your.csv --dry-run     # preview an import β€” fetches nothing
uv run python -m app --book your.csv --warm        # ONE-TIME: fill the offline cache (add `full` for the universe)
uv run python -m app --book your.csv --offline     # serve from the on-disk cache; no network
uv run python -m app --book your.csv --no-prices   # holdings + realized P&L only (no network)
uv run python -m app --book your.csv --cache-dir /some/dir   # override the cache location
# panels (stack freely)
uv run python -m app --book your.csv --no-risk     # skip the holdings drawdown/risk panel
uv run python -m app --book your.csv --metadata    # + SECURITIES panel (fund facts)
uv run python -m app --book your.csv --screen QQQM,SCHD            # judge NEW candidates
uv run python -m app --book your.csv --screen SCHD --target t.csv  # + the walk-forward role check
uv run python -m app --book your.csv --discover    # + DISCOVERY panel (gap-filling ETFs)
uv run python -m app --book your.csv --narrate     # + the fenced plain-language SUMMARY
# targets (propose-only)
uv run python -m app --book your.csv --dump-target target.csv                     # write your CURRENT allocation
uv run python -m app --book your.csv --allocate moderate --allocate-out t.csv     # strategic preset
uv run python -m app --book your.csv --allocate inverse_vol --allocate-out t.csv  # re-weight holdings
uv run python -m app --demo --onboard                                             # 3-question quiz β†’ a preset
# act & validate (separate commands, by design)
uv run python -m app --book your.csv --rebalance to_total --target target.csv     # suggestions toward the target
uv run python -m app --book your.csv --rebalance cash_flow_only --target target.csv --new-cash 1000
uv run python -m app --backtest --target target.csv                # notional backtest β€” target-only, no --book
uv run python -m app --backtest --target target.csv --benchmark 60-40   # + the reference comparison & verdict
uv run python -m app --book your.csv --rebalance bands --backtest --target target.csv  # panels stack
# delivery
uv run python -m app --book your.csv --save        # also write reports/<asof>.md (markdown)
uv run python -m app --book your.csv --send        # also email the brief as HTML via Resend

A target.csv is one you create with --dump-target / --allocate-out (or use data/sample_data/target.csv). --allocate is propose-only and cannot be combined with --rebalance/--backtest.

πŸ’» Example output

The shape --demo prints (drawdown leads, then risk-adjusted ratios, then returns, then holdings):

=== DRAWDOWN (investment, time-weighted) ===
Max drawdown:      -9.84%  (95% CI -16.63% .. -6.37%)
  peak 2025-02-19 β†’ trough 2025-04-08 β†’ 2025-06-10  (111 days)
Ulcer index:       2.38%  (95% CI 1.51% .. 6.26%)
CDaR (worst 5%):   6.73%  (95% CI 4.43% .. 14.01%)
You've spent 80% of this period below a previous high.

=== RISK-ADJUSTED (annualized, 252-day basis, risk-free 0%, Β± bootstrap CI) ===
Sharpe:   +1.75  (95% CI +0.68 .. +2.83)
Sortino:  +2.66  (95% CI +0.96 .. +4.63)
Calmar:   +1.97  (95% CI +0.52 .. +4.83)

=== RETURNS (annualized, 252-day basis) ===
Period: 2023-01-05 β†’ 2026-06-02 (1244 days, ~3.41y)
Time-weighted (true TWR):                +19.37%
Money-weighted (IRR):                    +18.28%
Modified Dietz (approx TWR):             +17.94%
  (point figures: accounting identities over your cash flows, not sampled
  statistics β†’ no band; see RISK-ADJUSTED for bootstrapped CIs)

=== HOLDINGS ===
ticker     shares  avg cost    price    mkt value      unreal    realized
-------------------------------------------------------------------------
BND        50.000     72.26    73.18      3659.00      +46.00      +45.10
...
Total cost basis (held): $9,649.23
Market value (priced):   $15,211.40
Unrealized P&L:          $+5,562.17
Realized P&L (sells+div): $+561.52
Fees paid (informational): $8.00
Net P&L (unrealized + realized): $+6,123.70

Prices: 4 cache  (age: 6.2h .. 6.2h old as of 2026-06-02 02:42 UTC)
Generated by asset-management. Figures are deterministic and reconciled against ghostfolio + quantstats; this is not financial advice.

Confidence bands come from a moving-block bootstrap. Drawdown is investment (time-weighted) drawdown, not account-balance drawdown. The panel also reports Gains given back β€” the largest dollar decline in your cumulative market profit (the felt "how much did I watch evaporate"); it's flow-neutral, so deposits, withdrawals, and transfers don't distort it. Each run also emits one structured JSON log line (run_summary) on stderr: {date, source, n_events_replayed, n_prices_fetched, n_prices_missing, n_series_fetched, n_series_missing, fallbacks_used, status, report_saved, email_sent, rebalance, backtest, allocate, dump_target, metadata, screen, narrate, discover, discover_narrate, benchmark_narrate, warm, onboard, dry_run} (with email_detail/error present when relevant).

πŸ” Rebalance modes

A target is a complete spec (--target path, columns Ticker,Weight; weights are relative and normalized): any held ticker not listed is treated as an exit and sold to $0. So --target is required with --rebalance (no silent default), and the run warns listing any held tickers the target omits. To close a position on purpose, give it weight 0 β€” that's an explicit, warning-free exit; omitting it does the same but triggers the safety warning (the tool can't tell "forgot" from "meant it"). Modes:

  • to_total β€” sell + buy to hit the target exactly (cash-neutral; deploys --new-cash too)

  • cash_flow_only β€” invest --new-cash into underweights; never sell (tax-friendly)

  • fixed_dca β€” buy the target mix with --new-cash, ignoring drift

  • bands β€” like to_total but only act when a ticker's drift exceeds its band; rebalances existing holdings only (ignores --new-cash). The band is the smaller of an absolute --band (default 5pp) or --band-rel Γ— the ticker's target weight (the "5/25 rule", default 25%) β€” so a small sleeve isn't handed a band many times its own size; a 0% target β†’ 0 band β†’ always exits

πŸ“ˆ Backtest details

--backtest --target T.csv runs a notional $10,000 historical simulation of that target and prints a BACKTEST panel comparing rebalanced (schedule via --rebalance-every {monthly,quarterly,annually}, default quarterly) vs buy-and-hold β€” drawdown-first (max drawdown, Ulcer, CDaR β€” each with a bootstrap CI), plus Sharpe/Sortino and returns. It's notional: it starts a clean $10k at the target weights on the earliest date all tickers have prices (--backtest-start to override), so it tests the strategy, independent of your actual buy timing. Labeled a historical simulation, not a prediction.

A fixed rebalance policy fits no parameters, so the whole history is out-of-sample-clean (nothing to overfit). The walk-forward train/test selection machinery β€” needed only once a strategy searches (tunes parameters or picks among candidates: an optimizer, or an edge timing strategy) β€” is deliberately deferred; a discipline-vs-edge gate enforces that any future edge strategy must pass a walk-forward backtest before it may surface a suggestion. Today's rebalance modes are all discipline, so they suggest freely.

With --benchmark, the held-out verdict resolves through three named gates: the Ulcer gain must clear a noise margin, CDaR (the worst-tail average) may tie but must not contradict the direction, and a paired moving-block bootstrap CI must confirm it β€” otherwise inconclusive, with the blocking gate named in the reason. Max drawdown and volatility are reported as context, voting nowhere: a single worst event is an extreme-value statistic, far too noisy on a short history to decide anything.

πŸ”­ Discovery & the curated universe

--discover maps your holdings to roles (US large, emerging markets, TIPS, REITs, …), finds the roles you hold ≀3% of, takes the biggest funds in each from a curated universe (bundled app/data/universe.csv, ~375 low-cost ETFs), and runs them through the same screen as --screen β€” printing a DISCOVERY panel, each candidate with its verdict (PASS/WARN/FAIL) and reasons. --discover reit,tips limits the roles; add --narrate for a fenced note ranking the picks by role-fit. A PASS is "sane, cheap, liquid, and genuinely different from what you hold" β€” never a prediction.

The universe is auto-built (and refreshable) β€” no hand-maintenance:

uv run python scripts/build_universe.py --auto --out app/data/universe.csv

It pulls the largest US ETFs per asset-class category from a screener (by fund size, not past returns β€” chasing performance is exactly the trap this avoids), drops leveraged/inverse, and keeps a small curated set for the few categories a screener can't isolate. Point --discover at your own list with ASSET_UNIVERSE=path/to/universe.csv in .env.

πŸ“ Narration (optional plain-language summary)

--narrate adds a short SUMMARY in plain English at the top of the brief β€” what happened to your drawdown, risk, and return, in sentences. It's opt-in and off by default, and it's built so a language model can never put a wrong number in your brief: the model writes only prose with {{placeholder}} tokens, and the tool substitutes the validated figures from its own core β€” rejecting the whole summary if the model tries to write any number itself, or names a figure that doesn't exist. The wording is the model's; every figure is the tool's, and the block is labeled with the model that produced it. The same fence narrates the discovery panel (--discover --narrate: the model ranks and explains the screened picks by role-fit, never forecasts) and the benchmark verdict (--backtest --benchmark … --narrate). It is a description, not financial advice.

You bring your own LLM key in .env β€” nothing is sent anywhere unless you turn this on:

ASSET_NARRATE_PROVIDER=anthropic        # or: openai (any OpenAI-compatible endpoint)
ASSET_NARRATE_MODEL=claude-haiku-4-5
ASSET_NARRATE_KEY=sk-...
# for provider=openai, also set the endpoint (https required; http only for localhost):
ASSET_NARRATE_BASE_URL=https://api.groq.com/openai/v1
ASSET_NARRATE_TIER=paid                  # free | paid | local β€” privacy dial (see below); default: free

Privacy dial. The tier controls what leaves your machine. On free (the default β€” for keys from providers whose free tiers may train on your inputs) only coarse qualitative bands ("moderate", "solid") are sent; your exact dollar amounts, returns, and dates stay home and are filled in locally. On paid (providers that contractually don't train on your data) the exact figures are sent for richer wording. A third tier, local, is for a model running on your own machine (Ollama / llama.cpp at http://localhost β€” also ::1 or host.docker.internal): it sends exact figures too, since nothing leaves the machine, and it's honored only against a genuine local endpoint (otherwise it falls back to free). The dial fails safe: only an explicit paid or local ever sends exact values β€” a blank or misspelled tier stays on free, and on free the tool logs a one-line reminder that the provider may train on what it is sent. If narration isn't configured, or the model call fails, the brief simply prints without the SUMMARY.

πŸ”‘ Configuration β€” every key in one place

All configuration is environment variables. Running from a checkout, set them once in a gitignored .env at the repo root; running via uvx (no checkout to read a .env from), set the same names as real environment variables β€” or in the Claude Desktop config's env block. Explicit flags always win.

# your book + target (Steps 1 & 3)
ASSET_BOOK=data/my_data/transactions.csv
ASSET_TARGET=data/my_data/target.csv

# prices β€” optional free second source for when Yahoo throttles (Step 2)
TIINGO_API_KEY=...

# email delivery for --send (Step 4)
RESEND_API_KEY=...
REPORT_TO=you@example.com
# REPORT_FROM=briefs@yourdomain.com   # optional; defaults to Resend's onboarding sender

# narration for --narrate (optional β€” your own LLM key; see Narration above)
ASSET_NARRATE_PROVIDER=anthropic      # or: openai (any OpenAI-compatible endpoint)
ASSET_NARRATE_MODEL=claude-haiku-4-5
ASSET_NARRATE_KEY=sk-...
# ASSET_NARRATE_BASE_URL=https://...  # provider=openai only
# ASSET_NARRATE_TIER=free             # free (default: only coarse bands leave) | paid (exact figures) | local

# MCP addon only
# ASSET_MCP_OFFLINE=1                 # never fetch β€” for an already-warmed cache

# rarely needed
# ASSET_CACHE_DIR=/somewhere/else     # move the price cache
# ASSET_UNIVERSE=path/to/universe.csv # your own discovery universe

Nothing here is required: every feature that needs a key says so when you invoke it, and degrades cleanly without it.

πŸ”§ Project

πŸ§ͺ Develop

uv run pytest                  # unit + property-based + regression tests
uv run mypy app/               # strict type-checking
uv run ruff check app/ tests/  # lint
uv run python scripts/build_mcpb.py  # package the Claude-Desktop addon β†’ dist/asset-management-<v>.mcpb

πŸ“ Layout

asset-management/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ events.py     CSV / Ghostfolio-JSON β†’ typed event list
β”‚   β”œβ”€β”€ derive.py     events β†’ holdings + cost basis + realized P&L
β”‚   β”œβ”€β”€ corporate_actions.py  split-adjust raw share counts (stock splits)
β”‚   β”œβ”€β”€ prices.py     multi-source price fetch (latest + history + splits) with provenance + cache
β”‚   β”œβ”€β”€ metadata.py   published fund facts (expense ratio, AUM, volume, age, holdings) + cache
β”‚   β”œβ”€β”€ returns.py    events + prices β†’ equity curve, true TWR, MWR (XIRR), Modified Dietz
β”‚   β”œβ”€β”€ risk.py       drawdown family (max-DD / Ulcer / CDaR) + Sharpe/Sortino/Calmar with bootstrap CIs
β”‚   β”œβ”€β”€ strategy.py   holdings + target β†’ named buy/sell suggestions (rebalance modes) + edge gate
β”‚   β”œβ”€β”€ allocate.py   choose a target: equal_weight / inverse_vol + risk-posture presets + per-asset caps
β”‚   β”œβ”€β”€ onboard.py    step-0 risk quiz (pure): 3 questions β†’ a conservative / moderate / aggressive posture
β”‚   β”œβ”€β”€ screen.py     judge NEW candidate tickers (diversifier / cost / liquidity / age / overlap)
β”‚   β”œβ”€β”€ backtest.py   notional simulation + the walk-forward role/benchmark verdicts (Ulcer-first)
β”‚   β”œβ”€β”€ pipeline.py   the shared bookβ†’pricesβ†’returnsβ†’risk bundle (cli + mcp_server) + cache warm + the --demo book
β”‚   β”œβ”€β”€ report.py     suggestions + backtest + state + prices + returns + risk β†’ ReportData β†’ text/markdown/HTML
β”‚   β”œβ”€β”€ narrate.py    fenced narration: validated figures β†’ {{token}} prose β†’ SUMMARY (pure; no number can be the model's)
β”‚   β”œβ”€β”€ llm.py        optional narrator backend (OpenAI-compatible + Anthropic); fail-closed, opt-in
β”‚   β”œβ”€β”€ email.py      send the HTML brief via Resend (--send)
β”‚   β”œβ”€β”€ cli.py        argparse + entry composition + delivery routing + structured run log
β”‚   β”œβ”€β”€ mcp_server.py read-only stdio MCP server: 8 tools over the core (offline; one-time cold-call auto-warm, ASSET_MCP_OFFLINE=1 opts out)
β”‚   β”œβ”€β”€ universe.py   curated ETF universe loader (Candidate + roles); app/data/universe.csv, auto-built
β”‚   β”œβ”€β”€ discover.py   book β†’ role gaps β†’ top-by-AUM candidates for the screen (--discover, propose-only)
β”‚   β”œβ”€β”€ log_config.py logging setup
β”‚   β”œβ”€β”€ __main__.py   python -m app
β”‚   └── __init__.py
β”œβ”€β”€ tests/            automated suite (unit, property, regression) β€” offline, run on every change
β”œβ”€β”€ reconcile/        manual cross-validation against external tools (ghostfolio, quantstats)
β”œβ”€β”€ scripts/          build_universe.py (refresh the ETF universe) Β· build_mcpb.py (Claude-Desktop bundle) Β· demo_fence.py (poke the fence)
β”œβ”€β”€ .github/          the gate: ruff + mypy --strict + pytest on every push to main + every PR
β”œβ”€β”€ pyproject.toml    dependencies, mypy/ruff config
└── README.md

πŸ”’ Privacy Policy

This tool runs entirely on your own machine. It has no backend, no account, and no telemetry β€” the author receives nothing, ever.

  • What it collects. Nothing. Your transaction log, derived holdings, and price cache are read and written only on your computer, at the paths you pass (--book, --cache-dir, or ASSET_BOOK / ASSET_CACHE_DIR). Nothing is uploaded, and no usage data, crash report, or analytics is emitted.

  • What leaves your machine, and only these. (1) Price and fund data requests β€” ticker symbols are sent to Yahoo Finance and, if you set TIINGO_API_KEY, to Tiingo, to fetch quotes, history, splits, and published fund facts. Ticker symbols only β€” never your quantities, cost basis, or balances. (2) Narration, --narrate, which is off by default: if you turn it on and supply your own LLM key, portfolio figures are sent to the provider you chose (OpenAI-compatible or Anthropic) to be written up as prose. The ASSET_NARRATE_TIER dial controls how much detail is sent; local keeps it on your machine. Turn it off and no LLM is contacted at all.

  • Storage and retention. The price cache lives in .asset-management/prices in your home folder (or ASSET_CACHE_DIR) and persists until you delete it. Reports written with --save go to reports/. Everything is a plain file you own; delete the folders and the data is gone. The author holds no copy and cannot.

  • Third-party sharing. None. The author shares nothing because the author has nothing. Data sent to Yahoo, Tiingo, or your chosen LLM provider is governed by that provider's own privacy policy.

  • The MCP server. Read-only, bound to your configured book, with no file-path arguments and no write tools. It performs two bounded, opt-out network fetches (a one-time cold-cache warm and an on-demand fetch when you screen an uncached ticker); ASSET_MCP_OFFLINE=1 disables both. It sends nothing anywhere else.

  • Contact. Open an issue at github.com/disin7c9/asset-management/issues.

πŸ“œ License

AGPL-3.0-or-later. Free to use, modify, and self-host β€” including commercially. The one condition: if you distribute a modified version, or run one as a network service for others, you must publish your modified source under the same license. (Same license Ghostfolio uses.)

Install Server
A
license - permissive license
A
quality
A
maintenance

Maintenance

–Maintainers
–Response time
–Release cycle
1Releases (12mo)
Commit activity

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/disin7c9/asset-management'

If you have feedback or need assistance with the MCP directory API, please join our Discord server