diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b35528e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Golden files are compared byte-for-byte in tests; never EOL-convert them. +*.golden -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c288436 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - name: gofmt + run: | + out=$(gofmt -l .) + if [ -n "$out" ]; then echo "gofmt needed:"; echo "$out"; exit 1; fi + - run: go vet ./... + - uses: golangci/golangci-lint-action@v8 + with: + version: latest + + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - run: go test -race ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ec5f29d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version: stable + - uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cd8e11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/mt5-pnl-cli +/mt5-pnl-cli.exe +/dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c5bfe10 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,17 @@ +# golangci-lint v2 config. Default linters, with one exclusion: fmt.Fprint* +# writes to stderr and in-memory tabwriters are diagnostics whose errors are +# not actionable; the meaningful error paths (tabwriter Flush, the final +# summary write in render) return errors and are checked. +version: "2" +linters: + settings: + errcheck: + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + # Deferred closes on the read-only snapshot path: a Close error + # cannot lose data we have already decoded, and truncation surfaces + # through the JSON decoder. + - (*os.File).Close + - (*compress/gzip.Reader).Close diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..fb29dff --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,24 @@ +version: 2 +project_name: mt5-pnl-cli + +builds: + - main: . + binary: mt5-pnl-cli + env: + - CGO_ENABLED=0 + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + ldflags: + - -s -w -X main.version={{.Version}} + +archives: + - formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: checksums.txt + +changelog: + use: github diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0d3ddc5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,58 @@ +# CLAUDE.md + +Go CLI that reads the encrypted snapshot written by mt5-pnl-exporter +(`snapshot.json.gz.age`: JSON → gzip → age scrypt) and prints P&L/account +tables or JSON. Spec: `docs/superpowers/specs/2026-06-13-mt5-pnl-cli-v1-design.md`. + +## Commands + +```bash +go test ./... # all tests (race-enabled in CI) +go test ./internal/render -update # regenerate golden files after render changes +go build -o mt5-pnl-cli . +go run github.com/goreleaser/goreleaser/v2@latest check # validate .goreleaser.yaml +``` + +## Architecture + +- `main.go` — `run(args, stdout, stderr, getPassphrase)` is the testable + entry point; `main()` wires `os` streams + `secrets.Get`. One + `flag.FlagSet` per subcommand; `main` is the only caller of `os.Exit`. +- `args.go` / `cmd_common.go` — range parsing (`--last`, `--from/--to`), + snapshot path resolution (flag > `MT5_PNL_SNAPSHOT` > error), staleness + 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. 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` — tabwriter tables + JSON; all display rounding here. +- `internal/snaptest` — test-only fixture builder (encrypts JSON the way + the exporter does; low scrypt work factor for speed). + +## Gotchas + +- **No config file, by design.** Snapshot path via flag/env; everything + else is flags. Don't add a config file without revisiting the spec. +- **The passphrase has no env var or flag, deliberately** (see spec + Security section). Don't add one. Tests inject `getPassphrase`; + cross-process tests can't reach the keychain, so the binary smoke test + only covers pre-keychain failure paths. +- **CI runs on ubuntu/macos/windows** — keep paths `filepath`-safe and + don't add tests that need a real keychain (`keyring.MockInit()` only). +- **Schema bumps:** when the exporter ships a new minor, update + `SupportedMinor`, re-vendor `schema/snapshot.schema.json` from that + release, and add fields to the structs (additive only). +- **Deal times are Unix seconds bucketed in UTC**; weeks start Monday. +- Dependencies are Renovate-managed; don't hand-bump pinned actions or + module versions. + +## Conventions + +- NZ English in comments and docs. No hyperbole. +- TDD; golden files for table output (`-update` to regenerate, then eyeball + the diff). +- After changing commands, architecture or a gotcha above, update this + file and README.md in the same change. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51123ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tane Morgan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..421a585 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# mt5-pnl-cli + +Query MT5 P&L from an encrypted [mt5-pnl-exporter](https://github.com/tanem/mt5-pnl-exporter) +snapshot. Single static binary; reads `snapshot.json.gz.age` (age decrypt → +gunzip → JSON), aggregates locally, prints tables or JSON. No daemon, no +database, no third-party service. + +## Install + +Download a binary from [Releases](https://github.com/tanem/mt5-pnl-cli/releases), +or with Go: + +```sh +go install github.com/tanem/mt5-pnl-cli@latest +``` + +## Quick start + +```sh +# once: store the snapshot passphrase in the OS keychain +mt5-pnl-cli set-passphrase + +# once: tell the CLI where the snapshot lives (or pass --snapshot per call) +export MT5_PNL_SNAPSHOT=~/snapshots/mt5.json.gz.age + +mt5-pnl-cli pnl # last 30 days, by week +mt5-pnl-cli pnl --last 6m --by month +mt5-pnl-cli pnl --from 2026-01-01 --to 2026-03-31 --accounts "Trend EA" --json +mt5-pnl-cli accounts +``` + +## Commands + +- `pnl` — P&L over a date range. `--last Nd|Nw|Nm|Ny` (default `30d`, + calendar-accurate) or `--from`/`--to`; `--by day|week|month` (default + `week`); `--accounts "A,B"` filters by label; `--json` for machine + output. +- `accounts` — balances, equity and freshness per account. +- `set-passphrase` — store the snapshot decryption passphrase in the OS + keychain (macOS Keychain / Windows Credential Manager / Linux Secret + Service). +- `version` — binary version and supported snapshot schema. + +A staleness warning goes to stderr when the snapshot is older than +`--stale-after` (default `2h`). + +## Security + +The decryption passphrase lives only in the OS keychain — there is no env +var or flag for it, deliberately (env vars leak via dotfiles, shell +history and child processes; scripts and agents don't need one because the +binary reads the keychain itself). `MT5_PNL_SNAPSHOT` carries only a file +path. The trust boundary is your OS user session, the same as the +exporter's; see its +[threat model](https://github.com/tanem/mt5-pnl-exporter#threat-model). + +## Schema compatibility + +The snapshot schema is vendored from the exporter release this build +supports (`schema/snapshot.schema.json`). The CLI accepts the same major +version and any minor at or below its own, and refuses anything else with +a message naming both versions. + +## Licence + +[MIT](LICENSE) diff --git a/args.go b/args.go new file mode 100644 index 0000000..edc8bd8 --- /dev/null +++ b/args.go @@ -0,0 +1,71 @@ +package main + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +var lastRe = regexp.MustCompile(`^(\d+)([dwmy])$`) + +func civilDate(t time.Time) time.Time { + u := t.UTC() + return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC) +} + +// parseLast turns "30d" / "2w" / "6m" / "1y" into an inclusive (from, to) +// date pair ending today. Months and years are calendar-accurate (AddDate). +func parseLast(s string, now time.Time) (time.Time, time.Time, error) { + m := lastRe.FindStringSubmatch(strings.ToLower(strings.TrimSpace(s))) + if m == nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid --last %q: use e.g. 30d, 2w, 6m, 1y", s) + } + n, _ := strconv.Atoi(m[1]) + today := civilDate(now) + var from time.Time + switch m[2] { + case "d": + from = today.AddDate(0, 0, -n) + case "w": + from = today.AddDate(0, 0, -7*n) + case "m": + from = today.AddDate(0, -n, 0) + case "y": + from = today.AddDate(-n, 0, 0) + } + return from, today, nil +} + +func resolveRange(last, from, to string, now time.Time) (time.Time, time.Time, error) { + var zero time.Time + if last != "" && (from != "" || to != "") { + return zero, zero, errors.New("--last cannot be combined with --from/--to") + } + if to != "" && from == "" { + return zero, zero, errors.New("--to requires --from") + } + if from != "" { + f, err := time.ParseInLocation("2006-01-02", from, time.UTC) + if err != nil { + return zero, zero, fmt.Errorf("invalid --from %q: use YYYY-MM-DD", from) + } + t := civilDate(now) + if to != "" { + t, err = time.ParseInLocation("2006-01-02", to, time.UTC) + if err != nil { + return zero, zero, fmt.Errorf("invalid --to %q: use YYYY-MM-DD", to) + } + } + if t.Before(f) { + return zero, zero, errors.New("--to is before --from") + } + return f, t, nil + } + if last == "" { + last = "30d" + } + return parseLast(last, now) +} diff --git a/args_test.go b/args_test.go new file mode 100644 index 0000000..45f5abf --- /dev/null +++ b/args_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "strings" + "testing" + "time" + + "github.com/tanem/mt5-pnl-cli/internal/snapshot" +) + +var now = time.Date(2026, 6, 13, 10, 30, 0, 0, time.UTC) + +func d(y int, m time.Month, day int) time.Time { + return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) +} + +func TestParseLast(t *testing.T) { + cases := []struct { + in string + from, to time.Time + }{ + {"30d", d(2026, 5, 14), d(2026, 6, 13)}, + {"2w", d(2026, 5, 30), d(2026, 6, 13)}, + {"6m", d(2025, 12, 13), d(2026, 6, 13)}, // calendar-accurate, not 180 days + {"1y", d(2025, 6, 13), d(2026, 6, 13)}, + } + for _, c := range cases { + from, to, err := parseLast(c.in, now) + if err != nil { + t.Errorf("parseLast(%q): %v", c.in, err) + continue + } + if !from.Equal(c.from) || !to.Equal(c.to) { + t.Errorf("parseLast(%q) = %v..%v, want %v..%v", c.in, from, to, c.from, c.to) + } + } + for _, bad := range []string{"", "30", "d30", "30x", "-5d"} { + if _, _, err := parseLast(bad, now); err == nil { + t.Errorf("parseLast(%q): want error", bad) + } + } +} + +func TestResolveRange(t *testing.T) { + // default when nothing given: --last 30d + from, to, err := resolveRange("", "", "", now) + if err != nil || !from.Equal(d(2026, 5, 14)) || !to.Equal(d(2026, 6, 13)) { + t.Errorf("default = %v..%v (%v), want 30d window", from, to, err) + } + // --from alone runs to today + from, to, err = resolveRange("", "2026-01-01", "", now) + if err != nil || !from.Equal(d(2026, 1, 1)) || !to.Equal(d(2026, 6, 13)) { + t.Errorf("from-only = %v..%v (%v)", from, to, err) + } + // explicit range + from, to, err = resolveRange("", "2026-01-01", "2026-03-31", now) + if err != nil || !from.Equal(d(2026, 1, 1)) || !to.Equal(d(2026, 3, 31)) { + t.Errorf("explicit = %v..%v (%v)", from, to, err) + } + // errors + for _, c := range [][3]string{ + {"30d", "2026-01-01", ""}, // --last with --from + {"", "", "2026-03-31"}, // --to without --from + {"", "2026-03-31", "2026-01-01"}, // to before from + {"", "not-a-date", ""}, + } { + if _, _, err := resolveRange(c[0], c[1], c[2], now); err == nil { + t.Errorf("resolveRange(%q,%q,%q): want error", c[0], c[1], c[2]) + } + } +} + +func TestResolveSnapshotPath(t *testing.T) { + env := func(vars map[string]string) func(string) string { + return func(k string) string { return vars[k] } + } + if p, err := resolveSnapshotPath("/flag/path", env(map[string]string{"MT5_PNL_SNAPSHOT": "/env/path"})); err != nil || p != "/flag/path" { + t.Errorf("flag should win: %q %v", p, err) + } + if p, err := resolveSnapshotPath("", env(map[string]string{"MT5_PNL_SNAPSHOT": "/env/path"})); err != nil || p != "/env/path" { + t.Errorf("env fallback: %q %v", p, err) + } + if _, err := resolveSnapshotPath("", env(nil)); err == nil || !strings.Contains(err.Error(), "MT5_PNL_SNAPSHOT") { + t.Errorf("missing both: %v, want guidance naming the env var", err) + } +} + +func TestWarnIfStale(t *testing.T) { + var buf bytes.Buffer + warnIfStale(&buf, "2026-06-13T00:00:00Z", 2*time.Hour, now) // 10.5h old + if !strings.Contains(buf.String(), "stale") && !strings.Contains(buf.String(), "old") { + t.Errorf("want staleness warning, got %q", buf.String()) + } + buf.Reset() + warnIfStale(&buf, "2026-06-13T10:00:00Z", 2*time.Hour, now) // 0.5h old + if buf.Len() != 0 { + t.Errorf("want no warning, got %q", buf.String()) + } + buf.Reset() + warnIfStale(&buf, "garbage", 2*time.Hour, now) + if !strings.Contains(buf.String(), "staleness unknown") { + t.Errorf("want unparseable-timestamp warning, got %q", buf.String()) + } +} + +func TestResolveAccounts(t *testing.T) { + accts := []snapshot.AccountSnapshot{ + {Login: 111, Label: "Trend EA"}, + {Login: 222, Label: "Scalper EA"}, + } + if got, err := resolveAccounts("", accts); err != nil || got != nil { + t.Errorf("empty spec = %v, %v; want nil, nil", got, err) + } + got, err := resolveAccounts("trend ea, Scalper EA", accts) + if err != nil || !got[111] || !got[222] || len(got) != 2 { + t.Errorf("case-insensitive resolve = %v, %v", got, err) + } + _, err = resolveAccounts("Nope", accts) + if err == nil || !strings.Contains(err.Error(), "Trend EA") { + t.Errorf("unknown label error should list valid labels: %v", err) + } +} diff --git a/cli_test.go b/cli_test.go new file mode 100644 index 0000000..77fc5f6 --- /dev/null +++ b/cli_test.go @@ -0,0 +1,223 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/tanem/mt5-pnl-cli/internal/secrets" + "github.com/tanem/mt5-pnl-cli/internal/snaptest" +) + +// Two accounts, four deals (same data as the aggregate tests; week of +// 2026-01-05 plus one deal the following week). +const fixtureJSON = `{ + "schema_version": "1.0", + "generated_at": "2026-06-13T00:00:00Z", + "accounts": [ + {"login": 111, "label": "Trend EA", "currency": "USD", "balance": 1000.0, + "equity": 1010.5, "last_success_at": "2026-06-13T00:00:00Z", "last_error": null}, + {"login": 222, "label": "Scalper EA", "currency": "USD", "balance": 500.0, + "equity": 500.0, "last_success_at": null, "last_error": "login failed"} + ], + "closed_deals": [ + {"account": 111, "time": 1767607200, "profit": 10.0, "swap": -0.5, "commission": -0.5, "fee": 0.0, + "ticket": 1, "order": 1, "position_id": 1, "time_msc": 0, "type": 0, "entry": 1, "reason": 0, + "magic": 0, "volume": 0.1, "price": 1.0, "symbol": "EURUSD", "comment": "", "external_id": ""}, + {"account": 111, "time": 1767693600, "profit": -4.0, "swap": 0.0, "commission": 0.0, "fee": 0.0, + "ticket": 2, "order": 2, "position_id": 2, "time_msc": 0, "type": 1, "entry": 1, "reason": 0, + "magic": 0, "volume": 0.1, "price": 1.0, "symbol": "EURUSD", "comment": "", "external_id": ""}, + {"account": 222, "time": 1767693600, "profit": 0.7, "swap": 0.0, "commission": -0.7, "fee": 0.0, + "ticket": 3, "order": 3, "position_id": 3, "time_msc": 0, "type": 0, "entry": 1, "reason": 0, + "magic": 0, "volume": 0.1, "price": 1.0, "symbol": "XAUUSD", "comment": "", "external_id": ""}, + {"account": 111, "time": 1768212000, "profit": 5.0, "swap": 0.0, "commission": 0.0, "fee": 0.0, + "ticket": 4, "order": 4, "position_id": 4, "time_msc": 0, "type": 0, "entry": 1, "reason": 0, + "magic": 0, "volume": 0.1, "price": 1.0, "symbol": "EURUSD", "comment": "", "external_id": ""} + ], + "open_positions": [], + "cash_flows": [] +}` + +func runCLI(t *testing.T, passphrase string, args ...string) (stdout, stderr string, code int) { + t.Helper() + var out, errBuf bytes.Buffer + code = run(args, &out, &errBuf, func() (string, error) { return passphrase, nil }) + return out.String(), errBuf.String(), code +} + +func fixture(t *testing.T) string { + t.Helper() + return snaptest.Write(t, fixtureJSON, "test-pass") +} + +func TestPnLTableCommand(t *testing.T) { + path := fixture(t) + out, errOut, code := runCLI(t, "test-pass", + "pnl", "--snapshot", path, "--from", "2026-01-01", "--to", "2026-01-31", + "--stale-after", "876000h") + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, errOut) + } + for _, want := range []string{"Trend EA", "Scalper EA", "ALL", "2026-01-05", "2026-01-12", "Total P&L: 10.00"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q:\n%s", want, out) + } + } + if errOut != "" { + t.Errorf("expected silent stderr with huge --stale-after, got %q", errOut) + } +} + +func TestPnLJSONCommand(t *testing.T) { + path := fixture(t) + out, _, code := runCLI(t, "test-pass", + "pnl", "--snapshot", path, "--from", "2026-01-01", "--to", "2026-01-31", "--json") + if code != 0 { + t.Fatalf("exit %d", code) + } + var got struct { + Rows []map[string]any `json:"rows"` + Summary struct { + TotalPnL float64 `json:"total_pnl"` + TotalTrades int `json:"total_trades"` + } `json:"summary"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out) + } + if got.Summary.TotalPnL != 10.0 || got.Summary.TotalTrades != 4 { + t.Errorf("summary = %+v", got.Summary) + } +} + +func TestPnLAccountsFilter(t *testing.T) { + path := fixture(t) + out, _, code := runCLI(t, "test-pass", + "pnl", "--snapshot", path, "--from", "2026-01-01", "--to", "2026-01-31", + "--accounts", "scalper ea") + if code != 0 { + t.Fatalf("exit %d", code) + } + if strings.Contains(out, "Trend EA") { + t.Errorf("filter leaked other account:\n%s", out) + } +} + +func TestPnLUnknownAccountLabel(t *testing.T) { + path := fixture(t) + _, errOut, code := runCLI(t, "test-pass", + "pnl", "--snapshot", path, "--accounts", "Nope") + if code != 1 || !strings.Contains(errOut, "Trend EA") { + t.Errorf("exit %d, stderr %q; want 1 + valid labels listed", code, errOut) + } +} + +func TestPnLStalenessWarning(t *testing.T) { + path := fixture(t) + // --stale-after 1ns makes any snapshot stale, so the test never depends + // on the wall clock's distance from the fixture's generated_at. + _, errOut, code := runCLI(t, "test-pass", + "pnl", "--snapshot", path, "--from", "2026-01-01", "--to", "2026-01-31", + "--stale-after", "1ns") + if code != 0 { + t.Fatalf("exit %d", code) + } + if !strings.Contains(errOut, "mt5-pnl-exporter export") { + t.Errorf("want staleness warning on stderr, got %q", errOut) + } +} + +func TestPnLInvalidBy(t *testing.T) { + _, errOut, code := runCLI(t, "test-pass", "pnl", "--by", "fortnight") + if code != 1 || !strings.Contains(errOut, "--by") { + t.Errorf("exit %d, stderr %q", code, errOut) + } +} + +func TestPnLWrongPassphrase(t *testing.T) { + path := fixture(t) + _, errOut, code := runCLI(t, "wrong", "pnl", "--snapshot", path) + if code != 1 || !strings.Contains(errOut, "wrong passphrase") { + t.Errorf("exit %d, stderr %q", code, errOut) + } +} + +func TestPnLPassphraseMissing(t *testing.T) { + path := fixture(t) + var out, errBuf bytes.Buffer + code := run([]string{"pnl", "--snapshot", path}, &out, &errBuf, + func() (string, error) { return "", secrets.ErrNotFound }) + if code != 1 || !strings.Contains(errBuf.String(), "set-passphrase") { + t.Errorf("exit %d, stderr %q", code, errBuf.String()) + } +} + +func TestPnLNoSnapshotPath(t *testing.T) { + t.Setenv("MT5_PNL_SNAPSHOT", "") + _, errOut, code := runCLI(t, "test-pass", "pnl") + if code != 1 || !strings.Contains(errOut, "MT5_PNL_SNAPSHOT") { + t.Errorf("exit %d, stderr %q", code, errOut) + } +} + +func TestPnLEnvFallback(t *testing.T) { + path := fixture(t) + t.Setenv("MT5_PNL_SNAPSHOT", path) + _, errOut, code := runCLI(t, "test-pass", + "pnl", "--from", "2026-01-01", "--to", "2026-01-31", "--stale-after", "876000h") + if code != 0 { + t.Fatalf("exit %d, stderr %q", code, errOut) + } +} + +func TestUnsupportedSchema(t *testing.T) { + body := strings.Replace(fixtureJSON, `"schema_version": "1.0"`, `"schema_version": "2.0"`, 1) + path := snaptest.Write(t, body, "test-pass") + _, errOut, code := runCLI(t, "test-pass", "pnl", "--snapshot", path) + if code != 1 || !strings.Contains(errOut, "unsupported snapshot schema") { + t.Errorf("exit %d, stderr %q", code, errOut) + } +} + +func TestAccountsCommand(t *testing.T) { + path := fixture(t) + out, _, code := runCLI(t, "test-pass", + "accounts", "--snapshot", path, "--stale-after", "876000h") + if code != 0 { + t.Fatalf("exit %d", code) + } + for _, want := range []string{"111", "Trend EA", "login failed", "Snapshot generated: 2026-06-13T00:00:00Z"} { + if !strings.Contains(out, want) { + t.Errorf("stdout missing %q:\n%s", want, out) + } + } +} + +func TestAccountsMissingSnapshotFile(t *testing.T) { + _, errOut, code := runCLI(t, "test-pass", "accounts", "--snapshot", "/nonexistent/snap.age") + if code != 1 || errOut == "" { + t.Errorf("exit %d, stderr %q; want 1 + error", code, errOut) + } +} + +func TestVersionCommand(t *testing.T) { + out, _, code := runCLI(t, "", "version") + if code != 0 || !strings.Contains(out, "mt5-pnl-cli") || !strings.Contains(out, "schema 1.0") { + t.Errorf("exit %d, out %q", code, out) + } +} + +func TestUnknownCommand(t *testing.T) { + _, errOut, code := runCLI(t, "", "bogus") + if code != 1 || !strings.Contains(errOut, "Usage") { + t.Errorf("exit %d, stderr %q", code, errOut) + } +} + +func TestNoCommand(t *testing.T) { + _, errOut, code := runCLI(t, "") + if code != 1 || !strings.Contains(errOut, "Usage") { + t.Errorf("exit %d, stderr %q", code, errOut) + } +} diff --git a/cmd_accounts.go b/cmd_accounts.go new file mode 100644 index 0000000..200a1ea --- /dev/null +++ b/cmd_accounts.go @@ -0,0 +1,38 @@ +package main + +import ( + "flag" + "fmt" + "io" + "time" + + "github.com/tanem/mt5-pnl-cli/internal/render" +) + +func cmdAccounts(args []string, stdout, stderr io.Writer, getPassphrase func() (string, error)) int { + fs := flag.NewFlagSet("accounts", flag.ContinueOnError) + fs.SetOutput(stderr) + asJSON := fs.Bool("json", false, "emit JSON instead of a table") + snapFlag := fs.String("snapshot", "", "snapshot path (default: $MT5_PNL_SNAPSHOT)") + staleAfter := fs.Duration("stale-after", 2*time.Hour, "staleness warning threshold") + if err := fs.Parse(args); err != nil { + return 1 + } + + snap, err := loadSnapshot(*snapFlag, *staleAfter, stderr, getPassphrase) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + + if *asJSON { + err = render.AccountsJSON(stdout, snap.Accounts) + } else { + err = render.AccountsTable(stdout, snap.Accounts, snap.GeneratedAt) + } + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + return 0 +} diff --git a/cmd_common.go b/cmd_common.go new file mode 100644 index 0000000..77b9723 --- /dev/null +++ b/cmd_common.go @@ -0,0 +1,86 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/tanem/mt5-pnl-cli/internal/snapshot" +) + +func resolveSnapshotPath(flagVal string, getenv func(string) string) (string, error) { + p := flagVal + if p == "" { + p = getenv("MT5_PNL_SNAPSHOT") + } + if p == "" { + return "", errors.New("no snapshot path: pass --snapshot or set MT5_PNL_SNAPSHOT") + } + return expandTilde(p) +} + +func expandTilde(p string) (string, error) { + if p == "~" || strings.HasPrefix(p, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, p[1:]), nil + } + return p, nil +} + +func warnIfStale(w io.Writer, generatedAt string, threshold time.Duration, now time.Time) { + ts, err := time.Parse(time.RFC3339, generatedAt) + if err != nil { + fmt.Fprintln(w, "warning: could not parse snapshot timestamp; staleness unknown") + return + } + if age := now.Sub(ts).Abs(); age > threshold { + fmt.Fprintf(w, "warning: snapshot is %.1fh old (threshold %s); run 'mt5-pnl-exporter export' on the host\n", + age.Hours(), threshold) + } +} + +func resolveAccounts(spec string, accounts []snapshot.AccountSnapshot) (map[int64]bool, error) { + if strings.TrimSpace(spec) == "" { + return nil, nil + } + byLabel := make(map[string]int64, len(accounts)) + labels := make([]string, 0, len(accounts)) + for _, a := range accounts { + byLabel[strings.ToLower(a.Label)] = a.Login + labels = append(labels, a.Label) + } + out := map[int64]bool{} + for _, raw := range strings.Split(spec, ",") { + name := strings.TrimSpace(raw) + login, ok := byLabel[strings.ToLower(name)] + if !ok { + return nil, fmt.Errorf("unknown account label %q; valid labels: %s", name, strings.Join(labels, ", ")) + } + out[login] = true + } + return out, nil +} + +func loadSnapshot(pathFlag string, staleAfter time.Duration, stderr io.Writer, getPassphrase func() (string, error)) (*snapshot.Snapshot, error) { + path, err := resolveSnapshotPath(pathFlag, os.Getenv) + if err != nil { + return nil, err + } + pass, err := getPassphrase() + if err != nil { + return nil, err + } + snap, err := snapshot.Read(path, pass) + if err != nil { + return nil, err + } + warnIfStale(stderr, snap.GeneratedAt, staleAfter, time.Now()) + return snap, nil +} diff --git a/cmd_pnl.go b/cmd_pnl.go new file mode 100644 index 0000000..b1c7861 --- /dev/null +++ b/cmd_pnl.go @@ -0,0 +1,69 @@ +package main + +import ( + "flag" + "fmt" + "io" + "time" + + "github.com/tanem/mt5-pnl-cli/internal/aggregate" + "github.com/tanem/mt5-pnl-cli/internal/render" +) + +func cmdPnL(args []string, stdout, stderr io.Writer, getPassphrase func() (string, error)) int { + fs := flag.NewFlagSet("pnl", flag.ContinueOnError) + fs.SetOutput(stderr) + last := fs.String("last", "", "relative range: Nd, Nw, Nm or Ny (default 30d)") + from := fs.String("from", "", "start date (YYYY-MM-DD)") + to := fs.String("to", "", "end date (YYYY-MM-DD); defaults to today") + by := fs.String("by", "week", "group results by: day, week or month") + accountsSpec := fs.String("accounts", "", "comma-separated account labels (default: all)") + asJSON := fs.Bool("json", false, "emit JSON instead of a table") + snapFlag := fs.String("snapshot", "", "snapshot path (default: $MT5_PNL_SNAPSHOT)") + staleAfter := fs.Duration("stale-after", 2*time.Hour, "staleness warning threshold") + if err := fs.Parse(args); err != nil { + return 1 + } + + if *by != "day" && *by != "week" && *by != "month" { + fmt.Fprintf(stderr, "error: invalid --by %q: use day, week or month\n", *by) + return 1 + } + fromD, toD, err := resolveRange(*last, *from, *to, time.Now()) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + + snap, err := loadSnapshot(*snapFlag, *staleAfter, stderr, getPassphrase) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + + filter, err := resolveAccounts(*accountsSpec, snap.Accounts) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + + rows, sum := aggregate.Aggregate(snap.ClosedDeals, aggregate.Options{ + From: fromD, To: toD, By: *by, Accounts: filter, + }) + + labels := make(map[int64]string, len(snap.Accounts)) + for _, a := range snap.Accounts { + labels[a.Login] = a.Label + } + + if *asJSON { + err = render.PnLJSON(stdout, rows, sum) + } else { + err = render.PnLTable(stdout, rows, sum, labels) + } + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + return 0 +} diff --git a/cmd_setpassphrase.go b/cmd_setpassphrase.go new file mode 100644 index 0000000..41fafc1 --- /dev/null +++ b/cmd_setpassphrase.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "io" + "os" + + "golang.org/x/term" + + "github.com/tanem/mt5-pnl-cli/internal/secrets" +) + +// cmdSetPassphrase stores the snapshot decryption passphrase in the OS +// keychain. Input is read without echo and never accepted from arguments +// or the environment. +func cmdSetPassphrase(stderr io.Writer) int { + fd := int(os.Stdin.Fd()) + if !term.IsTerminal(fd) { + fmt.Fprintln(stderr, "error: set-passphrase requires an interactive terminal") + return 1 + } + fmt.Fprint(stderr, "Encryption passphrase: ") + p1, err := term.ReadPassword(fd) + fmt.Fprintln(stderr) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + fmt.Fprint(stderr, "Confirm passphrase: ") + p2, err := term.ReadPassword(fd) + fmt.Fprintln(stderr) + if err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + if string(p1) != string(p2) { + fmt.Fprintln(stderr, "error: passphrases do not match") + return 1 + } + if err := secrets.Set(string(p1)); err != nil { + fmt.Fprintln(stderr, "error:", err) + return 1 + } + fmt.Fprintln(stderr, "Passphrase stored in keychain.") + return 0 +} diff --git a/e2e_test.go b/e2e_test.go new file mode 100644 index 0000000..8be7f9e --- /dev/null +++ b/e2e_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestBinarySmoke(t *testing.T) { + bin := filepath.Join(t.TempDir(), "mt5-pnl-cli") + if runtime.GOOS == "windows" { + bin += ".exe" + } + build := exec.Command("go", "build", "-o", bin, ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + + out, err := exec.Command(bin, "version").Output() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "mt5-pnl-cli dev (schema 1.0)") { + t.Errorf("version output: %q", out) + } + + // pnl with no --snapshot and no env var fails before touching the keychain. + cmd := exec.Command(bin, "pnl") + cmd.Env = envWithout("MT5_PNL_SNAPSHOT") + var stderr bytes.Buffer + cmd.Stderr = &stderr + err = cmd.Run() + if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 1 { + t.Fatalf("want exit 1, got %v", err) + } + if !strings.Contains(stderr.String(), "MT5_PNL_SNAPSHOT") { + t.Errorf("stderr: %q", stderr.String()) + } + + // set-passphrase refuses to run without an interactive terminal. + cmd = exec.Command(bin, "set-passphrase") + cmd.Stdin = strings.NewReader("") + stderr.Reset() + cmd.Stderr = &stderr + err = cmd.Run() + if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 1 { + t.Fatalf("want exit 1, got %v", err) + } + if !strings.Contains(stderr.String(), "interactive terminal") { + t.Errorf("stderr: %q", stderr.String()) + } +} + +func envWithout(name string) []string { + var env []string + for _, kv := range os.Environ() { + if !strings.HasPrefix(kv, name+"=") { + env = append(env, kv) + } + } + return env +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e256396 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/tanem/mt5-pnl-cli + +go 1.25.0 + +require filippo.io/age v1.3.1 + +require ( + filippo.io/hpke v0.4.0 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/zalando/go-keyring v0.2.8 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a2940b --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= diff --git a/internal/aggregate/aggregate.go b/internal/aggregate/aggregate.go new file mode 100644 index 0000000..0472bf5 --- /dev/null +++ b/internal/aggregate/aggregate.go @@ -0,0 +1,148 @@ +// Package aggregate turns raw closed deals into per-period P&L rows. +// +// The exporter pre-filters closed_deals to closing trades, so every deal +// here counts. Sums accumulate at full float64 precision; rounding happens +// at render time only. A breakeven deal (net == 0) counts toward trades but +// is neither a win nor a loss. +package aggregate + +import ( + "math" + "sort" + "time" + + "github.com/tanem/mt5-pnl-cli/internal/snapshot" +) + +type Options struct { + From, To time.Time // inclusive civil dates at UTC midnight + By string // "day", "week" (Monday-start) or "month" + Accounts map[int64]bool // nil = all accounts +} + +// Row is one period × account bucket. Account == nil is the combined row +// across all accounts for that period. +type Row struct { + Period string + Account *int64 + PnL float64 + Trades int + Wins int + Losses int + GrossProfit float64 + GrossLoss float64 +} + +type Summary struct { + TotalPnL float64 + TotalTrades int + WinRatePct *float64 // nil when no trades + ProfitFactor *float64 // nil when no gross loss + GrossProfit float64 + GrossLoss float64 +} + +func Aggregate(deals []snapshot.Deal, opts Options) ([]Row, Summary) { + type key struct { + period string + account int64 + } + buckets := map[key]*Row{} + accountSet := map[int64]bool{} + + for _, d := range deals { + if opts.Accounts != nil && !opts.Accounts[d.Account] { + continue + } + day := civilDay(d.Time) + if day.Before(opts.From) || day.After(opts.To) { + continue + } + k := key{periodKey(day, opts.By), d.Account} + b := buckets[k] + if b == nil { + acct := d.Account + b = &Row{Period: k.period, Account: &acct} + buckets[k] = b + } + net := d.Profit + d.Swap + d.Commission + d.Fee + b.PnL += net + b.Trades++ + switch { + case net > 0: + b.Wins++ + b.GrossProfit += net + case net < 0: + b.Losses++ + b.GrossLoss += net + } + accountSet[d.Account] = true + } + + periodSet := map[string]bool{} + for k := range buckets { + periodSet[k.period] = true + } + periods := make([]string, 0, len(periodSet)) + for p := range periodSet { + periods = append(periods, p) + } + sort.Strings(periods) + accounts := make([]int64, 0, len(accountSet)) + for a := range accountSet { + accounts = append(accounts, a) + } + sort.Slice(accounts, func(i, j int) bool { return accounts[i] < accounts[j] }) + + var rows []Row + var sum Summary + totalWins := 0 + for _, p := range periods { + combined := Row{Period: p} + for _, a := range accounts { + b, ok := buckets[key{p, a}] + if !ok { + continue + } + rows = append(rows, *b) + combined.PnL += b.PnL + combined.Trades += b.Trades + combined.Wins += b.Wins + combined.Losses += b.Losses + combined.GrossProfit += b.GrossProfit + combined.GrossLoss += b.GrossLoss + } + rows = append(rows, combined) + sum.TotalPnL += combined.PnL + sum.TotalTrades += combined.Trades + totalWins += combined.Wins + sum.GrossProfit += combined.GrossProfit + sum.GrossLoss += combined.GrossLoss + } + if sum.TotalTrades > 0 { + wr := float64(totalWins) / float64(sum.TotalTrades) * 100 + sum.WinRatePct = &wr + } + if sum.GrossLoss != 0 { + pf := sum.GrossProfit / math.Abs(sum.GrossLoss) + sum.ProfitFactor = &pf + } + return rows, sum +} + +func civilDay(unix int64) time.Time { + t := time.Unix(unix, 0).UTC() + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) +} + +func periodKey(day time.Time, by string) string { + switch by { + case "day": + return day.Format("2006-01-02") + case "week": + monday := day.AddDate(0, 0, -int((day.Weekday()+6)%7)) + return monday.Format("2006-01-02") + default: // month + return time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02") + } +} diff --git a/internal/aggregate/aggregate_test.go b/internal/aggregate/aggregate_test.go new file mode 100644 index 0000000..13043cc --- /dev/null +++ b/internal/aggregate/aggregate_test.go @@ -0,0 +1,118 @@ +package aggregate_test + +import ( + "reflect" + "testing" + "time" + + "github.com/tanem/mt5-pnl-cli/internal/aggregate" + "github.com/tanem/mt5-pnl-cli/internal/snapshot" +) + +func deal(account, ts int64, profit, swap, commission, fee float64) snapshot.Deal { + return snapshot.Deal{Account: account, Time: ts, Profit: profit, Swap: swap, Commission: commission, Fee: fee} +} + +func date(y int, m time.Month, d int) time.Time { + return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) +} + +func ptr[T any](v T) *T { return &v } + +// Four deals across two accounts and two ISO weeks: +// +// acct 111, Mon 2026-01-05: net 9.0 (10.0 - 0.5 - 0.5) -> win +// acct 111, Tue 2026-01-06: net -4.0 -> loss +// acct 222, Tue 2026-01-06: net 0.0 (0.7 - 0.7) -> breakeven: neither +// acct 111, Mon 2026-01-12: net 5.0 -> win, next week +var deals = []snapshot.Deal{ + deal(111, 1767607200, 10.0, -0.5, -0.5, 0), + deal(111, 1767693600, -4.0, 0, 0, 0), + deal(222, 1767693600, 0.7, 0, -0.7, 0), + deal(111, 1768212000, 5.0, 0, 0, 0), +} + +func TestAggregateByWeek(t *testing.T) { + rows, sum := aggregate.Aggregate(deals, aggregate.Options{ + From: date(2026, 1, 1), To: date(2026, 1, 31), By: "week", + }) + want := []aggregate.Row{ + {Period: "2026-01-05", Account: ptr(int64(111)), PnL: 5.0, Trades: 2, Wins: 1, Losses: 1, GrossProfit: 9.0, GrossLoss: -4.0}, + {Period: "2026-01-05", Account: ptr(int64(222)), PnL: 0.0, Trades: 1, Wins: 0, Losses: 0, GrossProfit: 0, GrossLoss: 0}, + {Period: "2026-01-05", Account: nil, PnL: 5.0, Trades: 3, Wins: 1, Losses: 1, GrossProfit: 9.0, GrossLoss: -4.0}, + {Period: "2026-01-12", Account: ptr(int64(111)), PnL: 5.0, Trades: 1, Wins: 1, Losses: 0, GrossProfit: 5.0, GrossLoss: 0}, + {Period: "2026-01-12", Account: nil, PnL: 5.0, Trades: 1, Wins: 1, Losses: 0, GrossProfit: 5.0, GrossLoss: 0}, + } + if !reflect.DeepEqual(rows, want) { + t.Errorf("rows:\n got %+v\nwant %+v", rows, want) + } + if sum.TotalPnL != 10.0 || sum.TotalTrades != 4 || sum.GrossProfit != 14.0 || sum.GrossLoss != -4.0 { + t.Errorf("summary totals wrong: %+v", sum) + } + if sum.WinRatePct == nil || *sum.WinRatePct != 50.0 { + t.Errorf("win rate = %v, want 50.0", sum.WinRatePct) + } + if sum.ProfitFactor == nil || *sum.ProfitFactor != 3.5 { + t.Errorf("profit factor = %v, want 3.5", sum.ProfitFactor) + } +} + +func TestAggregateByDayWithDateFilter(t *testing.T) { + rows, _ := aggregate.Aggregate(deals, aggregate.Options{ + From: date(2026, 1, 6), To: date(2026, 1, 6), By: "day", + }) + if len(rows) != 3 { // acct 111, acct 222, combined + t.Fatalf("got %d rows, want 3: %+v", len(rows), rows) + } + if rows[0].Period != "2026-01-06" { + t.Errorf("period = %q, want 2026-01-06", rows[0].Period) + } +} + +func TestAggregateByMonth(t *testing.T) { + withFeb := append(append([]snapshot.Deal{}, deals...), deal(111, 1770026400, 2.0, 0, 0, 0)) + rows, _ := aggregate.Aggregate(withFeb, aggregate.Options{ + From: date(2026, 1, 1), To: date(2026, 2, 28), By: "month", + }) + periods := map[string]bool{} + for _, r := range rows { + periods[r.Period] = true + } + if !periods["2026-01-01"] || !periods["2026-02-01"] || len(periods) != 2 { + t.Errorf("periods = %v, want 2026-01-01 and 2026-02-01", periods) + } +} + +func TestWeekBoundary(t *testing.T) { + // Sunday 23:59:59 belongs to the week starting the previous Monday. + rows, _ := aggregate.Aggregate([]snapshot.Deal{deal(111, 1768175999, 1.0, 0, 0, 0)}, + aggregate.Options{From: date(2026, 1, 1), To: date(2026, 1, 31), By: "week"}) + if rows[0].Period != "2026-01-05" { + t.Errorf("period = %q, want 2026-01-05", rows[0].Period) + } +} + +func TestAccountFilter(t *testing.T) { + rows, sum := aggregate.Aggregate(deals, aggregate.Options{ + From: date(2026, 1, 1), To: date(2026, 1, 31), By: "week", + Accounts: map[int64]bool{222: true}, + }) + if len(rows) != 2 { // acct 222 row + combined + t.Fatalf("got %d rows, want 2: %+v", len(rows), rows) + } + if sum.TotalTrades != 1 { + t.Errorf("trades = %d, want 1", sum.TotalTrades) + } +} + +func TestEmpty(t *testing.T) { + rows, sum := aggregate.Aggregate(nil, aggregate.Options{ + From: date(2026, 1, 1), To: date(2026, 1, 31), By: "week", + }) + if len(rows) != 0 || sum.TotalTrades != 0 { + t.Errorf("want empty result, got rows=%v sum=%+v", rows, sum) + } + if sum.WinRatePct != nil || sum.ProfitFactor != nil { + t.Errorf("want nil win rate and profit factor, got %+v", sum) + } +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..97b243b --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,129 @@ +// Package render prints aggregate results as tabwriter tables or JSON. +// All rounding to display precision happens here, not in aggregate. +package render + +import ( + "encoding/json" + "fmt" + "io" + "math" + "strconv" + "text/tabwriter" + + "github.com/tanem/mt5-pnl-cli/internal/aggregate" + "github.com/tanem/mt5-pnl-cli/internal/snapshot" +) + +func round(x float64, places int) float64 { + scale := math.Pow(10, float64(places)) + return math.Round(x*scale) / scale +} + +func roundPtr(p *float64, places int) *float64 { + if p == nil { + return nil + } + r := round(*p, places) + return &r +} + +func fmtPtr(p *float64, format string) string { + if p == nil { + return "n/a" + } + return fmt.Sprintf(format, *p) +} + +func PnLTable(w io.Writer, rows []aggregate.Row, sum aggregate.Summary, labels map[int64]string) error { + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "PERIOD\tACCOUNT\tP&L\tTRADES\tWINS\tLOSSES") + for _, r := range rows { + acct := "ALL" + if r.Account != nil { + acct = labels[*r.Account] + if acct == "" { + acct = strconv.FormatInt(*r.Account, 10) + } + } + fmt.Fprintf(tw, "%s\t%s\t%.2f\t%d\t%d\t%d\n", r.Period, acct, r.PnL, r.Trades, r.Wins, r.Losses) + } + if err := tw.Flush(); err != nil { + return err + } + _, err := fmt.Fprintf(w, + "\nTotal P&L: %.2f Trades: %d Win rate: %s Profit factor: %s Gross profit: %.2f Gross loss: %.2f\n", + sum.TotalPnL, sum.TotalTrades, + fmtPtr(sum.WinRatePct, "%.1f%%"), fmtPtr(sum.ProfitFactor, "%.2f"), + sum.GrossProfit, sum.GrossLoss) + return err +} + +type pnlRow struct { + Period string `json:"period"` + Account *int64 `json:"account"` + PnL float64 `json:"pnl"` + Trades int `json:"trades"` + Wins int `json:"wins"` + Losses int `json:"losses"` + GrossProfit float64 `json:"gross_profit"` + GrossLoss float64 `json:"gross_loss"` +} + +type pnlSummary struct { + TotalPnL float64 `json:"total_pnl"` + TotalTrades int `json:"total_trades"` + WinRatePct *float64 `json:"win_rate_pct"` + ProfitFactor *float64 `json:"profit_factor"` + GrossProfit float64 `json:"gross_profit"` + GrossLoss float64 `json:"gross_loss"` +} + +func PnLJSON(w io.Writer, rows []aggregate.Row, sum aggregate.Summary) error { + out := struct { + Rows []pnlRow `json:"rows"` + Summary pnlSummary `json:"summary"` + }{Rows: make([]pnlRow, 0, len(rows))} + for _, r := range rows { + out.Rows = append(out.Rows, pnlRow{ + Period: r.Period, Account: r.Account, + PnL: round(r.PnL, 2), Trades: r.Trades, Wins: r.Wins, Losses: r.Losses, + GrossProfit: round(r.GrossProfit, 2), GrossLoss: round(r.GrossLoss, 2), + }) + } + out.Summary = pnlSummary{ + TotalPnL: round(sum.TotalPnL, 2), TotalTrades: sum.TotalTrades, + WinRatePct: roundPtr(sum.WinRatePct, 1), ProfitFactor: roundPtr(sum.ProfitFactor, 2), + GrossProfit: round(sum.GrossProfit, 2), GrossLoss: round(sum.GrossLoss, 2), + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(out) +} + +func strOr(p *string, fallback string) string { + if p == nil { + return fallback + } + return *p +} + +func AccountsTable(w io.Writer, accounts []snapshot.AccountSnapshot, generatedAt string) error { + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "LOGIN\tLABEL\tCURRENCY\tBALANCE\tEQUITY\tLAST SUCCESS\tLAST ERROR") + for _, a := range accounts { + fmt.Fprintf(tw, "%d\t%s\t%s\t%.2f\t%.2f\t%s\t%s\n", + a.Login, a.Label, a.Currency, a.Balance, a.Equity, + strOr(a.LastSuccessAt, "-"), strOr(a.LastError, "-")) + } + if err := tw.Flush(); err != nil { + return err + } + _, err := fmt.Fprintf(w, "\nSnapshot generated: %s\n", generatedAt) + return err +} + +func AccountsJSON(w io.Writer, accounts []snapshot.AccountSnapshot) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(accounts) +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..1221933 --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,157 @@ +package render_test + +import ( + "bytes" + "flag" + "os" + "path/filepath" + "testing" + + "github.com/tanem/mt5-pnl-cli/internal/aggregate" + "github.com/tanem/mt5-pnl-cli/internal/render" + "github.com/tanem/mt5-pnl-cli/internal/snapshot" +) + +var update = flag.Bool("update", false, "rewrite golden files") + +func ptr[T any](v T) *T { return &v } + +var rows = []aggregate.Row{ + {Period: "2026-01-05", Account: ptr(int64(111)), PnL: 5.004, Trades: 2, Wins: 1, Losses: 1, GrossProfit: 9.0, GrossLoss: -3.996}, + {Period: "2026-01-05", Account: nil, PnL: 5.004, Trades: 2, Wins: 1, Losses: 1, GrossProfit: 9.0, GrossLoss: -3.996}, +} + +var sum = aggregate.Summary{ + TotalPnL: 5.004, TotalTrades: 2, + WinRatePct: ptr(50.0), ProfitFactor: ptr(2.2522522522522523), + GrossProfit: 9.0, GrossLoss: -3.996, +} + +var labels = map[int64]string{111: "Trend EA"} + +func checkGolden(t *testing.T, name string, got []byte) { + t.Helper() + golden := filepath.Join("testdata", name) + if *update { + if err := os.WriteFile(golden, got, 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Errorf("output mismatch:\ngot:\n%s\nwant:\n%s", got, want) + } +} + +func TestPnLTable(t *testing.T) { + var buf bytes.Buffer + if err := render.PnLTable(&buf, rows, sum, labels); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, want := range []string{"PERIOD", "Trend EA", "ALL", "5.00", "-4.00", "50.0%", "2.25"} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Errorf("table missing %q:\n%s", want, out) + } + } + checkGolden(t, "pnl_table.golden", buf.Bytes()) +} + +func TestPnLTableUnknownLabelFallsBackToLogin(t *testing.T) { + var buf bytes.Buffer + if err := render.PnLTable(&buf, rows, sum, nil); err != nil { + t.Fatal(err) + } + if !bytes.Contains(buf.Bytes(), []byte("111")) { + t.Errorf("expected login fallback in:\n%s", buf.String()) + } +} + +func TestPnLTableNilSummaryFields(t *testing.T) { + var buf bytes.Buffer + if err := render.PnLTable(&buf, nil, aggregate.Summary{}, nil); err != nil { + t.Fatal(err) + } + if !bytes.Contains(buf.Bytes(), []byte("n/a")) { + t.Errorf("expected n/a for nil win rate / profit factor:\n%s", buf.String()) + } +} + +func TestPnLJSON(t *testing.T) { + var buf bytes.Buffer + if err := render.PnLJSON(&buf, rows, sum); err != nil { + t.Fatal(err) + } + want := `{ + "rows": [ + { + "period": "2026-01-05", + "account": 111, + "pnl": 5, + "trades": 2, + "wins": 1, + "losses": 1, + "gross_profit": 9, + "gross_loss": -4 + }, + { + "period": "2026-01-05", + "account": null, + "pnl": 5, + "trades": 2, + "wins": 1, + "losses": 1, + "gross_profit": 9, + "gross_loss": -4 + } + ], + "summary": { + "total_pnl": 5, + "total_trades": 2, + "win_rate_pct": 50, + "profit_factor": 2.25, + "gross_profit": 9, + "gross_loss": -4 + } +} +` + if buf.String() != want { + t.Errorf("JSON mismatch:\ngot:\n%s\nwant:\n%s", buf.String(), want) + } +} + +var accounts = []snapshot.AccountSnapshot{ + {Login: 111, Label: "Trend EA", Currency: "USD", Balance: 1000, Equity: 1010.5, + LastSuccessAt: ptr("2026-06-13T00:00:00Z"), LastError: nil}, + {Login: 222, Label: "Scalper EA", Currency: "USD", Balance: 500, Equity: 500, + LastSuccessAt: nil, LastError: ptr("login failed")}, +} + +func TestAccountsTable(t *testing.T) { + var buf bytes.Buffer + if err := render.AccountsTable(&buf, accounts, "2026-06-13T00:00:00Z"); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, want := range []string{"LOGIN", "Trend EA", "login failed", "Snapshot generated: 2026-06-13T00:00:00Z"} { + if !bytes.Contains([]byte(out), []byte(want)) { + t.Errorf("accounts table missing %q:\n%s", want, out) + } + } + checkGolden(t, "accounts_table.golden", buf.Bytes()) +} + +func TestAccountsJSON(t *testing.T) { + var buf bytes.Buffer + if err := render.AccountsJSON(&buf, accounts); err != nil { + t.Fatal(err) + } + for _, want := range []string{`"login": 111`, `"last_error": "login failed"`, `"last_success_at": null`} { + if !bytes.Contains(buf.Bytes(), []byte(want)) { + t.Errorf("accounts JSON missing %q:\n%s", want, buf.String()) + } + } +} diff --git a/internal/render/testdata/accounts_table.golden b/internal/render/testdata/accounts_table.golden new file mode 100644 index 0000000..3fe236e --- /dev/null +++ b/internal/render/testdata/accounts_table.golden @@ -0,0 +1,5 @@ +LOGIN LABEL CURRENCY BALANCE EQUITY LAST SUCCESS LAST ERROR +111 Trend EA USD 1000.00 1010.50 2026-06-13T00:00:00Z - +222 Scalper EA USD 500.00 500.00 - login failed + +Snapshot generated: 2026-06-13T00:00:00Z diff --git a/internal/render/testdata/pnl_table.golden b/internal/render/testdata/pnl_table.golden new file mode 100644 index 0000000..9a1be30 --- /dev/null +++ b/internal/render/testdata/pnl_table.golden @@ -0,0 +1,5 @@ +PERIOD ACCOUNT P&L TRADES WINS LOSSES +2026-01-05 Trend EA 5.00 2 1 1 +2026-01-05 ALL 5.00 2 1 1 + +Total P&L: 5.00 Trades: 2 Win rate: 50.0% Profit factor: 2.25 Gross profit: 9.00 Gross loss: -4.00 diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go new file mode 100644 index 0000000..793186b --- /dev/null +++ b/internal/secrets/secrets.go @@ -0,0 +1,32 @@ +// Package secrets stores the snapshot decryption passphrase in the OS +// keychain (macOS Keychain / Windows Credential Manager / Linux Secret +// Service). The passphrase is never read from env vars or flags. +package secrets + +import ( + "errors" + + "github.com/zalando/go-keyring" +) + +const ( + service = "mt5-pnl-cli" + account = "encryption-passphrase" +) + +var ErrNotFound = errors.New("no passphrase in keychain: run 'mt5-pnl-cli set-passphrase' first") + +func Get() (string, error) { + pw, err := keyring.Get(service, account) + if errors.Is(err, keyring.ErrNotFound) { + return "", ErrNotFound + } + return pw, err +} + +func Set(passphrase string) error { + if passphrase == "" { + return errors.New("passphrase cannot be empty") + } + return keyring.Set(service, account, passphrase) +} diff --git a/internal/secrets/secrets_test.go b/internal/secrets/secrets_test.go new file mode 100644 index 0000000..0b01679 --- /dev/null +++ b/internal/secrets/secrets_test.go @@ -0,0 +1,39 @@ +package secrets_test + +import ( + "errors" + "testing" + + "github.com/zalando/go-keyring" + + "github.com/tanem/mt5-pnl-cli/internal/secrets" +) + +func TestSetAndGet(t *testing.T) { + keyring.MockInit() // in-memory store; no real keychain touched + if err := secrets.Set("hunter2"); err != nil { + t.Fatal(err) + } + got, err := secrets.Get() + if err != nil { + t.Fatal(err) + } + if got != "hunter2" { + t.Errorf("Get() = %q, want %q", got, "hunter2") + } +} + +func TestGetMissing(t *testing.T) { + keyring.MockInit() + _, err := secrets.Get() + if !errors.Is(err, secrets.ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestSetEmpty(t *testing.T) { + keyring.MockInit() + if err := secrets.Set(""); err == nil { + t.Fatal("want error for empty passphrase") + } +} diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go new file mode 100644 index 0000000..6e226d9 --- /dev/null +++ b/internal/snapshot/snapshot.go @@ -0,0 +1,148 @@ +// Package snapshot reads the encrypted snapshot written by mt5-pnl-exporter. +// +// The on-disk format is JSON → gzip → age (scrypt passphrase). The struct +// fields match schema/snapshot.schema.json (vendored from the exporter +// v1.0.3 release) field-for-field. +package snapshot + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "filippo.io/age" +) + +// Supported schema version: accept the same major and any minor <= SupportedMinor. +const ( + SupportedMajor = 1 + SupportedMinor = 0 +) + +type Snapshot struct { + SchemaVersion string `json:"schema_version"` + GeneratedAt string `json:"generated_at"` + Accounts []AccountSnapshot `json:"accounts"` + ClosedDeals []Deal `json:"closed_deals"` + OpenPositions []OpenPosition `json:"open_positions"` + CashFlows []Deal `json:"cash_flows"` +} + +type AccountSnapshot struct { + Login int64 `json:"login"` + Label string `json:"label"` + Currency string `json:"currency"` + Balance float64 `json:"balance"` + Equity float64 `json:"equity"` + LastSuccessAt *string `json:"last_success_at"` + LastError *string `json:"last_error"` +} + +// Deal is the shape shared by closed_deals and cash_flows (the schema's +// ClosedDeal and CashFlow are field-for-field identical). +type Deal struct { + Account int64 `json:"account"` + Ticket int64 `json:"ticket"` + Order int64 `json:"order"` + PositionID int64 `json:"position_id"` + Time int64 `json:"time"` + TimeMsc int64 `json:"time_msc"` + Type int `json:"type"` + Entry int `json:"entry"` + Reason int `json:"reason"` + Magic int64 `json:"magic"` + Volume float64 `json:"volume"` + Price float64 `json:"price"` + Profit float64 `json:"profit"` + Swap float64 `json:"swap"` + Commission float64 `json:"commission"` + Fee float64 `json:"fee"` + Symbol string `json:"symbol"` + Comment string `json:"comment"` + ExternalID string `json:"external_id"` +} + +type OpenPosition struct { + Account int64 `json:"account"` + Ticket int64 `json:"ticket"` + Identifier int64 `json:"identifier"` + Time int64 `json:"time"` + TimeMsc int64 `json:"time_msc"` + TimeUpdate int64 `json:"time_update"` + TimeUpdateMsc int64 `json:"time_update_msc"` + Type int `json:"type"` + Reason int `json:"reason"` + Magic int64 `json:"magic"` + Volume float64 `json:"volume"` + PriceOpen float64 `json:"price_open"` + PriceCurrent float64 `json:"price_current"` + SL float64 `json:"sl"` + TP float64 `json:"tp"` + Profit float64 `json:"profit"` + Swap float64 `json:"swap"` + Symbol string `json:"symbol"` + Comment string `json:"comment"` + ExternalID string `json:"external_id"` +} + +// CheckSchemaVersion enforces the contract: same major, minor <= ours. +func CheckSchemaVersion(v string) error { + unsupported := func() error { + return fmt.Errorf( + "unsupported snapshot schema %q: this build supports %d.0 through %d.%d "+ + "(newer minor: upgrade mt5-pnl-cli; different major: align exporter and CLI releases)", + v, SupportedMajor, SupportedMajor, SupportedMinor) + } + parts := strings.SplitN(v, ".", 2) + if len(parts) != 2 { + return unsupported() + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return unsupported() + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return unsupported() + } + if major != SupportedMajor || minor > SupportedMinor { + return unsupported() + } + return nil +} + +// Read opens, decrypts (age scrypt), decompresses and parses a snapshot, +// then enforces the schema version gate. The pipeline is fully streaming. +func Read(path, passphrase string) (*Snapshot, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + id, err := age.NewScryptIdentity(passphrase) + if err != nil { + return nil, err + } + dec, err := age.Decrypt(f, id) + if err != nil { + return nil, fmt.Errorf("decrypting %s: wrong passphrase, or the file is corrupt (%v)", path, err) + } + gz, err := gzip.NewReader(dec) + if err != nil { + return nil, fmt.Errorf("decompressing %s: %v", path, err) + } + defer gz.Close() + + var snap Snapshot + if err := json.NewDecoder(gz).Decode(&snap); err != nil { + return nil, fmt.Errorf("parsing %s: %v", path, err) + } + if err := CheckSchemaVersion(snap.SchemaVersion); err != nil { + return nil, err + } + return &snap, nil +} diff --git a/internal/snapshot/snapshot_test.go b/internal/snapshot/snapshot_test.go new file mode 100644 index 0000000..58bd260 --- /dev/null +++ b/internal/snapshot/snapshot_test.go @@ -0,0 +1,108 @@ +package snapshot_test + +import ( + "os" + "strings" + "testing" + + "github.com/tanem/mt5-pnl-cli/internal/snapshot" + "github.com/tanem/mt5-pnl-cli/internal/snaptest" +) + +func TestCheckSchemaVersion(t *testing.T) { + cases := []struct { + version string + wantErr string // "" = accepted + }{ + {"1.0", ""}, + {"1.1", "unsupported"}, // additive minor newer than this build + {"0.9", "unsupported"}, + {"2.0", "unsupported"}, + {"garbage", "unsupported"}, + {"1", "unsupported"}, + {"", "unsupported"}, + } + for _, c := range cases { + err := snapshot.CheckSchemaVersion(c.version) + if c.wantErr == "" && err != nil { + t.Errorf("CheckSchemaVersion(%q) = %v, want nil", c.version, err) + } + if c.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), c.wantErr) { + t.Errorf("CheckSchemaVersion(%q) = %v, want error containing %q", c.version, err, c.wantErr) + } + } + } +} + +const minimalJSON = `{ + "schema_version": "1.0", + "generated_at": "2026-06-13T00:00:00Z", + "accounts": [ + {"login": 111, "label": "Trend EA", "currency": "USD", + "balance": 1000.0, "equity": 1010.5, + "last_success_at": "2026-06-13T00:00:00Z", "last_error": null} + ], + "closed_deals": [ + {"account": 111, "ticket": 1, "order": 1, "position_id": 1, + "time": 1767607200, "time_msc": 1767607200000, "type": 0, "entry": 1, + "reason": 0, "magic": 7, "volume": 0.1, "price": 1.08, + "profit": 10.0, "swap": -0.5, "commission": -0.5, "fee": 0.0, + "symbol": "EURUSD", "comment": "", "external_id": ""} + ], + "open_positions": [], + "cash_flows": [] +}` + +func TestReadRoundTrip(t *testing.T) { + path := snaptest.Write(t, minimalJSON, "test-pass") + snap, err := snapshot.Read(path, "test-pass") + if err != nil { + t.Fatal(err) + } + if snap.SchemaVersion != "1.0" || len(snap.Accounts) != 1 || len(snap.ClosedDeals) != 1 { + t.Fatalf("unexpected snapshot: %+v", snap) + } + if snap.Accounts[0].Label != "Trend EA" || snap.Accounts[0].LastError != nil { + t.Errorf("account fields wrong: %+v", snap.Accounts[0]) + } + d := snap.ClosedDeals[0] + if d.Account != 111 || d.Profit != 10.0 || d.Commission != -0.5 || d.Time != 1767607200 { + t.Errorf("deal fields wrong: %+v", d) + } +} + +func TestReadWrongPassphrase(t *testing.T) { + path := snaptest.Write(t, minimalJSON, "test-pass") + _, err := snapshot.Read(path, "wrong") + if err == nil || !strings.Contains(err.Error(), "wrong passphrase") { + t.Fatalf("err = %v, want wrong-passphrase message", err) + } +} + +func TestReadMissingFile(t *testing.T) { + _, err := snapshot.Read("/nonexistent/snapshot.json.gz.age", "x") + if err == nil { + t.Fatal("want error for missing file") + } +} + +func TestReadCorruptFile(t *testing.T) { + path := snaptest.Write(t, minimalJSON, "test-pass") + if err := os.WriteFile(path, []byte("not an age file"), 0o600); err != nil { + t.Fatal(err) + } + _, err := snapshot.Read(path, "test-pass") + if err == nil { + t.Fatal("want error for corrupt file") + } +} + +func TestReadRefusesUnsupportedSchema(t *testing.T) { + body := strings.Replace(minimalJSON, `"schema_version": "1.0"`, `"schema_version": "2.0"`, 1) + path := snaptest.Write(t, body, "test-pass") + _, err := snapshot.Read(path, "test-pass") + if err == nil || !strings.Contains(err.Error(), "unsupported snapshot schema") { + t.Fatalf("err = %v, want unsupported-schema message", err) + } +} diff --git a/internal/snaptest/snaptest.go b/internal/snaptest/snaptest.go new file mode 100644 index 0000000..a25b33a --- /dev/null +++ b/internal/snaptest/snaptest.go @@ -0,0 +1,44 @@ +// Package snaptest builds encrypted snapshot fixtures for tests, reversing +// the exporter's pipeline: JSON → gzip → age (scrypt). +package snaptest + +import ( + "bytes" + "compress/gzip" + "os" + "path/filepath" + "testing" + + "filippo.io/age" +) + +// Write encrypts jsonBody and writes it under t.TempDir(), returning the path. +func Write(t *testing.T, jsonBody, passphrase string) string { + t.Helper() + var buf bytes.Buffer + r, err := age.NewScryptRecipient(passphrase) + if err != nil { + t.Fatal(err) + } + // Low work factor keeps tests fast; Read handles any factor from the file. + r.SetWorkFactor(10) + aw, err := age.Encrypt(&buf, r) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(aw) + if _, err := gz.Write([]byte(jsonBody)); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + if err := aw.Close(); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "snapshot.json.gz.age") + if err := os.WriteFile(path, buf.Bytes(), 0o600); err != nil { + t.Fatal(err) + } + return path +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..574d0dd --- /dev/null +++ b/main.go @@ -0,0 +1,60 @@ +// mt5-pnl-cli queries MT5 P&L from an encrypted mt5-pnl-exporter snapshot. +package main + +import ( + "fmt" + "io" + "os" + + "github.com/tanem/mt5-pnl-cli/internal/secrets" + "github.com/tanem/mt5-pnl-cli/internal/snapshot" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr, secrets.Get)) +} + +// run is the testable entry point: commands write to the given streams and +// obtain the decryption passphrase via getPassphrase (the real binary wires +// secrets.Get; tests inject a fake). +func run(args []string, stdout, stderr io.Writer, getPassphrase func() (string, error)) int { + if len(args) == 0 { + usage(stderr) + return 1 + } + switch args[0] { + case "pnl": + return cmdPnL(args[1:], stdout, stderr, getPassphrase) + case "accounts": + return cmdAccounts(args[1:], stdout, stderr, getPassphrase) + case "set-passphrase": + return cmdSetPassphrase(stderr) + case "version": + fmt.Fprintf(stdout, "mt5-pnl-cli %s (schema %d.%d)\n", version, snapshot.SupportedMajor, snapshot.SupportedMinor) + return 0 + case "help", "-h", "--help": + usage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "unknown command %q\n\n", args[0]) + usage(stderr) + return 1 + } +} + +func usage(w io.Writer) { + fmt.Fprint(w, `mt5-pnl-cli — query MT5 P&L from an mt5-pnl-exporter snapshot. + +Usage: + mt5-pnl-cli pnl [--last 30d | --from YYYY-MM-DD [--to YYYY-MM-DD]] + [--by day|week|month] [--accounts "A,B"] [--json] + [--snapshot PATH] [--stale-after 2h] + mt5-pnl-cli accounts [--json] [--snapshot PATH] [--stale-after 2h] + mt5-pnl-cli set-passphrase + mt5-pnl-cli version + +The snapshot path comes from --snapshot or the MT5_PNL_SNAPSHOT environment +variable. The decryption passphrase comes from the OS keychain; store it +once with set-passphrase. +`) +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5cf4cfd --- /dev/null +++ b/renovate.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + "helpers:pinGitHubActionDigests" + ], + "packageRules": [ + { + "description": "Auto-merge digest, minor and patch updates once CI passes", + "matchUpdateTypes": ["digest", "minor", "patch"], + "automerge": true + }, + { + "description": "Major updates always open a PR for review", + "matchUpdateTypes": ["major"], + "automerge": false + } + ] +} diff --git a/schema/snapshot.schema.json b/schema/snapshot.schema.json new file mode 100644 index 0000000..91de943 --- /dev/null +++ b/schema/snapshot.schema.json @@ -0,0 +1,432 @@ +{ + "$defs": { + "AccountSnapshot": { + "additionalProperties": false, + "properties": { + "login": { + "title": "Login", + "type": "integer" + }, + "label": { + "title": "Label", + "type": "string" + }, + "currency": { + "title": "Currency", + "type": "string" + }, + "balance": { + "title": "Balance", + "type": "number" + }, + "equity": { + "title": "Equity", + "type": "number" + }, + "last_success_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Success At" + }, + "last_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Error" + } + }, + "required": [ + "login", + "label", + "currency", + "balance", + "equity", + "last_success_at", + "last_error" + ], + "title": "AccountSnapshot", + "type": "object" + }, + "CashFlow": { + "additionalProperties": false, + "description": "One balance-family deal (deposit/withdrawal/credit/charge/correction/bonus/commission).\n\nSame shape as ClosedDeal \u2014 these ARE deals, distinguished only by `type`.\nFields that don't apply to balance records (volume, price, symbol, entry,\nposition_id) come through as 0 / empty string from MT5.", + "properties": { + "account": { + "title": "Account", + "type": "integer" + }, + "ticket": { + "title": "Ticket", + "type": "integer" + }, + "order": { + "title": "Order", + "type": "integer" + }, + "position_id": { + "title": "Position Id", + "type": "integer" + }, + "time": { + "title": "Time", + "type": "integer" + }, + "time_msc": { + "title": "Time Msc", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "integer" + }, + "entry": { + "title": "Entry", + "type": "integer" + }, + "reason": { + "title": "Reason", + "type": "integer" + }, + "magic": { + "title": "Magic", + "type": "integer" + }, + "volume": { + "title": "Volume", + "type": "number" + }, + "price": { + "title": "Price", + "type": "number" + }, + "profit": { + "title": "Profit", + "type": "number" + }, + "swap": { + "title": "Swap", + "type": "number" + }, + "commission": { + "title": "Commission", + "type": "number" + }, + "fee": { + "title": "Fee", + "type": "number" + }, + "symbol": { + "title": "Symbol", + "type": "string" + }, + "comment": { + "title": "Comment", + "type": "string" + }, + "external_id": { + "title": "External Id", + "type": "string" + } + }, + "required": [ + "account", + "ticket", + "order", + "position_id", + "time", + "time_msc", + "type", + "entry", + "reason", + "magic", + "volume", + "price", + "profit", + "swap", + "commission", + "fee", + "symbol", + "comment", + "external_id" + ], + "title": "CashFlow", + "type": "object" + }, + "ClosedDeal": { + "additionalProperties": false, + "description": "One closing trade deal \u2014 every field MT5's TradeDeal emits, plus `account`.", + "properties": { + "account": { + "title": "Account", + "type": "integer" + }, + "ticket": { + "title": "Ticket", + "type": "integer" + }, + "order": { + "title": "Order", + "type": "integer" + }, + "position_id": { + "title": "Position Id", + "type": "integer" + }, + "time": { + "title": "Time", + "type": "integer" + }, + "time_msc": { + "title": "Time Msc", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "integer" + }, + "entry": { + "title": "Entry", + "type": "integer" + }, + "reason": { + "title": "Reason", + "type": "integer" + }, + "magic": { + "title": "Magic", + "type": "integer" + }, + "volume": { + "title": "Volume", + "type": "number" + }, + "price": { + "title": "Price", + "type": "number" + }, + "profit": { + "title": "Profit", + "type": "number" + }, + "swap": { + "title": "Swap", + "type": "number" + }, + "commission": { + "title": "Commission", + "type": "number" + }, + "fee": { + "title": "Fee", + "type": "number" + }, + "symbol": { + "title": "Symbol", + "type": "string" + }, + "comment": { + "title": "Comment", + "type": "string" + }, + "external_id": { + "title": "External Id", + "type": "string" + } + }, + "required": [ + "account", + "ticket", + "order", + "position_id", + "time", + "time_msc", + "type", + "entry", + "reason", + "magic", + "volume", + "price", + "profit", + "swap", + "commission", + "fee", + "symbol", + "comment", + "external_id" + ], + "title": "ClosedDeal", + "type": "object" + }, + "OpenPosition": { + "additionalProperties": false, + "description": "One currently-open position \u2014 every field MT5's TradePosition emits, plus `account`.", + "properties": { + "account": { + "title": "Account", + "type": "integer" + }, + "ticket": { + "title": "Ticket", + "type": "integer" + }, + "identifier": { + "title": "Identifier", + "type": "integer" + }, + "time": { + "title": "Time", + "type": "integer" + }, + "time_msc": { + "title": "Time Msc", + "type": "integer" + }, + "time_update": { + "title": "Time Update", + "type": "integer" + }, + "time_update_msc": { + "title": "Time Update Msc", + "type": "integer" + }, + "type": { + "title": "Type", + "type": "integer" + }, + "reason": { + "title": "Reason", + "type": "integer" + }, + "magic": { + "title": "Magic", + "type": "integer" + }, + "volume": { + "title": "Volume", + "type": "number" + }, + "price_open": { + "title": "Price Open", + "type": "number" + }, + "price_current": { + "title": "Price Current", + "type": "number" + }, + "sl": { + "title": "Sl", + "type": "number" + }, + "tp": { + "title": "Tp", + "type": "number" + }, + "profit": { + "title": "Profit", + "type": "number" + }, + "swap": { + "title": "Swap", + "type": "number" + }, + "symbol": { + "title": "Symbol", + "type": "string" + }, + "comment": { + "title": "Comment", + "type": "string" + }, + "external_id": { + "title": "External Id", + "type": "string" + } + }, + "required": [ + "account", + "ticket", + "identifier", + "time", + "time_msc", + "time_update", + "time_update_msc", + "type", + "reason", + "magic", + "volume", + "price_open", + "price_current", + "sl", + "tp", + "profit", + "swap", + "symbol", + "comment", + "external_id" + ], + "title": "OpenPosition", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "schema_version": { + "const": "1.0", + "title": "Schema Version", + "type": "string" + }, + "generated_at": { + "title": "Generated At", + "type": "string" + }, + "accounts": { + "items": { + "$ref": "#/$defs/AccountSnapshot" + }, + "title": "Accounts", + "type": "array" + }, + "closed_deals": { + "items": { + "$ref": "#/$defs/ClosedDeal" + }, + "title": "Closed Deals", + "type": "array" + }, + "open_positions": { + "items": { + "$ref": "#/$defs/OpenPosition" + }, + "title": "Open Positions", + "type": "array" + }, + "cash_flows": { + "items": { + "$ref": "#/$defs/CashFlow" + }, + "title": "Cash Flows", + "type": "array" + } + }, + "required": [ + "schema_version", + "generated_at", + "accounts", + "closed_deals", + "open_positions", + "cash_flows" + ], + "title": "Snapshot", + "type": "object" +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..a74e135 --- /dev/null +++ b/version.go @@ -0,0 +1,4 @@ +package main + +// version is injected by GoReleaser via -ldflags "-X main.version=...". +var version = "dev"