diff --git a/.gitignore b/.gitignore index f2b8937..cbffc6a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist coverage *.log CLAUDE.local.md +.bench/ diff --git a/CLAUDE.md b/CLAUDE.md index f590bc1..e04a70d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,14 @@ WF_TEST_REFRESH=1 pnpm test:integration scripts/test-server.sh down ``` +Output size benchmark (see README "Output size benchmark"). Run it before and after a change to what a command prints: + +```sh +pnpm build && eval "$(scripts/test-server.sh up)" +scripts/bench-seed.sh # once per server; WF_TEST_PROXY=1 on test-server.sh if quotes need a proxy +scripts/bench.sh main # Markdown table: main vs dist/wf.js +``` + CI also fails if `vendor/`, `src/contract.ts` or `UPSTREAM_TAG` differ from what is committed (`git diff --exit-code`). ## Architecture diff --git a/README.md b/README.md index ee7b530..c771d55 100644 --- a/README.md +++ b/README.md @@ -106,28 +106,31 @@ rule). Then commands exit with code 3 and `PROFILE_LOCKED`; run ## Commands -| Command | What it does | -| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wf login` / `wf logout` / `wf status` | Session; `status` also warns when the server version is not `UPSTREAM_TAG`. | -| `wf commands [--filter x]` | The name of every command. With `--filter`, also each one's method, path and the payload type `invoke` reads. | -| `wf schema ` | Payload and response types of one command, expanded, with field notes. | -| `wf call [--args json \| --args-file f \| --args -] [--wait]` | Runs a command. `-` reads stdin. | -| `wf sync [--asset id]... [--refetch-all \| --recent-days n]` | Syncs market data and updates the portfolio. | -| `wf accounts [--all]` | Accounts, with tracking mode, group and sync provider. | -| `wf holdings [--account id [--include-closed]] [--full]` | Holdings, cash included, with asset id, local values and FX rate. Without `--full`, the instrument's classifications and notes and the holding's metadata are left out. | -| `wf activity search [filters]` | Activities, newest first. Filters: `--account`, `--type`, `--id` (each repeatable), `--from`, `--to`, `--needs-review` or `--reviewed`, `--asset`, `--page`, `--page-size` (default 20). Each row has only `id`, `accountId`, `assetId`, `assetSymbol`, `activityType`, `subtype`, `status`, `date`, `quantity`, `unitPrice`, `amount`, `fee`, `currency`, `fxRate` and `needsReview`; `--full` returns every field. | -| `wf activity update [--field value]... [--dry-run]` | Corrects activity fields with the server's own rules; `--updates` for a batch. Needs `wf agent-token create`. | -| `wf agent-token create\|show\|delete` | The access token for the server's MCP endpoint, which `activity update` uses. | -| `wf activity delete ... [--dry-run]` | Deletes up to 100 activities. One leg of a linked transfer deletes both. | -| `wf activity void ... [--dry-run]` | Voids posted activities: kept, but out of holdings, cash and performance. | -| `wf activity restore ... [--dry-run]` | Posts voided activities again. | -| `wf health [--refresh]` | Health Center status; `--refresh` runs the checks first. Each issue's `diagnostics` is left out unless `--full`. | -| `wf rebalance [--target id\|name] [--holdings] [--cash n [--mode m] [--asset id]...]` | Drift against an allocation target (`--holdings` adds the holdings); with `--cash`, a rebalance plan. | -| `wf spending report [--from d] [--to d] [--account id]...` | Spending report (default: this month so far). | -| `wf budget [--period YYYY-MM]` | Budget snapshot. | -| `wf alt-asset value [--date d] [--notes t]` | Records a valuation for a property, vehicle, and so on. | +| Command | What it does | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wf login` / `wf logout` / `wf status` | Session; `status` also warns when the server version is not `UPSTREAM_TAG`. | +| `wf commands [--filter x]` | The name of every command. With `--filter`, also each one's method, path and the payload type `invoke` reads. | +| `wf schema ` | Payload and response types of one command, expanded, with field notes. | +| `wf call [--args json \| --args-file f \| --args -] [--wait]` | Runs a command. `-` reads stdin. | +| `wf sync [--asset id]... [--refetch-all \| --recent-days n]` | Syncs market data and updates the portfolio. | +| `wf accounts [--all]` | Accounts, with tracking mode, group and sync provider. | +| `wf holdings [--account id [--include-closed]] [--full]` | Holdings, cash included: one flat row each, with asset id, symbol, quantity, price, and values and gains in the base currency (plus `fxRate` and `marketValueLocal` for a foreign currency). `--full` returns the server's rows: local values, lots, classifications and so on. | +| `wf activity search [filters]` | Activities, newest first. Filters: `--account`, `--type`, `--id` (each repeatable), `--from`, `--to`, `--needs-review` or `--reviewed`, `--asset`, `--page`, `--page-size` (default 20). Each row has only `id`, `accountId`, `assetId`, `assetSymbol`, `activityType`, `subtype`, `status`, `date`, `quantity`, `unitPrice`, `amount`, `fee`, `currency`, `fxRate` and `needsReview`; `--full` returns every field. | +| `wf activity update [--field value]... [--dry-run]` | Corrects activity fields with the server's own rules; `--updates` for a batch. Needs `wf agent-token create`. | +| `wf agent-token create\|show\|delete` | The access token for the server's MCP endpoint, which `activity update` uses. | +| `wf activity delete ... [--dry-run]` | Deletes up to 100 activities. One leg of a linked transfer deletes both. | +| `wf activity void ... [--dry-run]` | Voids posted activities: kept, but out of holdings, cash and performance. | +| `wf activity restore ... [--dry-run]` | Posts voided activities again. | +| `wf health [--refresh]` | Health Center status; `--refresh` runs the checks first. Each issue's `diagnostics`, routes and translation keys are left out unless `--full`. | +| `wf rebalance [--target id\| Drift against an allocation target (`--holdings`adds the holdings); with`--cash`, a rebalance plan. Category colours are left out unless `--full`. | +| `wf spending report [--from d] [--to d] [--account id]...` | Spending report (default: this month so far). The per-day totals (`byDay`, `byDayByCategory`) are left out unless `--full`. | +| `wf budget [--period YYYY-MM]` | Budget snapshot: totals, then each group and its categories with target, actual and remaining. Categories with no target and no spending are left out. `--full` returns the server's `{state, computed}`. | +| `wf alt-asset value [--date d] [--notes t]` | Records a valuation for a property, vehicle, and so on. | Add `--table` before or after the command for a human-readable table. +Add `--fields a,b.c` to print only some fields of each row (dotted paths; +for `activity search`, of each row in `data`). It works on every command, +`wf call` included. ### Waiting for the recalculation @@ -140,7 +143,8 @@ the new numbers. `--wait` works on `wf call` (not on read-only GET commands), The output gets a `wait` object: `{status: "complete" | "error" | "timeout", seconds, error?, marketSync?}`. `marketSync` is the server's quote sync result -without its list of skipped assets, so look at `failed_syncs` for failures. +without its list of skipped assets and the provider URLs in its messages, so +look at `failed_syncs` for failures. `wf call --wait` wraps the response as `{result, wait}`. An error or a timeout exits 1; the write has happened either way. Another client's update that starts during the wait can end it @@ -239,8 +243,7 @@ wf schema create_activity | jq -r .payloadType # Accounts, then holdings of the first one wf call get_accounts | jq -r '.[] | "\(.id)\t\(.name)\t\(.currency)"' id=$(wf call get_accounts | jq -r '.[0].id') -wf call get_holdings --args "{\"filter\":{\"type\":\"account\",\"accountId\":\"$id\"}}" \ - | jq '[.[] | {symbol: .instrument.symbol, value: .marketValue.base}]' +wf holdings --account "$id" --fields symbol,quantity,marketValue # Record a deposit, wait for the recalculation, then read holdings wf call create_activity --wait --args-file - <<'JSON' | jq '.wait.status' @@ -322,6 +325,28 @@ WF_TEST_REFRESH=1 pnpm test:integration scripts/test-server.sh down ``` +### Output size benchmark + +Agents pay for every byte that `wf` prints. `scripts/bench.sh` prints a +Markdown table of how much each read command outputs (tokens are estimated as +bytes / 4) against a server that `scripts/bench-seed.sh` fills with a fixed +data set: 3 accounts, 8 stocks, about 120 activities, and a month of +categorised spending with budget targets. + +```sh +pnpm build +eval "$(scripts/test-server.sh up)" +scripts/bench-seed.sh # once per server +scripts/bench.sh # this build +scripts/bench.sh v0.3.1 # this build against a tag or commit +scripts/test-server.sh down +``` + +Without prices, holdings carry nulls and look smaller than a real portfolio's. +The server fetches them from Yahoo Finance. If outbound traffic must go through +a proxy, start the server with `WF_TEST_PROXY=1` (it passes `HTTPS_PROXY`), and +with `WF_TEST_CA_BUNDLE=` when that proxy has its own CA. + ### Upgrading upstream ```sh diff --git a/scripts/bench-seed.sh b/scripts/bench-seed.sh new file mode 100755 index 0000000..bd5b8e2 --- /dev/null +++ b/scripts/bench-seed.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Seeds a fresh test server (scripts/test-server.sh up) with a fixed data set +# for scripts/bench.sh: +# +# Brokerage USD securities: 6 stocks bought monthly in 2025, dividends +# Euro ISA EUR securities: 2 stocks bought quarterly +# Checking USD cash, tracked for spending: November 2025 salary, +# categorised spending, and budget targets for 2025-11 +# +# eval "$(WF_TEST_PROXY=1 scripts/test-server.sh up)" +# pnpm build && scripts/bench-seed.sh +# +# Run it once per server: it adds the same data again on a second run. Prices +# need the server to reach Yahoo Finance; without them, holdings carry nulls +# and the numbers are smaller than a real portfolio's. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +: "${WF_TEST_URL:?Run: eval \"\$(scripts/test-server.sh up)\"}" +: "${WF_TEST_PASSWORD:?Run: eval \"\$(scripts/test-server.sh up)\"}" +export WF_CONFIG_DIR="${WF_BENCH_CONFIG_DIR:-$ROOT/.bench}" +mkdir -p "$WF_CONFIG_DIR" +wf() { node "$ROOT/dist/wf.js" "$@"; } +id() { node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).id))'; } + +WF_PASSWORD="$WF_TEST_PASSWORD" wf login --url "$WF_TEST_URL" >/dev/null +wf call update_settings --args \ + '{"settingsUpdate":{"baseCurrency":"USD","timezone":"UTC","onboardingCompleted":true}}' >/dev/null + +account() { + wf call create_account --args "{\"account\":{\"name\":\"$1\",\"accountType\":\"$2\",\"currency\":\"$3\", + \"isDefault\":false,\"isActive\":true,\"isArchived\":false,\"trackingMode\":\"TRANSACTIONS\"}}" | id +} +activity() { wf call create_activity --args "{\"activity\":$1}" | id; } + +BROKER=$(account Brokerage SECURITIES USD) +ISA=$(account "Euro ISA" SECURITIES EUR) +CHECKING=$(account Checking CASH USD) +echo "accounts: $BROKER $ISA $CHECKING" >&2 + +activity "{\"accountId\":\"$BROKER\",\"activityType\":\"DEPOSIT\",\"activityDate\":\"2025-01-02\",\"amount\":\"200000\",\"currency\":\"USD\"}" >/dev/null +activity "{\"accountId\":\"$ISA\",\"activityType\":\"DEPOSIT\",\"activityDate\":\"2025-01-02\",\"amount\":\"100000\",\"currency\":\"EUR\"}" >/dev/null +for symbol in AAPL MSFT NVDA GOOGL AMZN VTI; do + for month in 01 02 03 04 05 06 07 08 09 10 11 12; do + activity "{\"accountId\":\"$BROKER\",\"activityType\":\"BUY\",\"activityDate\":\"2025-$month-15\", + \"quantity\":\"2\",\"unitPrice\":\"$((100 + 10#$month * 3))\",\"fee\":\"1\",\"currency\":\"USD\", + \"asset\":{\"symbol\":\"$symbol\",\"quoteCcy\":\"USD\"}}" >/dev/null + done +done +for symbol in SAP.DE ASML.AS; do + for month in 02 05 08 11; do + activity "{\"accountId\":\"$ISA\",\"activityType\":\"BUY\",\"activityDate\":\"2025-$month-10\", + \"quantity\":\"5\",\"unitPrice\":\"200\",\"fee\":\"2\",\"currency\":\"EUR\", + \"asset\":{\"symbol\":\"$symbol\",\"quoteCcy\":\"EUR\"}}" >/dev/null + done +done +for month in 03 06 09 12; do + activity "{\"accountId\":\"$BROKER\",\"activityType\":\"DIVIDEND\",\"activityDate\":\"2025-$month-20\", + \"amount\":\"12.5\",\"currency\":\"USD\",\"asset\":{\"symbol\":\"AAPL\"}}" >/dev/null +done +echo "investments: done" >&2 + +# Spending: the account must be tracked before its activities count. +wf call update_spending_settings --args \ + "{\"update\":{\"enabled\":true,\"accountIds\":[\"$CHECKING\"]}}" >/dev/null +categorise() { + wf call assign_activity_category --args \ + "{\"activityId\":\"$1\",\"taxonomyId\":\"$2\",\"categoryId\":\"$3\"}" >/dev/null +} +salary=$(activity "{\"accountId\":\"$CHECKING\",\"activityType\":\"DEPOSIT\",\"activityDate\":\"2025-11-01\",\"amount\":\"6500\",\"currency\":\"USD\"}") +categorise "$salary" income_sources cat_income_employment +# day category amount +while read -r day category amount; do + spend=$(activity "{\"accountId\":\"$CHECKING\",\"activityType\":\"WITHDRAWAL\", + \"activityDate\":\"2025-11-$day\",\"amount\":\"$amount\",\"currency\":\"USD\"}") + categorise "$spend" spending_categories "$category" +done <<'ROWS' +01 cat_housing 2200 +02 cat_groceries 84.20 +03 cat_transport 45 +04 cat_food 32.50 +05 cat_bills 120 +06 cat_groceries 61.75 +07 cat_entertainment 18.99 +08 cat_shopping 149 +09 cat_food 27.80 +10 cat_health 60 +11 cat_groceries 92.10 +12 cat_transport 45 +13 cat_food 41.20 +14 cat_personal 35 +15 cat_bills 64.99 +16 cat_groceries 73.40 +17 cat_travel 410 +18 cat_food 22.30 +19 cat_gifts 75 +20 cat_groceries 88.60 +21 cat_entertainment 12.99 +22 cat_fees 4.50 +23 cat_food 55.10 +24 cat_shopping 89 +25 cat_groceries 66.30 +26 cat_transport 45 +27 cat_education 120 +28 cat_food 38.90 +29 cat_other_expense 25 +30 cat_groceries 79.95 +ROWS +while read -r category amount; do + wf call upsert_budget_target --args "{\"periodKey\":\"2025-11\",\"target\":{\"periodKey\":\"2025-11\", + \"targetType\":\"category\",\"taxonomyId\":\"spending_categories\",\"categoryId\":\"$category\", + \"amount\":\"$amount\"}}" >/dev/null +done <<'ROWS' +cat_housing 2200 +cat_groceries 500 +cat_food 200 +cat_transport 150 +cat_bills 200 +cat_shopping 150 +cat_entertainment 50 +ROWS +echo "spending: done" >&2 + +# Quotes and the recalculation; the sync result says which symbols failed. +wf sync --wait --wait-timeout 300 >&2 diff --git a/scripts/bench.sh b/scripts/bench.sh new file mode 100755 index 0000000..eeeef65 --- /dev/null +++ b/scripts/bench.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Measures how much each read command prints, as a Markdown table: what an +# agent pays to read it. Tokens are estimated as bytes / 4. +# +# scripts/bench.sh # dist/wf.js against the seeded server +# scripts/bench.sh # also builds and compares +# +# Needs a server seeded by scripts/bench-seed.sh (it reuses its login), and a +# build (pnpm build). Runs only reads, so it can run any number of times. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export WF_CONFIG_DIR="${WF_BENCH_CONFIG_DIR:-$ROOT/.bench}" +NEW="$ROOT/dist/wf.js" +[ -f "$NEW" ] || { echo "Run pnpm build first." >&2; exit 2; } +[ -d "$WF_CONFIG_DIR" ] || { echo "Run scripts/bench-seed.sh first." >&2; exit 2; } + +BASE="" +if [ -n "${1:-}" ]; then + TREE="$(mktemp -d)" + trap 'git -C "$ROOT" worktree remove --force "$TREE" >/dev/null 2>&1 || true' EXIT + git -C "$ROOT" worktree add -q --detach "$TREE" "$1" + ln -s "$ROOT/node_modules" "$TREE/node_modules" + (cd "$TREE" && pnpm -s build >/dev/null) + BASE="$TREE/dist/wf.js" +fi + +account() { + node "$NEW" accounts | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{ + const a=JSON.parse(s).find((x)=>x.name===process.argv[1]); console.log(a ? a.id : "")})' "$1" +} +BROKER="$(account Brokerage)" +[ -n "$BROKER" ] || { echo "No Brokerage account. Run scripts/bench-seed.sh first." >&2; exit 2; } + +# label|arguments. is replaced by the Brokerage account id. +COMMANDS=( + "status|status" + "accounts|accounts" + "holdings|holdings" + "holdings --account|holdings --account " + "holdings --full|holdings --full" + "activity search|activity search" + "activity search --page-size 100|activity search --page-size 100" + "activity search --full|activity search --full" + "health|health" + "budget|budget --period 2025-11" + "budget --full|budget --period 2025-11 --full" + "spending report|spending report --from 2025-11-01 --to 2025-11-30" + "spending report --full|spending report --from 2025-11-01 --to 2025-11-30 --full" + "commands|commands" + "schema create_activity|schema create_activity" + "--help|--help" +) + +# Bytes on stdout, or "exit N" when the command fails. +measure() { + local bin=$1 out code + shift + set +e + out="$(node "$bin" "$@" 2>/dev/null | wc -c)" + code=${PIPESTATUS[0]} + set -e + if [ "$code" != 0 ]; then echo "exit $code"; else echo "$out"; fi +} + +cell() { case $1 in exit*) echo "$1" ;; *) echo "$1 (~$(($1 / 4)))" ;; esac; } + +if [ -n "$BASE" ]; then + echo "| Command | $1 bytes (~tokens) | dist bytes (~tokens) | Change |" + echo "| --- | ---: | ---: | ---: |" +else + echo "| Command | Bytes (~tokens) |" + echo "| --- | ---: |" +fi +for entry in "${COMMANDS[@]}"; do + label="${entry%%|*}" + read -r -a args <<<"${entry#*|}" + args=("${args[@]//$BROKER}") + now="$(measure "$NEW" "${args[@]}")" + if [ -n "$BASE" ]; then + before="$(measure "$BASE" "${args[@]}")" + change="" + case "$before$now" in + *exit*) ;; + *) [ "$before" -gt 0 ] && change="$(((now - before) * 100 / before))%" ;; + esac + echo "| \`$label\` | $(cell "$before") | $(cell "$now") | $change |" + else + echo "| \`$label\` | $(cell "$now") |" + fi +done diff --git a/scripts/test-server.sh b/scripts/test-server.sh index 0ec5a87..c31eda5 100755 --- a/scripts/test-server.sh +++ b/scripts/test-server.sh @@ -6,6 +6,9 @@ # # The image tag comes from UPSTREAM_TAG without the leading "v". # WF_TEST_PORT (default 8088) and WF_TEST_TTL_MINUTES (default 60) are optional. +# WF_TEST_PROXY=1 passes HTTPS_PROXY and WF_TEST_CA_BUNDLE (a CA file) to the +# server, for sandboxes where outbound traffic must go through a proxy, so +# quote syncs can reach the market data provider. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -20,8 +23,18 @@ HASH='$argon2id$v=19$m=65536,t=3,p=4$MmjLEewIk4DVBqvLmF5+Dg$0yHuiPF4d7YOOS9+tnOV case "${1:-}" in up) docker rm -f "$NAME" >/dev/null 2>&1 || true - docker run -d --name "$NAME" -p "127.0.0.1:$PORT:8088" \ - -e WF_LISTEN_ADDR=0.0.0.0:8088 \ + NET=(-p "127.0.0.1:$PORT:8088" -e WF_LISTEN_ADDR=0.0.0.0:8088) + if [ "${WF_TEST_PROXY:-}" = 1 ]; then + # A proxy on the host's loopback is reachable only from the host network. + NET=(--network host -e "WF_LISTEN_ADDR=127.0.0.1:$PORT" + -e "HTTPS_PROXY=${HTTPS_PROXY:?WF_TEST_PROXY=1 needs HTTPS_PROXY}" + -e "HTTP_PROXY=${HTTP_PROXY:-$HTTPS_PROXY}" -e NO_PROXY=127.0.0.1,localhost) + if [ -n "${WF_TEST_CA_BUNDLE:-}" ]; then + NET+=(-v "$WF_TEST_CA_BUNDLE:/etc/ssl/certs/ca-certificates.crt:ro" + -e SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt) + fi + fi + docker run -d --name "$NAME" "${NET[@]}" \ -e WF_DB_PATH=/data/wealthfolio.db \ -e "WF_SECRET_KEY=$(openssl rand -base64 32)" \ -e "WF_AUTH_PASSWORD_HASH=$HASH" \ diff --git a/skills/wealthfolio/SKILL.md b/skills/wealthfolio/SKILL.md index e4f25ee..436b6b1 100644 --- a/skills/wealthfolio/SKILL.md +++ b/skills/wealthfolio/SKILL.md @@ -25,8 +25,8 @@ code you can act on. `wf activity search`. Never guess an id. 5. **Add `--wait` to writes when you read right after.** Without it, the server recalculates in the background and your next read can be stale. -6. **Read as JSON, without `--table`.** `--table` truncates nested fields such - as `instrument`, so you lose the symbol. Fetch JSON, then summarise or +6. **Read as JSON, without `--table`.** `--table` cuts long values and nested + fields, so you can lose data. Fetch JSON, then summarise or format it yourself, even when the user asks to see the data. Use `--table` only when the user asks for a table. @@ -77,18 +77,15 @@ wf rebalance --cash 5000 # plus a plan to invest 5000 round them. - Dates are `YYYY-MM-DD` or RFC 3339. - Add `--table` only when the user asks for a table (rule 6). -- `wf holdings` leaves out each instrument's classifications and notes and the - holding's metadata. Add `--full` only when you need sectors or regions. Even - slim, no `--account` returns the aggregate across all accounts (on one - portfolio, 26 rows were about 12k tokens), so pass `--account` when the question is about one - account. Pipe it through `jq` to the fields you need: - - ```sh - wf holdings --account | jq '[.[] | {symbol: .instrument.symbol, - quantity, price, value: .marketValue.base, cost: .costBasis.base, - gain: .totalGain.base, weight}]' - ``` - +- `wf holdings` returns one flat row per holding: `assetId`, `symbol`, + `quantity`, `price` (in `currency`), and `marketValue`, `costBasis`, + `unrealizedGain`, `totalGain`, `totalGainPct`, `dayChangePct` and `weight` + in `baseCurrency`. Null fields are left out. Add `--full` only when you need + local values, lots, sectors or regions. Without `--account`, it returns the + total across all accounts, with `sourceAccountIds`. Pass `--account` when the + question is about one account. +- Add `--fields a,b` to any command to keep only those fields of each row, + for example `wf holdings --fields symbol,marketValue,weight`. - `activity search` returns 20 rows by default, each with only `id`, `accountId`, `assetId`, `assetSymbol`, `activityType`, `subtype`, `status`, `date`, `quantity`, `unitPrice`, `amount`, `fee`, `currency`, `fxRate` and @@ -100,12 +97,15 @@ wf rebalance --cash 5000 # plus a plan to invest 5000 it was recorded at. - `wf spending report` defaults to this month. It returns `current` and `prior` totals (`income`, `outflow`, `saved`, `net`) and breakdowns by - category and day. `--account ` repeats. -- `wf budget` returns `state` (groups, targets) and `computed` (`totals`, - per-group rows). All zeros with empty `groupRows` means no budget is set up - for that month; say so instead of reporting zero spending. + category. Add `--full` for the per-day totals. `--account ` repeats. +- `wf budget` returns `totals`, `groups` (each with `planned`, `actual`, + `remaining`, `overspent` and its `categories`), `ungrouped` and `income`. + Categories with no target and no spending are left out. Zero totals with no + categories mean no budget is set up for that month; say so instead of + reporting zero spending. `--full` returns the server's `{state, computed}`. - `wf health` leaves out each issue's `diagnostics` (it repeats `details` and - `affectedItems`). Add `--full` only if those don't explain an issue. + `affectedItems`) and the web app's routes. Add `--full` only if those don't + explain an issue. - `wf holdings --account --include-closed` also lists sold-out positions. For anything without its own command: @@ -131,7 +131,7 @@ wf call create_activity --wait --args '{"activity":{ - A new asset needs `symbol` and `quoteCcy`. For an asset already stored, pass `"asset":{"id":""}`. `wf holdings` shows that id as - `instrument.id`. + `assetId`. Cash activities (`DEPOSIT`, `WITHDRAWAL`, `DIVIDEND`, `INTEREST`, `FEE`) take `amount` and no quantity. The output is `{result, wait}`. When @@ -161,7 +161,7 @@ wf activity update --unit-price 110 --wait - Name only the fields that change. `--clear fee` sets an optional field to null. - `--asset` takes an asset id, not a symbol. Find the id in `wf holdings` - (`instrument.id`) or in the activity's `assetId`. + or in the activity, as `assetId`. - `--approve` takes an activity out of the review queue. A draft becomes posted. - For a batch, use `--updates '[{"activityId":"a","fee":"1"},...]'` or diff --git a/src/commands/args.ts b/src/commands/args.ts index 6837ce7..720e30b 100644 --- a/src/commands/args.ts +++ b/src/commands/args.ts @@ -44,3 +44,73 @@ export function intArg(value: string): number { } export const collect = (value: string, previous: string[] = []) => [...previous, value]; + +/** The timezone if Intl knows it, else this machine's, as the web app resolves it. */ +function resolveTimezone(timezone?: string | null): string { + const candidate = timezone?.trim(); + if (candidate) { + try { + new Intl.DateTimeFormat("en-US", { timeZone: candidate }).format(new Date()); + return candidate; + } catch { + // fall through + } + } + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +/** Today's calendar date as YYYY-MM-DD in `timezone` (else this machine's). */ +export function todayIn(timezone?: string | null, now = new Date()): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: resolveTimezone(timezone), + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); +} + +/** How far `timezone`'s wall clock is ahead of UTC at `instant`, in ms. */ +function offsetMs(instant: Date, timezone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }).formatToParts(instant); + const part = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((p) => p.type === type)?.value); + const hour = part("hour") === 24 ? 0 : part("hour"); + const wall = Date.UTC( + part("year"), + part("month") - 1, + part("day"), + hour, + part("minute"), + part("second"), + instant.getUTCMilliseconds(), + ); + return wall - instant.getTime(); +} + +/** + * The first or last millisecond of a YYYY-MM-DD day in `timezone`, as an + * RFC 3339 timestamp: what the web app sends for a date range + * (zonedCalendarDateBoundaryToDate in features/spending/lib/timezone.ts). + */ +export function dayBoundary(date: string, boundary: "start" | "end", timezone?: string | null) { + const tz = resolveTimezone(timezone); + const [y, m, d] = date.split("-").map(Number) as [number, number, number]; + const wall = + boundary === "start" ? Date.UTC(y, m - 1, d) : Date.UTC(y, m - 1, d, 23, 59, 59, 999); + let instant = new Date(wall); + for (let i = 0; i < 3; i += 1) { + const next = new Date(wall - offsetMs(instant, tz)); + if (next.getTime() === instant.getTime()) break; + instant = next; + } + return instant.toISOString(); +} diff --git a/src/commands/curated.ts b/src/commands/curated.ts index d332a93..b3e8ecb 100644 --- a/src/commands/curated.ts +++ b/src/commands/curated.ts @@ -2,9 +2,20 @@ // vendored invoke switch reads for its command (see `wf commands`). import { Command, Option } from "commander"; import { UsageError } from "../errors"; -import { open, output } from "../context"; +import { appTimezone, open, output } from "../context"; +import { omit } from "../output"; import { addWaitOptions, withWait, type WaitOptions } from "../wait"; -import { collect, dateArg, decimalArg, intArg, monthArg, numberArg, today } from "./args"; +import { + collect, + dateArg, + dayBoundary, + decimalArg, + intArg, + monthArg, + numberArg, + today, + todayIn, +} from "./args"; type AccountScope = | { type: "all" } @@ -74,6 +85,7 @@ export function registerCurated(program: Command): void { ) .option("--asset ", "limit the plan to this asset; repeat for more", collect) .option("--holdings", "include holdings in the drift report") + .option("--full", "keep the colours of each category, left out by default") .action( async ( opts: { @@ -82,6 +94,7 @@ export function registerCurated(program: Command): void { mode: string; asset?: string[]; holdings?: boolean; + full?: boolean; }, cmd: Command, ) => { @@ -104,14 +117,14 @@ export function registerCurated(program: Command): void { ); } const filter = targetFilter(target!); - const drift = await client.invoke("get_allocation_target_drift", { + const drift = await client.invoke("get_allocation_target_drift", { targetId: target!.id, filter, includeHoldings: opts.holdings ?? false, }); const result: Record = { target: { id: target!.id, name: target!.name }, - drift, + drift: opts.full ? drift : withoutColours(drift), }; if (opts.cash !== undefined) { result.plan = await client.invoke("calculate_rebalance_plan", { @@ -138,28 +151,52 @@ export function registerCurated(program: Command): void { ) .option("--to ", "end date, YYYY-MM-DD (default: today)", dateArg) .option("--account ", "only this account; repeat for more", collect) - .action(async (opts: { from?: string; to?: string; account?: string[] }, cmd: Command) => { - const endDate = opts.to ?? today(); - // Without --from, report the month of --to; a past --to must not start after it. - const startDate = opts.from ?? `${endDate.slice(0, 8)}01`; - if (startDate > endDate) throw new UsageError("--from must not be after --to."); - const request = { - startDate, - endDate, - ...(opts.account ? { accountIds: opts.account } : {}), - }; - output(cmd, await open(cmd).invoke("get_spending_report", { request })); - }); + .option("--full", "also return the per-day totals (byDay, byDayByCategory)") + .action( + async ( + opts: { from?: string; to?: string; account?: string[]; full?: boolean }, + cmd: Command, + ) => { + if (opts.from && opts.to && opts.from > opts.to) { + throw new UsageError("--from must not be after --to."); + } + const client = open(cmd); + // The server reads RFC 3339 instants, and refuses a bare date once an + // account is tracked. As the web app: whole days in the app's timezone, + // and "today" there too. + const timezone = await appTimezone(client); + const endDate = opts.to ?? todayIn(timezone); + // Without --from, report the month of --to; a past --to must not start after it. + const startDate = opts.from ?? `${endDate.slice(0, 8)}01`; + if (startDate > endDate) throw new UsageError("--from must not be after --to."); + const request = { + startDate: dayBoundary(startDate, "start", timezone), + endDate: dayBoundary(endDate, "end", timezone), + ...(opts.account ? { accountIds: opts.account } : {}), + }; + const report = await client.invoke>("get_spending_report", { + request, + }); + if (!opts.full) { + // One row per day and per day and category: most of a long range's size. + delete report.byDay; + delete report.byDayByCategory; + } + output(cmd, report); + }, + ); program .command("budget") .description("Budget snapshot for a month (get_budget)") .option("--period ", "month as YYYY-MM (default: the server's current month)", monthArg) - .action(async (opts: { period?: string }, cmd: Command) => { - output( - cmd, - await open(cmd).invoke("get_budget", opts.period ? { periodKey: opts.period } : {}), + .option("--full", "return the server's {state, computed}, with ids, colours and every category") + .action(async (opts: { period?: string; full?: boolean }, cmd: Command) => { + const budget = await open(cmd).invoke( + "get_budget", + opts.period ? { periodKey: opts.period } : {}, ); + output(cmd, opts.full || !budget?.computed ? budget : briefBudget(budget)); }); const altAsset = program @@ -196,6 +233,105 @@ export function registerCurated(program: Command): void { ); } +interface Drift { + rows?: { color?: unknown }[]; + holdings?: { rows?: { categoryColor?: unknown }[] } | null; +} + +function withoutColours(drift: Drift): Drift { + return { + ...drift, + rows: drift.rows?.map((row) => omit(row, "color")), + ...(drift.holdings && { + holdings: { + ...drift.holdings, + rows: drift.holdings.rows?.map((row) => omit(row, "categoryColor")), + }, + }), + }; +} + +interface BudgetCategory { + categoryId: string; + name: string; + target: number; + actual: number; + rolloverIn: number; + rolloverOut: number; + remaining: number; + overspent: boolean; +} + +interface Budget { + state: unknown; + computed: { + currency: string; + periodKey: string; + fxAsOf?: string | null; + totals: Record; + groupRows: { + group: { name: string }; + plannedTotal: number; + buffer: number; + actual: number; + rolloverIn: number; + rolloverOut: number; + remaining: number; + overspent: boolean; + categories: BudgetCategory[]; + }[]; + ungroupedRows: BudgetCategory[]; + incomeRows: BudgetCategory[]; + }; +} + +/** Rollovers only when there are any. */ +function rollover(row: { rolloverIn: number; rolloverOut: number }) { + return row.rolloverIn || row.rolloverOut + ? { rolloverIn: row.rolloverIn, rolloverOut: row.rolloverOut } + : {}; +} + +/** Categories with no target, no spending and no rollover carry nothing. */ +function briefCategories(rows: BudgetCategory[]) { + return rows + .filter((c) => c.target || c.actual || c.rolloverIn || c.rolloverOut) + .map((c) => ({ + categoryId: c.categoryId, + name: c.name, + target: c.target, + actual: c.actual, + remaining: c.remaining, + overspent: c.overspent, + ...rollover(c), + })); +} + +/** + * The computed side only, without ids, colours and icons. `state` repeats the + * groups and targets that `computed` already shows, and every default category + * is listed even when nothing is budgeted or spent in it. + */ +export function briefBudget({ computed }: Budget) { + return { + periodKey: computed.periodKey, + currency: computed.currency, + totals: computed.totals, + groups: computed.groupRows.map((g) => ({ + name: g.group.name, + planned: g.plannedTotal, + ...(g.buffer ? { buffer: g.buffer } : {}), + actual: g.actual, + remaining: g.remaining, + overspent: g.overspent, + ...rollover(g), + categories: briefCategories(g.categories), + })), + ungrouped: briefCategories(computed.ungroupedRows), + income: briefCategories(computed.incomeRows), + }; +} + function listTargets(targets: AllocationTarget[]): string { return `Targets: ${targets.map((t) => `${t.name} (${t.id})`).join(", ")}`; } diff --git a/src/commands/reads.ts b/src/commands/reads.ts index 6afdbc3..6de9d03 100644 --- a/src/commands/reads.ts +++ b/src/commands/reads.ts @@ -1,32 +1,98 @@ // Read commands for the data the MCP read tools return. The REST responses // already carry every field those tools add (tracking mode, provider, asset -// id, local values, FX rate, ...), so these pass them through unchanged, -// except that holdings drop their classification tree and health its -// per-entity diagnostics unless asked (--full). +// id, local values, FX rate, ...). Holdings and health print a brief shape +// unless asked (--full): agents pay for every byte. import type { Command } from "commander"; -import { open, output } from "../context"; +import { appTimezone, open, output } from "../context"; +import { omit } from "../output"; + +type Money = { local: number; base: number } | null | undefined; interface Holding { - metadata?: unknown; - instrument?: { classifications?: unknown; notes?: unknown }; + id: string; + accountId: string; + holdingType: string; + isClosed?: boolean; + instrument?: { id: string; symbol: string; name?: string | null } | null; + quantity: number; + price?: number | null; + localCurrency: string; + baseCurrency: string; + fxRate?: number | null; + marketValue?: Money; + costBasis?: Money; + unrealizedGain?: Money; + totalGain?: Money; + totalGainPct?: number | null; + income?: Money; + dayChangePct?: number | null; + weight?: number; + sourceAccountIds?: string[]; +} + +/** Drops null and undefined values. */ +function compact(row: Record): Record { + return Object.fromEntries(Object.entries(row).filter(([, v]) => v !== null && v !== undefined)); } -// The classification tree (sectors, regions, colours) is most of a holding's size. -function slim(holding: Holding): Holding { - const copy = { ...holding, ...(holding.instrument && { instrument: { ...holding.instrument } }) }; - delete copy.metadata; - delete copy.instrument?.classifications; - delete copy.instrument?.notes; - return copy; +/** + * One flat row per holding, money in the base currency. A full holding is + * about 1 KB: ten local/base money pairs, lots, classifications and provider + * fields. Price stays in the holding's own currency, as the server sends it. + */ +export function briefHolding(h: Holding): Record { + const foreign = h.localCurrency !== h.baseCurrency; + return compact({ + id: h.id, + accountId: h.accountId, + holdingType: h.holdingType, + isClosed: h.isClosed || undefined, + assetId: h.instrument?.id, + symbol: h.instrument?.symbol, + name: h.instrument?.name, + quantity: h.quantity, + price: h.price, + currency: h.localCurrency, + baseCurrency: h.baseCurrency, + fxRate: foreign ? h.fxRate : undefined, + marketValueLocal: foreign ? h.marketValue?.local : undefined, + marketValue: h.marketValue?.base, + costBasis: h.costBasis?.base, + unrealizedGain: h.unrealizedGain?.base, + totalGain: h.totalGain?.base, + totalGainPct: h.totalGainPct, + income: h.income?.base || undefined, + dayChangePct: h.dayChangePct, + weight: h.weight, + sourceAccountIds: h.sourceAccountIds?.length ? h.sourceAccountIds : undefined, + }); } interface Health { - issues?: { diagnostics?: unknown }[]; + issues?: { + diagnostics?: unknown; + navigateAction?: unknown; + params?: unknown; + affectedItems?: { id: string; name: string; symbol?: string; route?: string }[]; + }[]; } // diagnostics repeats each issue's details and affected items, entity by entity. +// The other fields left out are for the web app: routes, translation keys and +// params, and cache keys. fixAction stays whole: execute_health_fix needs its payload. function slimHealth(health: Health): Health { - return { ...health, issues: health.issues?.map((i) => ({ ...i, diagnostics: undefined })) }; + return { + ...health, + issues: health.issues?.map((issue) => { + const { affectedItems } = issue; + return { + ...omit(issue, "diagnostics", "navigateAction", "params", "code", "dataHash", "timestamp"), + ...(affectedItems && { + affectedItems: affectedItems.map((item) => omit(item, "route")), + }), + }; + }), + }; } export function registerReads(program: Command): void { @@ -48,7 +114,7 @@ export function registerReads(program: Command): void { .option("--include-closed", "include closed positions (with --account)") .option( "--full", - "also return each instrument's classifications and notes and the holding's metadata, which are left out by default", + "return every field the server sends (local values, lots, classifications, ...), not one flat row per holding", ) .action( async (opts: { account?: string; includeClosed?: boolean; full?: boolean }, cmd: Command) => { @@ -59,7 +125,7 @@ export function registerReads(program: Command): void { filter, ...(opts.includeClosed ? { includeClosed: true } : {}), }); - output(cmd, opts.full ? holdings : holdings.map(slim)); + output(cmd, opts.full ? holdings : holdings.map(briefHolding)); }, ); @@ -67,7 +133,10 @@ export function registerReads(program: Command): void { .command("health") .description("Health Center status (get_health_status); --refresh runs the checks first") .option("--refresh", "run the full checks first (run_health_checks)") - .option("--full", "also return each issue's diagnostics, which are left out by default") + .option( + "--full", + "return every field of each issue (diagnostics, actions, routes), not only the main ones", + ) .action(async (opts: { refresh?: boolean; full?: boolean }, cmd: Command) => { const client = open(cmd); const show = (health: Health) => output(cmd, opts.full ? health : slimHealth(health)); @@ -77,8 +146,7 @@ export function registerReads(program: Command): void { } // As the MCP tool: check in the timezone configured in the app, not the // machine's. Without one, the vendored invoke sends the local timezone. - const settings = await client.invoke<{ timezone?: string }>("get_settings"); - const timezone = settings?.timezone?.trim(); + const timezone = await appTimezone(client); show( await client.invoke( "run_health_checks", diff --git a/src/context.ts b/src/context.ts index 696f8d7..6ca15e2 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,6 +1,6 @@ import type { Command } from "commander"; import { Client } from "./client"; -import { print } from "./output"; +import { pick, print } from "./output"; declare const __WF_VERSION__: string; export const VERSION = typeof __WF_VERSION__ === "string" ? __WF_VERSION__ : "0.0.0"; @@ -8,6 +8,7 @@ export const VERSION = typeof __WF_VERSION__ === "string" ? __WF_VERSION__ : "0. interface GlobalOptions { url?: string; table?: boolean; + fields?: string[]; } /** Global flags merged with the subcommand's own flags. */ @@ -19,6 +20,13 @@ export function open(cmd: Command): Client { return Client.open({ url: globals(cmd).url }); } +/** The timezone set in the app, if any (get_settings). */ +export async function appTimezone(client: Client): Promise { + const settings = await client.invoke<{ timezone?: string }>("get_settings"); + return settings?.timezone?.trim() || undefined; +} + export function output(cmd: Command, value: unknown): void { - print(value, { table: globals(cmd).table }); + const { table, fields } = globals(cmd); + print(fields ? pick(value, fields) : value, { table }); } diff --git a/src/events.ts b/src/events.ts index 54fa6a1..b90c603 100644 --- a/src/events.ts +++ b/src/events.ts @@ -47,12 +47,24 @@ export async function* parseSse(body: ReadableStream): AsyncGenerato } } -/** The skip list names an asset id and a reason per skipped asset, around 2 KB, and never changes the outcome. */ -function withoutSkipped(data: unknown): unknown { +/** + * The skip list names an asset id and a reason per skipped asset, around 2 KB, and never changes the outcome. + * Each failure's message ends with the provider's request URL, about 200 bytes that only repeat the symbol. + */ +function slimSync(data: unknown): unknown { if (typeof data !== "object" || data === null) return data; const rest: Record = { ...data }; delete rest.skipped_reasons; delete rest.show_skipped_reasons; + if (Array.isArray(rest.failed_syncs)) { + rest.failed_syncs = rest.failed_syncs.map((failure: unknown) => + Array.isArray(failure) + ? failure.map((part) => + typeof part === "string" ? part.replace(/ for url \(https?:[^)]*\)/g, "") : part, + ) + : failure, + ); + } return rest; } @@ -119,7 +131,7 @@ export async function watchPortfolio(): Promise { for await (const event of events) { switch (event.name) { case "market:sync-complete": - marketSync = withoutSkipped(event.data); + marketSync = slimSync(event.data); break; case "market:sync-error": marketError = event.data; diff --git a/src/output.ts b/src/output.ts index 519db31..ff7662c 100644 --- a/src/output.ts +++ b/src/output.ts @@ -57,3 +57,49 @@ function grid(columns: string[], rows: string[][]): string { .trimEnd(); return [line(columns), line(widths.map((w) => "-".repeat(w))), ...rows.map(line)].join("\n"); } + +/** A copy of `row` without `keys`. */ +export function omit(row: T, ...keys: string[]): T { + const copy = { ...row } as Record; + for (const key of keys) delete copy[key]; + return copy as T; +} + +function pickPaths(row: unknown, paths: string[][]): unknown { + if (row === null || typeof row !== "object" || Array.isArray(row)) return row; + const out: Record = {}; + for (const path of paths) { + let source: unknown = row; + for (const key of path) { + source = + source !== null && typeof source === "object" && Object.hasOwn(source, key) + ? (source as Record)[key] + : undefined; + } + if (source === undefined) continue; + let target = out; + for (const key of path.slice(0, -1)) { + target = (target[key] ??= {}) as Record; + } + target[path.at(-1)!] = source; + } + return out; +} + +/** + * Keeps only the dotted `fields` of each row: of each item of an array, of + * each item of `data` in a `{data, meta}` page (meta stays), or of an object. + */ +export function pick(value: unknown, fields: string[]): unknown { + const paths = fields.map((f) => f.split(".")); + if (Array.isArray(value)) return value.map((row) => pickPaths(row, paths)); + if ( + value !== null && + typeof value === "object" && + Array.isArray((value as { data?: unknown }).data) + ) { + const page = value as { data: unknown[] }; + return { ...page, data: page.data.map((row) => pickPaths(row, paths)) }; + } + return pickPaths(value, paths); +} diff --git a/src/program.ts b/src/program.ts index abc41e5..3cf51fa 100644 --- a/src/program.ts +++ b/src/program.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { Argument, Command } from "commander"; +import { Argument, Command, InvalidArgumentError } from "commander"; import { Client } from "./client"; import { globals, open, output, VERSION } from "./context"; import { registerActivities } from "./commands/activities"; @@ -57,6 +57,18 @@ export function buildProgram(): Command { .version(VERSION) .option("--url ", "server URL (overrides WF_URL and the config file)") .option("--table", "print a table instead of JSON") + .option( + "--fields ", + "print only these comma-separated fields of each row (dotted paths, such as instrument.symbol)", + (list: string) => { + const fields = list + .split(",") + .map((f) => f.trim()) + .filter(Boolean); + if (fields.length === 0) throw new InvalidArgumentError("Name at least one field."); + return fields; + }, + ) // Must come before subcommands are added: they copy it at creation. // main() maps commander errors to exit code 2. .exitOverride() diff --git a/test/integration/server.test.ts b/test/integration/server.test.ts index 8fb0613..65e1cc9 100644 --- a/test/integration/server.test.ts +++ b/test/integration/server.test.ts @@ -242,7 +242,7 @@ describe.skipIf(!URL || !PASSWORD)("against a live server", () => { queued: true, wait: { status: "complete" }, }); - expect(await wfJson("budget")).toHaveProperty("state"); + expect(await wfJson("budget")).toHaveProperty("totals"); expect( await wfJson("spending", "report", "--from", "2026-01-01", "--to", "2026-01-31"), ).toHaveProperty("current"); diff --git a/test/unit/activities.test.ts b/test/unit/activities.test.ts index 7063d87..1f74570 100644 --- a/test/unit/activities.test.ts +++ b/test/unit/activities.test.ts @@ -368,32 +368,98 @@ describe("reads and health", () => { expect(seen[1]!.path).toBe("/api/v1/holdings?accountId=a1&includeClosed=true"); }); - it("holdings drops classifications, notes and metadata unless --full", async () => { + it("holdings prints one flat row in the base currency unless --full", async () => { const holding = { id: "h1", - price: 10, + accountId: "a1", + holdingType: "security", + isClosed: false, + instrument: { id: "s1", symbol: "SAP", name: null, isin: "DE0007164600", notes: "n" }, + quantity: 5, + price: 200, + localCurrency: "EUR", + baseCurrency: "USD", + fxRate: 1.1, + marketValue: { local: 1000, base: 1100 }, + costBasis: { local: 900, base: 990 }, + unrealizedGain: { local: 100, base: 110 }, + totalGain: { local: 100, base: 110 }, + totalGainPct: 0.11, + income: { local: 0, base: 0 }, + dayChange: null, + dayChangePct: null, + lots: [{ id: "l1", quantity: 5 }], + weight: 0.5, + sourceAccountIds: [], metadata: { profile: {} }, - instrument: { symbol: "BLOK", notes: "n", classifications: { sectors: [] } }, }; mockFetch(() => json([holding])); expect((await run("holdings")).json()).toEqual([ - { id: "h1", price: 10, instrument: { symbol: "BLOK" } }, + { + id: "h1", + accountId: "a1", + holdingType: "security", + assetId: "s1", + symbol: "SAP", + quantity: 5, + price: 200, + currency: "EUR", + baseCurrency: "USD", + fxRate: 1.1, + marketValueLocal: 1000, + marketValue: 1100, + costBasis: 990, + unrealizedGain: 110, + totalGain: 110, + totalGainPct: 0.11, + weight: 0.5, + }, ]); expect((await run("holdings", "--full")).json()).toEqual([holding]); }); + it("--fields picks dotted fields from each row", async () => { + mockFetch(() => json([{ id: "h1", instrument: { symbol: "SAP", name: "SAP SE" }, weight: 1 }])); + expect( + (await run("holdings", "--full", "--fields", "instrument.symbol,weight")).json(), + ).toEqual([{ instrument: { symbol: "SAP" }, weight: 1 }]); + }); + + it("--fields refuses an empty list, and ignores inherited properties", async () => { + mockFetch(() => json([{ id: "h1" }])); + expect((await run("holdings", "--fields", ",")).code).toBe(2); + expect((await run("holdings", "--full", "--fields", "constructor.name,id")).json()).toEqual([ + { id: "h1" }, + ]); + }); + it("health reads the cached status", async () => { const seen = mockFetch(() => json({ overallSeverity: "INFO", issues: [] })); await run("health"); expect(seen.map((r) => `${r.method} ${r.path}`)).toEqual(["GET /api/v1/health/status"]); }); - it("health drops each issue's diagnostics unless --full", async () => { - const issue = { id: "i1", title: "Cash went below zero", diagnostics: [{ code: "X" }] }; + it("health drops diagnostics and the web app's routes unless --full, and keeps fixAction whole", async () => { + const issue = { + id: "i1", + title: "No market data for 1 holding", + params: { count: 1 }, + fixAction: { id: "sync_prices", label: "Sync Prices", payload: ["s1"] }, + navigateAction: { route: "/settings/market-data", label: "Review" }, + affectedItems: [{ id: "s1", name: "SAP", symbol: "SAP", route: "/holdings/s1" }], + diagnostics: [{ code: "X" }], + }; mockFetch(() => json({ overallSeverity: "WARNING", issues: [issue] })); expect((await run("health")).json()).toEqual({ overallSeverity: "WARNING", - issues: [{ id: "i1", title: "Cash went below zero" }], + issues: [ + { + id: "i1", + title: "No market data for 1 holding", + fixAction: { id: "sync_prices", label: "Sync Prices", payload: ["s1"] }, + affectedItems: [{ id: "s1", name: "SAP", symbol: "SAP" }], + }, + ], }); expect((await run("health", "--full")).json()).toEqual({ overallSeverity: "WARNING", diff --git a/test/unit/curated.test.ts b/test/unit/curated.test.ts index 2e63803..2dbd05d 100644 --- a/test/unit/curated.test.ts +++ b/test/unit/curated.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { targetFilter } from "../../src/commands/curated"; -import { today } from "../../src/commands/args"; +import { dayBoundary, today, todayIn } from "../../src/commands/args"; import { json, mockFetch, run, useTempConfig, type Recorded } from "../helpers"; useTempConfig(); @@ -94,6 +94,21 @@ describe("wf rebalance", () => { }); }); + it("drops category colours unless --full", async () => { + const drift = { + rows: [{ categoryId: "c1", color: "#fff", driftBps: 10 }], + holdings: { rows: [{ symbol: "SAP", categoryColor: "#fff" }] }, + }; + server({ "POST /api/v1/allocation-targets/t1/drift": drift }); + expect((await run("rebalance", "--holdings")).json()).toMatchObject({ + drift: { + rows: [{ categoryId: "c1", driftBps: 10 }], + holdings: { rows: [{ symbol: "SAP" }] }, + }, + }); + expect(((await run("rebalance", "--full")).json() as { drift: unknown }).drift).toEqual(drift); + }); + it("exits 2 when several targets are active and none is picked", async () => { server({ "GET /api/v1/allocation-targets": [ @@ -121,8 +136,12 @@ describe("wf rebalance", () => { }); describe("wf spending report", () => { - it("sends the request object with dates and accounts", async () => { - const seen = server(); + // The app's timezone, not the machine's, sets where each day starts. + const SETTINGS = { "GET /api/v1/settings": { timezone: "Asia/Hong_Kong" } }; + const report = (seen: Recorded[]) => seen.find((r) => r.path === "/api/v1/spending/report")!; + + it("sends whole days in the app's timezone as RFC 3339, and the accounts", async () => { + const seen = server(SETTINGS); await run( "spending", "report", @@ -133,19 +152,25 @@ describe("wf spending report", () => { "--account", "a1", ); - expect(seen[0]).toMatchObject({ + expect(report(seen)).toMatchObject({ method: "POST", - path: "/api/v1/spending/report", - body: { startDate: "2026-01-01", endDate: "2026-03-31", accountIds: ["a1"] }, + body: { + startDate: "2025-12-31T16:00:00.000Z", + endDate: "2026-03-31T15:59:59.999Z", + accountIds: ["a1"], + }, }); }); it("defaults to this month so far", async () => { vi.useFakeTimers({ now: new Date(2026, 9, 18, 12) }); try { - const seen = server(); + const seen = server(SETTINGS); await run("spending", "report"); - expect(seen[0]!.body).toEqual({ startDate: "2026-10-01", endDate: "2026-10-18" }); + expect(report(seen).body).toEqual({ + startDate: "2026-09-30T16:00:00.000Z", + endDate: "2026-10-18T15:59:59.999Z", + }); } finally { vi.useRealTimers(); } @@ -154,14 +179,32 @@ describe("wf spending report", () => { it("starts at the first day of the --to month without --from", async () => { vi.useFakeTimers({ now: new Date(2026, 9, 18, 12) }); try { - const seen = server(); + const seen = server(SETTINGS); expect((await run("spending", "report", "--to", "2026-01-31")).code).toBe(0); - expect(seen[0]!.body).toEqual({ startDate: "2026-01-01", endDate: "2026-01-31" }); + expect(report(seen).body).toEqual({ + startDate: "2025-12-31T16:00:00.000Z", + endDate: "2026-01-31T15:59:59.999Z", + }); } finally { vi.useRealTimers(); } }); + it("drops the per-day totals unless --full", async () => { + const report = { + current: { outflow: 10 }, + spendingBreakdown: [{ categoryId: "cat_food", amount: 10 }], + byDay: [{ date: "2026-01-01" }], + byDayByCategory: [{ date: "2026-01-01", categoryId: "cat_food" }], + }; + server({ "POST /api/v1/spending/report": report }); + expect((await run("spending", "report")).json()).toEqual({ + current: { outflow: 10 }, + spendingBreakdown: [{ categoryId: "cat_food", amount: 10 }], + }); + expect((await run("spending", "report", "--full")).json()).toEqual(report); + }); + it.each([ [["--from", "2026-13-40"]], [["--from", "2026-02-30"]], @@ -172,6 +215,17 @@ describe("wf spending report", () => { }); }); +describe("dayBoundary", () => { + it("handles UTC, offsets and a DST change", () => { + expect(dayBoundary("2025-11-01", "start", "UTC")).toBe("2025-11-01T00:00:00.000Z"); + expect(todayIn("Asia/Tokyo", new Date("2026-10-31T16:00:00Z"))).toBe("2026-11-01"); + expect(todayIn("America/Los_Angeles", new Date("2026-11-01T03:00:00Z"))).toBe("2026-10-31"); + expect(dayBoundary("2025-11-01", "start", "America/New_York")).toBe("2025-11-01T04:00:00.000Z"); + // London moves to BST at 01:00 UTC on 2025-03-30; the day ends at 22:59:59.999 UTC. + expect(dayBoundary("2025-03-30", "end", "Europe/London")).toBe("2025-03-30T22:59:59.999Z"); + }); +}); + describe("wf budget", () => { it("sends periodKey as a query", async () => { const seen = server(); @@ -185,6 +239,81 @@ describe("wf budget", () => { expect(seen[0]!.path).toBe("/api/v1/spending/budget"); }); + it("prints computed rows without ids, colours or empty categories unless --full", async () => { + const category = (name: string, target: number, actual: number) => ({ + taxonomyId: "spending_categories", + categoryId: `cat_${name}`, + groupId: "g1", + parentId: null, + name, + color: "#fff", + icon: "Home", + target, + actual, + rolloverIn: 0, + rolloverOut: 0, + remaining: target - actual, + overspent: actual > target, + hasDefaultTarget: false, + hasMonthOverride: false, + rolloverEnabled: false, + }); + const budget = { + state: { groups: [{ id: "g1", name: "Needs" }], groupAssignments: [], targets: [] }, + computed: { + currency: "USD", + periodKey: "2026-09", + fxAsOf: "2026-09-30", + groupRows: [ + { + group: { id: "g1", name: "Needs", color: "#fff", createdAt: "x" }, + categoryTargetTotal: 500, + buffer: 0, + plannedTotal: 500, + actual: 600, + rolloverIn: 0, + rolloverOut: 0, + remaining: -100, + overspent: true, + rolloverEnabled: false, + categories: [category("housing", 500, 600), category("bills", 0, 0)], + }, + ], + ungroupedRows: [], + incomeRows: [category("salary", 0, 0)], + totals: { spendingPlanned: 500, spendingActual: 600 }, + }, + }; + server({ "GET /api/v1/spending/budget": budget }); + expect((await run("budget")).json()).toEqual({ + periodKey: "2026-09", + currency: "USD", + totals: { spendingPlanned: 500, spendingActual: 600 }, + groups: [ + { + name: "Needs", + planned: 500, + actual: 600, + remaining: -100, + overspent: true, + categories: [ + { + categoryId: "cat_housing", + name: "housing", + target: 500, + actual: 600, + remaining: -100, + overspent: true, + }, + ], + }, + ], + ungrouped: [], + income: [], + }); + expect((await run("budget", "--full")).json()).toEqual(budget); + }); + it("rejects a bad month", async () => { server(); expect((await run("budget", "--period", "2026-9")).code).toBe(2); diff --git a/test/unit/wait.test.ts b/test/unit/wait.test.ts index c776def..d0646a6 100644 --- a/test/unit/wait.test.ts +++ b/test/unit/wait.test.ts @@ -85,18 +85,18 @@ describe("--wait", () => { }); }); - it("drops the list of skipped assets from marketSync", async () => { + it("drops the skipped assets and the provider URLs from marketSync", async () => { server([ event("portfolio:update-start"), event( "market:sync-complete", - '{"failed_syncs":[["a","boom"]],"show_skipped_reasons":false,"skipped_reasons":[["b","Inactive asset"]]}', + '{"failed_syncs":[["a","boom: error sending request for url (https://x.test/a?b=c)"]],"show_skipped_reasons":false,"skipped_reasons":[["b","Inactive asset"]]}', ), event("portfolio:update-complete"), ]); const res = await run(...createArgs, "--wait"); expect((res.json() as { wait: { marketSync: unknown } }).wait.marketSync).toEqual({ - failed_syncs: [["a", "boom"]], + failed_syncs: [["a", "boom: error sending request"]], }); });