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
34 changes: 25 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,17 @@ pre-commit run --all-files # run the gitleaks hook manually
warning, account-label filter resolution.
- `internal/snapshot` — schema 1.x structs, streaming age→gzip→JSON read,
version gate (`CheckSchemaVersion`: same major, minor <= supported).
- `internal/aggregate` — deals → period rows + summary. Each row and the
summary carry net P&L plus its four components (trade_profit / commission
/ swap / fee, summing to net). The summary also carries expectancy,
average and largest win/loss, and max drawdown (a deal-ordered
realised-P&L pass, not equity drawdown). Full-precision sums; rounding
happens in render only. Breakeven (net == 0) is neither win nor loss.
- `internal/aggregate` — deals → group rows + summary. `--by` chooses the
grouping: time cuts (day/week/month) emit per-account rows plus a combined
`ALL` row per period; symbol/magic cuts emit one row per symbol/magic
aggregated across accounts (no per-account or combined row, Account nil).
Each row and the summary carry net P&L plus its four components
(trade_profit / commission / swap / fee, summing to net). The summary also
carries expectancy, average and largest win/loss, and max drawdown (a
deal-ordered realised-P&L pass, not equity drawdown). `AccountsInScope`
exposes the contributing logins for the currency guard. Full-precision
sums; rounding happens in render only. Breakeven (net == 0) is neither win
nor loss.
- `internal/secrets` — keychain via zalando/go-keyring, service
`mt5-pnl-cli`, account `encryption-passphrase`.
- `internal/render` — fixed-width tables (manual writer; ANSI colour
Expand All @@ -57,21 +62,32 @@ pre-commit run --all-files # run the gitleaks hook manually
- **`--format`.** `pnl`/`accounts` take `--format table|json|csv`
(default `table`). CSV is rows-only (no summary).
- **Mixed-currency guard.** `pnl` never sums across currencies: when
accounts in scope span more than one, combined `ALL` rows and the
summary are suppressed (`n/a`/`null`/omitted) with a stderr warning.
accounts in scope span more than one, combined `ALL` rows and the summary
are suppressed (`n/a`/`null`/omitted) with a stderr warning. A
`--by symbol|magic` cut has no per-account row to fall back to, so it
**refuses** under mixed currency (stderr, exit 1) — narrow `--accounts`.
Scope is the accounts that contributed deals (`aggregate.AccountsInScope`),
not the grouped rows (symbol/magic rows carry no account).
- **`--quiet`/`-q`** silences stderr warnings (staleness, mixed-currency); errors still print.
- **`--color`** (pnl only): auto/always/never; auto needs a `*os.File` TTY and honours `NO_COLOR`/`TERM=dumb`.
- **Summary block is table/JSON only.** The two-group performance/breakdown
summary appears in `--format table` (an aligned key/value block) and
`--format json`; CSV is rows-only by design. Max drawdown is realised-P&L
drawdown over the ordered in-scope deals, deliberately distinct from
broker equity drawdown.
- **`group`/`group_by` are uniform across cuts.** Every `pnl` JSON/CSV row
carries `group` (period date, symbol, or magic) and `group_by` (the
`--by` value); the Go field is `aggregate.Row.Group`. `account` is the
login for per-account time rows and `null` for the combined time row and
for every symbol/magic row. The table labels the first column
`PERIOD`/`SYMBOL`/`MAGIC` and drops the `ACCOUNT` column for dimension
cuts.
- Dependencies are Renovate-managed; don't hand-bump pinned actions or
module versions.

## Conventions

- British/Commonwealth English in comments and docs. No hyperbole.
- British/Commonwealth English in comments and docs.
- TDD; golden files for table output (`-update` to regenerate, then eyeball
the diff).
- After changing commands, architecture or a gotcha above, update this
Expand Down
201 changes: 127 additions & 74 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ agents.
- [Quick start](#quick-start)
- [Demo](#demo)
- [Commands](#commands)
- [Notes](#notes)
- [How it works](#how-it-works)
- [Schema compatibility](#schema-compatibility)
- [Threat model](#threat-model)
Expand All @@ -37,8 +38,9 @@ agents.

- **Self-hosted.** Your trading data never touches a third-party
dashboard. The snapshot is yours; this binary reads it locally.
- **One file in, answers out.** No config file. Point it at the snapshot
once (env var or flag) and `mt5-pnl-cli pnl` just works.
- **One file in, answers out.** Point it at the snapshot once via the
`MT5_PNL_SNAPSHOT` env var or `--snapshot` flag, then run
`mt5-pnl-cli pnl`.
- **Agent- and script-friendly.** `--format json`
emits stable machine-readable output, and warnings go to stderr so they
never corrupt a pipeline. An agent like Claude Code can turn *"show me
Expand Down Expand Up @@ -108,6 +110,20 @@ Summary
Net P&L 10.00 USD
```

`--by symbol` aggregates across accounts, one row per symbol:

```
$ mt5-pnl-cli pnl --from 2026-01-01 --to 2026-12-31 --by symbol
SYMBOL P&L TRADES WINS LOSSES
EURUSD 5.00 2 1 1
XAUUSD 10.00 1 1 0

Summary
Performance
Trades 3
...
```

```
$ mt5-pnl-cli accounts
LOGIN LABEL CURRENCY BALANCE EQUITY LAST SUCCESS LAST ERROR
Expand All @@ -125,7 +141,8 @@ $ mt5-pnl-cli pnl --from 2026-01-01 --to 2026-01-31 --by month --accounts "Trend
{
"rows": [
{
"period": "2026-01-01",
"group": "2026-01-01",
"group_by": "month",
"account": 111,
"pnl": 10,
"trade_profit": 13,
Expand All @@ -139,7 +156,8 @@ $ mt5-pnl-cli pnl --from 2026-01-01 --to 2026-01-31 --by month --accounts "Trend
"gross_loss": -4
},
{
"period": "2026-01-01",
"group": "2026-01-01",
"group_by": "month",
"account": null,
"pnl": 10,
"trade_profit": 13,
Expand Down Expand Up @@ -178,80 +196,115 @@ $ mt5-pnl-cli pnl --from 2026-01-01 --to 2026-01-31 --by month --accounts "Trend

```
$ mt5-pnl-cli pnl --from 2026-01-01 --to 2026-01-31 --by month --accounts "Trend EA" --format csv
period,account_login,account_label,pnl,trade_profit,commission,swap,fee,trades,wins,losses,gross_profit,gross_loss
2026-01-01,111,Trend EA,10.00,13.00,-2.00,-1.00,0.00,3,2,1,14.00,-4.00
2026-01-01,,ALL,10.00,13.00,-2.00,-1.00,0.00,3,2,1,14.00,-4.00
group,group_by,account_login,account_label,pnl,trade_profit,commission,swap,fee,trades,wins,losses,gross_profit,gross_loss
2026-01-01,month,111,Trend EA,10.00,13.00,-2.00,-1.00,0.00,3,2,1,14.00,-4.00
2026-01-01,month,,ALL,10.00,13.00,-2.00,-1.00,0.00,3,2,1,14.00,-4.00
```

## Commands

- `pnl` — P&L over a date range.
- Range: `--last Nd|Nw|Nm|Ny` (default `30d`; months and years are
calendar-accurate) or `--from YYYY-MM-DD [--to YYYY-MM-DD]` (`--to`
defaults to today). `--last` runs from N units ago through today
inclusive, so `30d` covers 31 calendar days. Dates, `--last` and
"today" are all interpreted in **UTC**, and each deal is bucketed by
its UTC day — so from a far-east timezone (e.g., UTC+12), the UTC day can
differ from your local day near midnight.
- **Deal times and broker months.** Each deal's `time` is the value MT5
records — on most brokers the server's local clock stored as a Unix
timestamp. The CLI buckets by the UTC day/week/month of that value, so
monthly and weekly figures line up with what your broker statement
shows; there is no timezone skew to correct for.
- `--by day|week|month` (default `week`; weeks start Monday, dates are
UTC).
- `--accounts "Trend EA,Scalper EA"` filters by account label
(case-insensitive; default all).
- `--format table|json|csv` (default `table`). The table footer is a
**Summary** block in two groups — *Performance* (trades, win rate,
profit factor, expectancy, average and largest win/loss, max drawdown,
gross profit/loss) and *P&L breakdown* (trade profit, commission, swap,
fee, and the net). JSON carries the same fields per row and in the
summary; CSV is header + rows only (no summary), with the component
columns `pnl,trade_profit,commission,swap,fee` so a `--by month` export
drops straight into a spreadsheet or tax register. The summary footer
shows the account currency when all in-scope accounts share one
(e.g. `Net P&L 10.00 USD`).
- **P&L components.** Net P&L is `trade_profit + commission + swap + fee`.
Keeping the parts separate shows where a result came from — trading
versus broker costs — which the net alone hides. Many tax regimes treat
realised trade profit as income and commission/swap/fee as deductible
expenses, so `pnl --by month --format csv` gives per-account, per-month
component columns ready for a return; figures are always in the account
currency (no home-currency conversion — see Mixed currencies).
- **Max drawdown** is the largest peak-to-trough decline of the
*realised* P&L curve over the selected deals (ordered by time,
accumulated from zero), reported signed-negative. It is **not**
account-equity drawdown — it excludes deposits, open positions and
starting balance, so it will not match a broker's equity-drawdown
figure.
- `--color auto|always|never` (default `auto`): colourise P&L cells and
the summary total by sign (green for profit, red for loss). `auto` enables
colour only when writing to an interactive terminal and honours the
`NO_COLOR` and `TERM=dumb` environment conventions; output is never
coloured when piped or redirected. `always` forces ANSI codes regardless;
`never` disables them unconditionally. On Windows the terminal must already
have virtual-terminal processing enabled (Windows Terminal does; older
`cmd.exe` may not).
- **Mixed currencies.** If the accounts in scope span more than one
currency, combined `ALL` rows and the summary are suppressed (`n/a` in
tables, `null` in JSON, omitted from CSV) and a warning goes to
stderr — the tool never silently sums across currencies. Narrow
`--accounts` to one currency for combined totals.
- `accounts` — balances, equity and freshness per account, plus the
snapshot's `generated_at`.
- `--format table|json|csv` (default `table`).
- `set-passphrase` — store the snapshot decryption passphrase in the OS
keychain (macOS Keychain / Windows Credential Manager / Linux Secret
Service). Prompted twice, never echoed.
- `version` — binary version and supported snapshot schema (also
available as `mt5-pnl-cli --version`).

Both query commands accept `--snapshot PATH` (overrides
`MT5_PNL_SNAPSHOT`) and `--stale-after` (default `2h`) — when the
snapshot is older than that, a warning goes to **stderr**, never stdout,
so machine-output pipelines stay clean. Pass `--quiet` (`-q`) to silence
warnings for scripted use; errors still print.
### `pnl` — P&L over a date range

- `--last Nd|Nw|Nm|Ny` (default `30d`)<br>
Range ending today, inclusive — N units ago through today, so `30d`
covers 31 calendar days. Months and years are calendar-accurate.
- `--from YYYY-MM-DD [--to YYYY-MM-DD]`<br>
Explicit range; `--to` defaults to today. Use instead of `--last`.
- `--by day|week|month|symbol|magic` (default `week`, weeks start Monday)<br>
Time cuts (`day`/`week`/`month`) group per period and account, with a
combined `ALL` row per period. `symbol`/`magic` aggregate across all
in-scope accounts — one row per symbol or magic number, no per-account
or `ALL` row, first column `SYMBOL`/`MAGIC`. (`magic` is the raw MT5
magic number, commonly one per strategy/EA.) See
[Mixed currencies](#mixed-currencies).
- `--accounts "Trend EA,Scalper EA"` (default all)<br>
Filter by account label (case-insensitive).
- `--format table|json|csv` (default `table`)<br>
`table` prints rows plus a Summary block; `json` carries the same
fields per row and in the summary; `csv` is header + rows only (no
summary). See [Output shape](#output-shape).
- `--color auto|always|never` (default `auto`)<br>
Colour P&L cells and the summary total by sign. `auto` colours only on
an interactive terminal and honours `NO_COLOR`/`TERM=dumb`; output is
never coloured when piped or redirected. `always` forces ANSI codes;
`never` disables them. On Windows the terminal must have
virtual-terminal processing enabled (Windows Terminal does; older
`cmd.exe` may not).

Dates, `--last` and "today" are all interpreted in UTC.

### `accounts` — balances, equity and freshness

Per-account balances, equity and freshness, plus the snapshot's
`generated_at`. Takes `--format table|json|csv` (default `table`).

### `set-passphrase`

Store the snapshot decryption passphrase in the OS keychain (macOS
Keychain / Windows Credential Manager / Linux Secret Service). Prompted
twice, never echoed.

### `version`

Binary version and supported snapshot schema. Also available as
`mt5-pnl-cli --version`.

Both query commands (`pnl`, `accounts`) also accept `--snapshot PATH`
(overrides `MT5_PNL_SNAPSHOT`) and `--stale-after` (default `2h`) — when
the snapshot is older, a warning goes to stderr, never stdout, so
machine-output pipelines stay clean. `--quiet` (`-q`) silences warnings;
errors still print.

## Notes

### Dates and bucketing

Each deal's `time` is the value MT5 records — on most brokers the
server's local clock stored as a Unix timestamp. The CLI buckets by the
UTC day/week/month of that value, so weekly and monthly figures line up
with what your broker statement shows; there is no timezone skew to
correct for. One edge case: from a far-east timezone (e.g. UTC+12) the
UTC day can differ from your local day near midnight.

### P&L components

Net P&L is `trade_profit + commission + swap + fee`, using MT5's native
signs where commission, swap and fees are already negative for costs.
Keeping the parts separate shows where a result came from — trading
versus broker costs — which the net alone hides. Many tax regimes treat
realised trade profit as income and commission/swap/fee as deductible
expenses, so `pnl --by month --format csv` gives per-account, per-month
component columns ready for a return. Figures are always in the account
currency (no home-currency conversion — see
[Mixed currencies](#mixed-currencies)).

### Max drawdown

The largest peak-to-trough decline of the *realised* P&L curve over the
selected deals (ordered by time, accumulated from zero), reported
signed-negative. It is **not** account-equity drawdown — it excludes
deposits, open positions and starting balance, so it will not match a
broker's equity-drawdown figure.

### Mixed currencies

The CLI never silently sums across currencies. If the accounts in scope
span more than one, combined `ALL` rows and the summary are suppressed
(`n/a` in tables, `null` in JSON, omitted from CSV) and a warning goes to
stderr. A `symbol`/`magic` cut aggregates across accounts and has no
per-account row to fall back on, so it refuses outright (exit 1). Narrow
`--accounts` to one currency for combined totals.

### Output shape

Every `--by` cut emits the same JSON/CSV shape: rows carry `group` (the
period date, symbol, or magic) and `group_by` (the `--by` value);
`account` is the login for per-account time rows and `null` (JSON) or
empty (CSV) for the combined time row and for every symbol/magic row. CSV
component columns are `pnl,trade_profit,commission,swap,fee`, so a
`--by month` export drops straight into a spreadsheet or tax register.
The table summary footer shows the account currency when all in-scope
accounts share one (e.g. `Net P&L 10.00 USD`).

## How it works

Expand Down
49 changes: 48 additions & 1 deletion cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,53 @@ const mixedFixtureJSON = `{
"cash_flows": []
}`

func mixedFixture(t *testing.T) string {
t.Helper()
return snaptest.Write(t, mixedFixtureJSON, "test-pass")
}

func TestPnLBySymbol(t *testing.T) {
path := fixture(t)
out, errOut, code := runCLI(t, "test-pass",
"pnl", "--snapshot", path, "--from", "2026-01-01", "--to", "2026-01-31",
"--by", "symbol", "--stale-after", "876000h")
if code != 0 {
t.Fatalf("exit %d, stderr: %s", code, errOut)
}
if !strings.Contains(out, "SYMBOL") || !strings.Contains(out, "EURUSD") {
t.Errorf("by-symbol output should list symbols:\n%s", out)
}
if strings.Contains(out, "ACCOUNT") {
t.Errorf("by-symbol output should not have an ACCOUNT column:\n%s", out)
}
}

func TestPnLByMagicJSONGroupBy(t *testing.T) {
path := fixture(t)
out, _, code := runCLI(t, "test-pass",
"pnl", "--snapshot", path, "--from", "2026-01-01", "--to", "2026-01-31",
"--by", "magic", "--format", "json", "--stale-after", "876000h")
if code != 0 {
t.Fatalf("exit %d", code)
}
if !strings.Contains(out, `"group_by": "magic"`) {
t.Errorf("by-magic JSON should carry group_by magic:\n%s", out)
}
}

func TestPnLBySymbolMixedCurrencyRefuses(t *testing.T) {
path := mixedFixture(t)
_, errOut, code := runCLI(t, "test-pass",
"pnl", "--snapshot", path, "--from", "2026-01-01", "--to", "2026-01-31",
"--by", "symbol", "--stale-after", "876000h")
if code != 1 {
t.Fatalf("exit %d, want 1; stderr: %s", code, errOut)
}
if !strings.Contains(errOut, "narrow --accounts") {
t.Errorf("refusal should guide narrowing accounts:\n%s", errOut)
}
}

func TestPnLFormatCSV(t *testing.T) {
path := fixture(t)
out, _, code := runCLI(t, "test-pass",
Expand All @@ -308,7 +355,7 @@ func TestPnLFormatCSV(t *testing.T) {
if code != 0 {
t.Fatalf("exit %d", code)
}
if !strings.HasPrefix(out, "period,account_login,account_label,pnl,") {
if !strings.HasPrefix(out, "group,group_by,account_login,account_label,pnl,") {
t.Errorf("want CSV header first, got:\n%s", out)
}
}
Expand Down
Loading
Loading