From 456db28ae3dfd0da62b3368922ee2db5a978da75 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 5 Jul 2026 10:23:51 -0400 Subject: [PATCH] Add inline --holdings so the quickstart needs no bucket files (v0.2.0) Launch-blocker fix: the README quickstart used --bucket examples/... which only exist in the repo, so `pip install`-ed users hit FileNotFoundError. Now `divvy compare --holdings "SCHD=45,DGRO=25,VYM=15,SDY=15"` defines a bucket inline (weights auto-normalized, repeatable), so the quickstart runs from anywhere with zero files. --bucket still works for file-based. README updated to lead with the file-less path. Co-Authored-By: Claude Opus 4.8 --- README.md | 28 ++++++++++----------- pyproject.toml | 2 +- src/divvy/cli.py | 50 ++++++++++++++++++++++++++++++++------ tests/test_cli_holdings.py | 19 +++++++++++++++ uv.lock | 2 +- 5 files changed, 77 insertions(+), 24 deletions(-) create mode 100644 tests/test_cli_holdings.py diff --git a/README.md b/README.md index 099879b..4cb4b1f 100644 --- a/README.md +++ b/README.md @@ -58,18 +58,20 @@ pip install 'divvy-backtest[ui]' # + interactive Experiment Lab Or run from source with [uv](https://github.com/astral-sh/uv): `uv sync`. -## ⚡ Quickstart (no data required) +## ⚡ Quickstart (no data, no files) -Backtest a hypothetical "$500/month since 2019" into a couple of dividend baskets: +Backtest a hypothetical "$500/month since 2019" into a couple of dividend baskets — define them inline with `--holdings` (weights are auto-normalized), so this runs from anywhere: ```bash -uv sync -uv run divvy compare \ - --synthetic-monthly 500 --synthetic-start 2019-01-01 \ - --bucket examples/buckets/dividend_etf_core.yaml \ - --bucket examples/buckets/high_yield_tilt.yaml +pip install divvy-backtest + +divvy compare --synthetic-monthly 500 --synthetic-start 2019-01-01 \ + --holdings "SCHD=45,DGRO=25,VYM=15,SDY=15" \ + --holdings "SCHD=40,VYM=20,SDY=20,SPYD=20" ``` +Prefer files? Pass `--bucket path/to/bucket.yaml` instead (see [Define a bucket](#define-a-bucket-a-candidate-portfolio)). + …and out comes a side-by-side comparison, plus equity & dividend charts in `results//`: ```text @@ -106,9 +108,8 @@ date,amount ``` ```bash -uv run divvy compare \ - --contributions-csv my_contributions.csv \ - --bucket examples/buckets/dividend_etf_core.yaml +divvy compare --contributions-csv my_contributions.csv \ + --holdings "SCHD=45,DGRO=25,VYM=15,SDY=15" ``` See [`examples/contributions.csv`](examples/contributions.csv) for a full sample. @@ -126,16 +127,15 @@ weights: ABBV: 0.15 ``` -Drop new buckets in your own `buckets/` folder (gitignored) and pass as many `--bucket` flags as you like to compare them side by side. +Pass as many `--bucket` flags as you like to compare them side by side — or skip files entirely with inline `--holdings "SCHD=45,DGRO=25,VYM=15,SDY=15"` (repeatable, weights auto-normalized). ## Compare against your *actual* Fidelity account If you export your Fidelity transaction history CSVs, Divvy can auto-derive both your real contribution calendar **and** the real dividends you received, and add your actual account as a comparison row: ```bash -uv run divvy compare \ - --ledger path/to/fidelity_history_csvs/ \ - --bucket examples/buckets/dividend_etf_core.yaml \ +divvy compare --ledger path/to/fidelity_history_csvs/ \ + --holdings "SCHD=45,DGRO=25,VYM=15,SDY=15" \ --real-value 12345.67 --real-as-of 2026-07-03 ``` diff --git a/pyproject.toml b/pyproject.toml index 5da5755..f869aad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "divvy-backtest" -version = "0.1.0" +version = "0.2.0" description = "Replay your actual brokerage transaction history against a counterfactual portfolio bucket and compare dividend income + returns." readme = "README.md" license = "MIT" diff --git a/src/divvy/cli.py b/src/divvy/cli.py index 50ebf9d..842539c 100644 --- a/src/divvy/cli.py +++ b/src/divvy/cli.py @@ -19,6 +19,24 @@ def _load_bucket(path: Path) -> dict[str, float]: return yaml.safe_load(path.read_text())["weights"] +def _parse_holdings(spec: str) -> dict[str, float]: + """Parse inline holdings like 'SCHD=45,DGRO=25,VYM=15,SDY=15' into normalized weights.""" + weights: dict[str, float] = {} + for part in spec.split(","): + part = part.strip() + if not part: + continue + sym, sep, w = part.partition("=") + if not sep: + raise SystemExit(f"Bad --holdings entry {part!r}; expected TICKER=weight") + sym = sym.strip().upper() + weights[sym] = weights.get(sym, 0.0) + float(w) + total = sum(weights.values()) + if total <= 0: + raise SystemExit(f"--holdings needs positive weights: {spec!r}") + return {sym: w / total for sym, w in weights.items()} + + def _parse_year_amount(value: str) -> tuple[int, float]: year, amount = value.split("=") return int(year), float(amount) @@ -54,17 +72,27 @@ def cmd_compare(args: argparse.Namespace) -> None: raise SystemExit("Provide a contribution source: --ledger, --synthetic-monthly, or --contributions-csv") opts = {"dividend_tax_rate": args.dividend_tax_rate, "rebalance": args.rebalance} - er = {} # per-holding expense ratios (flat --expense-ratio applied to all) + + # Buckets come from YAML files (--bucket) and/or inline specs (--holdings). + buckets: list[tuple[str, dict[str, float]]] = [] + for bucket_path in args.bucket or []: + p = Path(bucket_path) + buckets.append((p.stem, {sym: w for sym, w in _load_bucket(p).items() if w > 0})) + for spec in args.holdings or []: + weights = _parse_holdings(spec) + label = "/".join(list(weights)[:4]) + ("…" if len(weights) > 4 else "") + buckets.append((label, weights)) + if not buckets: + raise SystemExit("Provide at least one --bucket or --holdings 'TICKER=weight,...'") variants: dict[str, tuple] = {} - for bucket_path in args.bucket: - bucket_path = Path(bucket_path) - bucket = {sym: w for sym, w in _load_bucket(bucket_path).items() if w > 0} + for label, bucket in buckets: + while label in variants: + label += "*" data = market_data.load_bucket_data(list(bucket), history_start, cache_dir) - if args.expense_ratio: - er = {sym: args.expense_ratio for sym in bucket} + er = {sym: args.expense_ratio for sym in bucket} if args.expense_ratio else {} result = run_backtest(contributions, bucket, data, expense_ratios=er, **opts) - variants[bucket_path.stem] = (result, data) + variants[label] = (result, data) benchmark = (args.benchmark or "").strip().upper() if benchmark and benchmark != "NONE": @@ -173,7 +201,13 @@ def main() -> None: compare.add_argument("--synthetic-monthly", type=float, help="Flat $ amount contributed monthly (no data needed)") compare.add_argument("--synthetic-start", help="Start date for --synthetic-monthly (YYYY-MM-DD)") compare.add_argument("--synthetic-end", help="End date for --synthetic-monthly (default: today)") - compare.add_argument("--bucket", action="append", required=True, help="Bucket YAML path (repeatable)") + compare.add_argument("--bucket", action="append", help="Bucket YAML file path (repeatable)") + compare.add_argument( + "--holdings", + action="append", + metavar="TICKER=weight,...", + help="Define a bucket inline, e.g. 'SCHD=45,DGRO=25,VYM=15,SDY=15' (repeatable; no file needed)", + ) compare.add_argument( "--benchmark", default="SPY", diff --git a/tests/test_cli_holdings.py b/tests/test_cli_holdings.py new file mode 100644 index 0000000..39b80bc --- /dev/null +++ b/tests/test_cli_holdings.py @@ -0,0 +1,19 @@ +import pytest + +from divvy.cli import _parse_holdings + + +def test_parse_holdings_normalizes_percentages(): + w = _parse_holdings("SCHD=45,DGRO=25,VYM=15,SDY=15") + assert sum(w.values()) == pytest.approx(1.0) + assert w["SCHD"] == pytest.approx(0.45) + + +def test_parse_holdings_uppercases_and_sums_dupes(): + w = _parse_holdings("schd=50, schd=50") + assert w == {"SCHD": 1.0} + + +def test_parse_holdings_rejects_bad_entry(): + with pytest.raises(SystemExit): + _parse_holdings("SCHD,VYM") diff --git a/uv.lock b/uv.lock index fa29de6..57aa7d9 100644 --- a/uv.lock +++ b/uv.lock @@ -349,7 +349,7 @@ wheels = [ [[package]] name = "divvy-backtest" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "matplotlib" },