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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist
coverage
*.log
CLAUDE.local.md
.bench/
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 48 additions & 23 deletions README.md

Large diffs are not rendered by default.

125 changes: 125 additions & 0 deletions scripts/bench-seed.sh
Original file line number Diff line number Diff line change
@@ -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
91 changes: 91 additions & 0 deletions scripts/bench.sh
Original file line number Diff line number Diff line change
@@ -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 <git-ref> # also builds <git-ref> 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. <broker> is replaced by the Brokerage account id.
COMMANDS=(
"status|status"
"accounts|accounts"
"holdings|holdings"
"holdings --account|holdings --account <broker>"
"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>/$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
17 changes: 15 additions & 2 deletions scripts/test-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand All @@ -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" \
Expand Down
42 changes: 21 additions & 21 deletions skills/wealthfolio/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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 <id> | 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
Expand All @@ -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 <id>` 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 <id>` 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 <id> --include-closed` also lists sold-out positions.

For anything without its own command:
Expand All @@ -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":"<assetId>"}`. `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
Expand Down Expand Up @@ -161,7 +161,7 @@ wf activity update <id> --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
Expand Down
Loading
Loading