Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<date>/`:

```text
Expand Down Expand Up @@ -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.
Expand All @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
50 changes: 42 additions & 8 deletions src/divvy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <file.yaml> 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":
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions tests/test_cli_holdings.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.