The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Asset Management listing page.
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 — it never receives the figures, and every value it quotes is substituted from the computed result.
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 any numeral itself — in any Unicode numeric category, so ½ fails closed alongside 3.2:
Run both sides yourself — it drives the real production fence, no API key needed: uv run python scripts/demo_fence.py
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. When the split feed is unavailable, the mismatch detector catches ratios of 2:1 or larger.
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 held-out recent-window verdict, and threshold-band rebalance suggestions are laid out with the named rule behind every line, plus per-fund facts:
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.
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.
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.
The core is offline-first: prices come from an on-disk cache, refreshed when you run online. After a fresh clone, fill it once:
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.
Most of the decision features (--rebalance, --backtest, the held-out 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:
--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.)--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.--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:
simulates your target and the reference over their common history (notional $10k, drawdown-first legs with CIs) and adds a held-out recent-window 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.
One command covers the routine check-in — the status brief, suggested actions when a band is breached, the backtest + benchmark verdict, and fund facts:
--benchmark reference nearest your posture (60-40 / all-weather / permanent).bands fires only when some holding has drifted past its band (the "5/25 rule"), then rebalances the whole book — on a week you're depositing and don't want to sell, swap in --rebalance cash_flow_only --new-cash 500 (invest into underweights only; tax-friendly).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. The same rule covers a run that could not do its job: if no holding could be priced (provider unreachable, cold cache offline) the brief still prints from your ledger, but the exit is non-zero and one line names the fix — a single missing ticker stays a partial run and exits 0. A weekly Monday brief is just this on cron:
(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 shape of the output is what makes this a description rather than advice:
inconclusive, insufficient, n/a) instead of pretending;shallower / deeper / inconclusive / insufficient — the vocabulary has no "beats";That shapes the brief. The optional AI narration is prose: the fence guarantees its figures come from the core, but it does not constrain wording — advice-shaped or forecast-shaped sentences are not blocked. app/narrate.py's module docstring lists exactly what is and is not enforced.
mypy --strict + ruff on every push to main and every PR — the badge at the top is that gate, live;scripts/demo_fence.py.The numbers were cross-checked against two independent tools (harness in reconcile/). The recorded run is a 2026-06-02 snapshot over an 11-event book — the bundled example as it stood before cash tracking added its CASH rows — so read it as a point-in-time check, not a gate CI re-runs:
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.
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" with every figure in a tool result computed by the same deterministic core the CLI uses. Be precise about the boundary: the assistant's own prose is not fenced. Nothing here can stop a model typing a number of its own into the chat, so treat any figure not visibly traceable to a tool result as unverified — the server says exactly this in its portfolio://guarantees resource. (The CLI's --narrate path is different: it substitutes figures mechanically and refuses a narration containing a model-typed numeral.) 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; optional role/flavor args drill one shelf (propose-only; judge a pick with screen_candidate).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 held-out recent-window, Ulcer-first 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 (or just replace, if you have never touched claude_desktop_config.json):
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.
.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."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).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.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:
The server runs no LLM itself — an assistant calls it; this is not financial advice.
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 | none for the presets; --book for the re-weight rules | propose a target — re-weight your holdings (equal_weight/inverse_vol, which need a book) or build a strategic role template (conservative/moderate/aggressive, which works with no book at all); write it with --allocate-out |
--onboard | none (a book anchors the roles on what you already hold) | 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 held-out 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 held-out recent-window, Ulcer-first 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:
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.
--demo brief (drawdown, ratios, returns, holdings)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).
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 driftbands — to_total, but gated on a drift trigger: if no ticker is outside its band nothing trades, and if any ticker is, every leg goes back to target (including --new-cash). Trading only the breached leg would sell with nothing to buy, since the offsetting drift sits in the legs still inside their bands. 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 exitsBoth blocks are verbatim output of the commands shown, against data/sample_data/target.csv. Prices move, so the figures are a snapshot; the shape is not.
to_total — sell + buy to hit the target exactly:
bands (the 5/25 rule) — same target. The band is a trigger: VEA sits 12.9pp outside its band, so the whole book goes back to target. BND is inside its own band and still trades, because that's where the offsetting drift lives — its row says so:
If nothing is outside its band, nothing trades at all. Propose-only — no trade is executed; every row cites the rule that fired.
--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.
Here the Ulcer gap clears the noise margin, so the verdict runs all the way to the bootstrap — and the interval straddles zero, so it stays inconclusive and says which gate stopped it. A candidate screened at a 5% sleeve is the opposite case: the effect is far smaller and gate 1 usually settles it on its own (MATH.md §12.7).
Read what it doesn't say. The preset carries slightly less drawdown pain than 60-40 (Ulcer 5.57% vs 5.74%) and slightly less return (+9.42% vs +9.81%/yr) — a trade, not a win. And even the drawdown edge doesn't survive the held-out window: the verdict is "no clear drawdown difference", because the Ulcer gain is inside the noise margin. The held-out line also prints the return cost alongside the drawdown gain, so a less-painful path can't be sold to you without its price tag.
--discover maps your holdings to roles (US large, emerging markets, TIPS, REITs, …), finds the roles you hold ≤3% of, takes the biggest core 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.
Four honesty rules keep the panel from overreaching. A gap means no dedicated fund — broad funds you hold may already include the role at market weight (a total-market fund already holds mid/smalls; an aggregate bond fund already holds treasuries and IG corporates), so the panel says so instead of implying a hole. Candidates come in shelves of near-substitutes — each universe row carries a machine-readable flavor (its shelf: a treasury duration, a sector, REIT geography), and every menu shows one shelf's comparable funds (≥3, a genuine choice) while naming the role's other shelves with counts instead of hiding or ranking them; the default shelf is the one the presets already buy from, so it carries no new opinion, and drilling any other is one flag away (--discover treasury:long). Core funds surface first — a core flag (plain blend / diversified / investment-grade vs a Growth/Value style, single region, or high-yield tilt) keeps a junk-bond fund from ever filling a "corporate-bond gap" by AUM accident; core-less shelves stay index lines until you name them (--discover corporate-bond:high-yield is consent to see junk, labeled as junk). And the sector/thematic aisle is never flagged as a gap — not holding a tech bet is a stance, not a hole; --discover sector-equity hands you the shelf map (tech · semis · clean-energy · …) and refuses to pick a sector, because that choice is yours; drill a shelf to screen its funds. A custom universe without the new columns simply degrades to the plain behavior.
Verbatim output, one role each from the panel the command prints (the full panel covers every gap).
Default (--demo --discover) — the lead shelf's comparable funds, with the role's other shelves named, not ranked:
Satellite (--demo --discover sector-equity) — the tool maps the shelves and refuses to pick one:
Drill one shelf (--demo --discover treasury:long) — the shelf label discloses the risk up front:
The universe is auto-built (and refreshable) — no hand-maintenance:
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.
With a --target (or ASSET_TARGET in .env) each discovered candidate also faces the held-out role check — replayed as a 5% sleeve and judged on a recent window the screen did not use while deciding. The panel states which mode it ran in, because the two look identical otherwise. It costs roughly a second per four candidates.
--screen TICKER judges a new ticker against your book — cost, liquidity, age, concentration, overlap with what you hold, whether it diversified your past drawdowns, and its own worst drawdown — each with a reason. Propose-only; a PASS is "sane, cheap, liquid, genuinely different", never a prediction.
The own-drawdown row compares against the deepest-falling fund you already hold, not against your portfolio's worst fall. A blend drops less than its parts by construction, so "deeper than your book" is a bar almost any single equity fund clears — on the bundled example it would flag three of the four funds that book holds. Clearing the peer bar says "no worse than what you already live with"; there is also a fixed equity-scale bar (worse than −30%) that warns regardless.
Add --target and it also runs the held-out role check: give the candidate a 5% sleeve, replay it, and judge only on a recent window the check didn't look at while deciding. Two things to know about that verdict. It measures drawdown pain, not return — an uncorrelated fund makes the ride smoother even when it earns nothing, so the line prints the return cost beside the drawdown gain and you weigh both. And when a role check follows, the screen's return-based checks (correlation, drawdown-window return, own drawdown) are computed on the in-sample window only and labelled [in-sample through …], so the candidate isn't chosen using the window the check then holds out; the structural checks (cost, liquidity, age, overlap) carry no return information and keep reading full history. Details: MATH.md §12.3.
Both fill the treasury role and both pass on correlation — but own-drawdown shows they are not the same risk:
Both clear the peer bar here — this book already holds IAU, which fell -26.4%, so the bar is set by what you already live with rather than by an abstract threshold. The contrast is still the point: TLT's worst fall is more than twice IEF's, and against a book whose deepest holding fell less than 22% that same TLT row would warn while IEF's would not. Correlation alone would wave both through identically.
--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:
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.
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.
Nothing here is required: every feature that needs a key says so when you invoke it, and degrades cleanly without it.
This tool runs entirely on your own machine. It has no backend, no account, and no telemetry — the author receives nothing, ever.
--book, --cache-dir, or ASSET_BOOK / ASSET_CACHE_DIR). Nothing is uploaded, and no usage data, crash report, or analytics is emitted.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. (3) Email delivery, --send, which is off by default: if you turn it on and supply your own Resend API key, the rendered HTML brief — which contains your holdings, share counts, cost basis, and P&L — is POSTed to Resend to be delivered to the address you set. Nothing else is sent there, and without --send Resend is never contacted.data/prices when you run from a clone, or .asset-management/prices in your home folder when the tool is installed as a package (override either with ASSET_CACHE_DIR), and persists until you delete it. The two are separate directories — warming from a clone does not warm the cache a wheel-installed Desktop addon reads. 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.ASSET_CACHE_DIR. 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.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.)