begin transfer log
This commit is contained in:
parent
10f54c24ad
commit
cab4c291ad
4 changed files with 697 additions and 0 deletions
119
AGENTS.md
119
AGENTS.md
|
|
@ -1,5 +1,65 @@
|
|||
# AGENTS.md
|
||||
|
||||
## ⛔ ABSOLUTE PROHIBITIONS — READ FIRST ⛔
|
||||
|
||||
### NEVER delete or modify build caches. EVER.
|
||||
|
||||
**This means:**
|
||||
|
||||
- **NEVER** run `rm -rf .zig-cache` or `rm -rf .zig-cache/*` or any variant.
|
||||
- **NEVER** run `rm -rf ~/.cache/zig` or touch anything under `~/.cache/zig/`.
|
||||
- **NEVER** touch `~/.cache/zls/` or any other tool cache.
|
||||
- **NEVER** suggest deleting the cache as a "fix" — it is not a fix, it is
|
||||
damage. Deleting `.zig-cache` while ZLS or another `zig build` is running
|
||||
creates a corrupt state where the build runner's expected cache entry
|
||||
(`.zig-cache/o/<hash>/build`) references a path that no longer exists,
|
||||
producing the error `failed to spawn build runner .zig-cache/o/<hash>/build:
|
||||
FileNotFound`. Recovering from this on the affected machine requires
|
||||
killing every concurrent `zig` process (including ZLS) and waiting for
|
||||
filesystem state to re-stabilize — it is NOT a simple retry.
|
||||
- **NEVER** touch the cache "just to force a rebuild". Zig's cache is
|
||||
content-addressed. It does not get stale in a way that deletion fixes.
|
||||
If a build result looks wrong, the bug is in the source, not the cache.
|
||||
Use `touch src/somefile.zig` if you truly need to invalidate one file's
|
||||
cache line. Do not nuke the whole directory.
|
||||
- **NEVER** suggest a "different cache directory" as a workaround without
|
||||
an explicit, specific reason and explicit user approval. `--cache-dir`
|
||||
and `--global-cache-dir` flags exist; they are not toys.
|
||||
|
||||
**If a test result seems wrong or cached incorrectly:** the answer is
|
||||
ALWAYS to investigate the source code or build graph, not to delete
|
||||
cache. See "Test discovery" below — 99% of the time the "cached wrong
|
||||
result" is actually a test discovery problem, not a cache problem.
|
||||
|
||||
**If you find yourself typing `rm -rf` anywhere near a cache path: STOP.
|
||||
Ask the user instead.**
|
||||
|
||||
### NEVER run destructive git operations without explicit permission.
|
||||
|
||||
- No `git reset --hard`, `git clean -fdx`, `git push --force`, `git checkout .`
|
||||
on files with uncommitted work, unless the user asks for that specific
|
||||
operation by name.
|
||||
|
||||
### NEVER run `git add`, `git commit`, or `git push`. EVER.
|
||||
|
||||
- **The user commits. You do not.** Do not stage files. Do not create commits.
|
||||
Do not amend commits. Do not push. Do not suggest running these commands
|
||||
yourself "to save a step". This includes `git add -p`, `git add .`,
|
||||
`git add <file>`, `git commit -m ...`, `git commit --amend`, `git push`,
|
||||
and any `gh pr create` that would auto-stage or auto-commit.
|
||||
- If you are tempted to run any of these because "the work is done and it
|
||||
seems logical to commit" — STOP. The user has a review-and-commit workflow.
|
||||
Your job ends at a clean working tree with the changes ready to review.
|
||||
- The ONLY exception is when the user says, verbatim in the current turn,
|
||||
"commit this" / "make a commit" / "push it" / similar direct imperative.
|
||||
Do not extrapolate from earlier intent, a plan that mentioned milestones,
|
||||
or any indirect signal. If in doubt, ask — don't commit.
|
||||
- When a milestone plan says "STOP POINT — user reviews and commits": you
|
||||
stop. You do not commit. You do not prepare a commit. You hand off the
|
||||
working tree and wait.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
|
@ -103,6 +163,65 @@ All tests are inline (in `test` blocks within source files). There is a single t
|
|||
|
||||
Tests use `std.testing.allocator` (which detects leaks) and are structured as unit tests that verify individual functions. Network-dependent code is not tested (no mocking infrastructure).
|
||||
|
||||
### ⚠️ Test discovery — READ THIS BEFORE ADDING A NEW .zig FILE WITH TESTS ⚠️
|
||||
|
||||
**This gets fucked up every single session. Read it. Do what it says.**
|
||||
|
||||
`zig build test` runs tests from `test` blocks in files that are part of the
|
||||
test binary's compilation unit AND are reachable from `src/main.zig`'s
|
||||
import graph in a way that `refAllDeclsRecursive` actually visits the file
|
||||
struct itself (not just a type extracted from it).
|
||||
|
||||
**The failure mode:** you add `src/models/foo.zig` with 20 tests. You wire
|
||||
it into `src/service.zig` via `const foo = @import("models/foo.zig");` and
|
||||
re-export a type from `root.zig` as `pub const foo = @import("models/foo.zig");`.
|
||||
You run `zig build test` and the test count does NOT go up. The file
|
||||
**compiles** (because `foo.Bar` is referenced as a function return type),
|
||||
but the `test` blocks inside it are never run.
|
||||
|
||||
**Why:** `root.zig` is imported into `main.zig` via
|
||||
`const zfin = @import("root.zig");` — non-pub. `refAllDeclsRecursive` walks
|
||||
`@typeInfo(@This()).@"struct".decls` at main.zig, which only surfaces some
|
||||
of the decl graph. The `pub const foo = @import(...)` in root.zig is not
|
||||
reliably traversed from main.zig's test root, so `foo.zig`'s test blocks
|
||||
aren't collected even though the file is compiled.
|
||||
|
||||
**How to verify a new file's tests are discovered:**
|
||||
|
||||
1. Before relying on the test count, add a canary that MUST fail:
|
||||
```zig
|
||||
test "CANARY_DISCOVERY_CHECK_REMOVE_ME" {
|
||||
try std.testing.expect(false);
|
||||
}
|
||||
```
|
||||
2. Run `zig build test --summary all 2>&1 | grep -E "tests passed|error:"`.
|
||||
3. If the canary test appears in failures → discovery works, remove canary.
|
||||
4. If the canary does NOT appear and total count is unchanged → see fix below.
|
||||
|
||||
**Fix:** add an explicit import in the `test` block at the bottom of
|
||||
`src/main.zig`:
|
||||
|
||||
```zig
|
||||
test {
|
||||
std.testing.refAllDeclsRecursive(@This());
|
||||
_ = @import("models/foo.zig"); // ← new entry for each orphaned file
|
||||
}
|
||||
```
|
||||
|
||||
Adding it inside the `test` block (not at file scope as a `comptime` block)
|
||||
keeps the non-test build unaffected while guaranteeing the test binary
|
||||
sema-reaches the file and collects its test blocks.
|
||||
|
||||
**Rule of thumb:** after adding ANY new `.zig` file under `src/` that contains
|
||||
`test` blocks, run `zig build test --summary all 2>&1 | grep "tests passed"`
|
||||
BEFORE and AFTER the change. If the delta doesn't match
|
||||
`rg -c "^test " path/to/new_file.zig`, add the explicit import to main.zig's
|
||||
test block.
|
||||
|
||||
**Do NOT, under any circumstance, try to "fix" this by clearing the cache.**
|
||||
The cache is not the problem. The import graph is the problem. Re-read
|
||||
the prohibitions at the top of this file.
|
||||
|
||||
### Adding a new CLI command
|
||||
|
||||
1. Create `src/commands/newcmd.zig` with a `pub fn run(allocator, *DataService, symbol, color, *Writer) !void`
|
||||
|
|
|
|||
10
src/main.zig
10
src/main.zig
|
|
@ -781,6 +781,16 @@ test "parseGlobals: subcommand-local flag NOT consumed as global" {
|
|||
|
||||
// Single test binary: all source is in one module (file imports, no module
|
||||
// boundaries), so refAllDeclsRecursive discovers every test in the tree.
|
||||
//
|
||||
// IMPORTANT: refAllDeclsRecursive only walks files reachable from main.zig's
|
||||
// public decl graph. Files that are only reached indirectly (e.g. a module
|
||||
// re-exported from root.zig as `pub const foo = @import("foo.zig")` where
|
||||
// main.zig imports root.zig via a *non-pub* `const zfin = @import("root.zig")`)
|
||||
// are compiled (because their types are referenced) but their `test` blocks
|
||||
// are NOT collected. Add explicit `_ = @import("path/to/file.zig");` lines
|
||||
// in the test block below for any such orphaned test files.
|
||||
// See AGENTS.md → "Adding tests" for details.
|
||||
test {
|
||||
std.testing.refAllDeclsRecursive(@This());
|
||||
_ = @import("models/transaction_log.zig");
|
||||
}
|
||||
|
|
|
|||
548
src/models/transaction_log.zig
Normal file
548
src/models/transaction_log.zig
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
//! Transaction log — the wire format for `transaction_log.srf`.
|
||||
//!
|
||||
//! A sibling file to `portfolio.srf` / `accounts.srf` / `watchlist.srf`
|
||||
//! that declares real-world transactions which adjust interpretation of
|
||||
//! the portfolio diff. In v1, the only record kind is `transfer::` —
|
||||
//! used to mark money moving between accounts the user owns, so that
|
||||
//! the contributions pipeline doesn't double-count transfers as new
|
||||
//! contributions.
|
||||
//!
|
||||
//! ## Why this file exists
|
||||
//!
|
||||
//! `portfolio.srf` answers "what do I have" — state. But some events
|
||||
//! that affect contribution attribution aren't state; they're
|
||||
//! transactions. The biggest current gap: account transfers get
|
||||
//! double-counted as contributions because the receiving side's
|
||||
//! `new_*` lots count toward attribution and the sending side's
|
||||
//! `lot_removed` is silently ignored. A six-figure transfer from one
|
||||
//! account to another inflates reported "contributions" by that full
|
||||
//! amount. Existing classifications can't tell a transfer from a real
|
||||
//! external contribution/withdrawal just from the diff.
|
||||
//!
|
||||
//! ## One record per destination
|
||||
//!
|
||||
//! A transfer record pins exactly ONE destination — a specific lot (by
|
||||
//! `symbol@open_date`) OR the literal token `cash`. Sweeps and partial
|
||||
//! investments are recorded as multiple records sharing
|
||||
//! `(date, from, to)` but differing in `dest_lot`. This keeps each
|
||||
//! record self-validating and avoids multi-lot allocation ordering
|
||||
//! concerns.
|
||||
//!
|
||||
//! ## Example records
|
||||
//!
|
||||
//! ```
|
||||
//! # Simple cash deposit
|
||||
//! transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||||
//!
|
||||
//! # Partial attribution: pre-existing cash + transfer → single stock lot
|
||||
//! transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||
//!
|
||||
//! # Sweep into basket + residual (two records, same date/from/to)
|
||||
//! transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||
//! transfer::2026-05-02,type::cash,amount:num:4700,from::Acct A,to::Acct B,dest_lot::cash
|
||||
//! ```
|
||||
//!
|
||||
//! ## v1 scope
|
||||
//!
|
||||
//! - Only `transfer::` records (no buys/sells/dividends — those stay
|
||||
//! inferred from the portfolio diff).
|
||||
//! - Only `type::cash` is wired downstream. `type::in_kind` parses
|
||||
//! successfully but is rejected by the contributions matcher with
|
||||
//! an "in-kind transfers not yet supported" message.
|
||||
//! - No historical reconstruction — forward-looking only.
|
||||
//!
|
||||
//! See `REPORT.md` §5 for the full usage guide and
|
||||
//! `src/commands/contributions.zig` for the classifier integration.
|
||||
|
||||
const std = @import("std");
|
||||
const srf = @import("srf");
|
||||
const Date = @import("date.zig").Date;
|
||||
|
||||
const logger = std.log.scoped(.transaction_log);
|
||||
|
||||
/// Kind of transfer. Only `cash` is wired into the contributions
|
||||
/// classifier in v1. `in_kind` parses successfully so the file format
|
||||
/// is forward-compatible, but the matcher will reject records with
|
||||
/// this type until per-symbol in-kind matching is implemented.
|
||||
pub const TransferType = enum {
|
||||
cash,
|
||||
in_kind,
|
||||
};
|
||||
|
||||
/// Where a transfer landed inside the destination account.
|
||||
///
|
||||
/// Either a specific lot (identified by its symbol + open_date, which
|
||||
/// together disambiguate lots within a single account in the common
|
||||
/// case) or the literal token `cash` (the transfer ended up as cash
|
||||
/// balance on the destination account).
|
||||
pub const DestLot = union(enum) {
|
||||
lot: LotRef,
|
||||
cash: void,
|
||||
|
||||
pub const LotRef = struct {
|
||||
symbol: []const u8,
|
||||
open_date: Date,
|
||||
};
|
||||
|
||||
/// srf parser hook. Accepts `cash` (case-insensitive) or
|
||||
/// `SYMBOL@YYYY-MM-DD`. Any other shape is rejected.
|
||||
pub fn srfParse(str: []const u8) !DestLot {
|
||||
if (std.ascii.eqlIgnoreCase(str, "cash")) return .{ .cash = {} };
|
||||
const at = std.mem.indexOfScalar(u8, str, '@') orelse return error.InvalidDestLot;
|
||||
if (at == 0) return error.InvalidDestLot;
|
||||
if (at + 1 >= str.len) return error.InvalidDestLot;
|
||||
const sym = str[0..at];
|
||||
const date_str = str[at + 1 ..];
|
||||
const date = Date.parse(date_str) catch return error.InvalidDestLot;
|
||||
return .{ .lot = .{ .symbol = sym, .open_date = date } };
|
||||
}
|
||||
|
||||
/// srf serializer hook. Emits `cash` or `SYMBOL@YYYY-MM-DD`.
|
||||
/// Allocates the output buffer via `allocator` — caller (the SRF
|
||||
/// OwnedRecord machinery) manages the lifetime.
|
||||
pub fn srfFormat(
|
||||
self: DestLot,
|
||||
allocator: std.mem.Allocator,
|
||||
comptime field_name: []const u8,
|
||||
) !srf.Value {
|
||||
_ = field_name;
|
||||
return switch (self) {
|
||||
.cash => .{ .string = try allocator.dupe(u8, "cash") },
|
||||
.lot => |l| blk: {
|
||||
// SYMBOL up to ~20 chars + '@' + 10-char date.
|
||||
const buf = try std.fmt.allocPrint(allocator, "{s}@", .{l.symbol});
|
||||
defer allocator.free(buf);
|
||||
var out = try allocator.alloc(u8, buf.len + 10);
|
||||
@memcpy(out[0..buf.len], buf);
|
||||
_ = l.open_date.format(out[buf.len..][0..10]);
|
||||
break :blk .{ .string = out };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Equality for tests and duplicate-dest_lot detection in the matcher.
|
||||
pub fn eql(self: DestLot, other: DestLot) bool {
|
||||
return switch (self) {
|
||||
.cash => other == .cash,
|
||||
.lot => |a| switch (other) {
|
||||
.cash => false,
|
||||
.lot => |b| a.open_date.days == b.open_date.days and std.mem.eql(u8, a.symbol, b.symbol),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// One transfer record. All string fields are owned by the containing
|
||||
/// `TransactionLog` when the record was produced by
|
||||
/// `parseTransactionLogFile` — the log's allocator frees them on
|
||||
/// `deinit`. Records constructed by hand for tests can use any
|
||||
/// lifetime the caller prefers.
|
||||
///
|
||||
/// The first field `transfer` is named to match the SRF on-wire record
|
||||
/// tag — `transfer::<date>,...`. SRF's `fields.to(T)` coerces fields
|
||||
/// by name-matching against the struct, so `transfer: Date` maps the
|
||||
/// record tag's value (the date) into this field. Other code refers to
|
||||
/// it as `r.transfer` (reads as "the date this transfer is keyed by").
|
||||
pub const TransferRecord = struct {
|
||||
transfer: Date,
|
||||
type: TransferType = .cash,
|
||||
amount: f64,
|
||||
from: []const u8,
|
||||
to: []const u8,
|
||||
dest_lot: DestLot,
|
||||
note: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Parsed transaction log. `transfers` is allocator-owned; all string
|
||||
/// fields on each record (including the symbol inside `dest_lot.lot`)
|
||||
/// are also owned by the log's allocator.
|
||||
pub const TransactionLog = struct {
|
||||
transfers: []TransferRecord,
|
||||
allocator: std.mem.Allocator,
|
||||
|
||||
pub fn deinit(self: *TransactionLog) void {
|
||||
for (self.transfers) |r| {
|
||||
self.allocator.free(r.from);
|
||||
self.allocator.free(r.to);
|
||||
if (r.note) |n| self.allocator.free(n);
|
||||
switch (r.dest_lot) {
|
||||
.cash => {},
|
||||
.lot => |l| self.allocator.free(l.symbol),
|
||||
}
|
||||
}
|
||||
self.allocator.free(self.transfers);
|
||||
}
|
||||
|
||||
/// Return transfers whose `transfer` date falls within `[start, end]`
|
||||
/// inclusive. The returned slice is allocator-owned — caller must
|
||||
/// free it.
|
||||
///
|
||||
/// Works only because `parseTransactionLogFile` preserves file
|
||||
/// order. If callers ever need chronological ordering regardless
|
||||
/// of file layout, sort on the way out instead of on the way in —
|
||||
/// file order is sometimes meaningful for a human reviewer
|
||||
/// (grouping related records together).
|
||||
pub fn transfersInWindow(
|
||||
self: *const TransactionLog,
|
||||
allocator: std.mem.Allocator,
|
||||
start: Date,
|
||||
end: Date,
|
||||
) ![]const TransferRecord {
|
||||
var out: std.ArrayList(TransferRecord) = .empty;
|
||||
errdefer out.deinit(allocator);
|
||||
for (self.transfers) |r| {
|
||||
if (r.transfer.days < start.days) continue;
|
||||
if (r.transfer.days > end.days) continue;
|
||||
try out.append(allocator, r);
|
||||
}
|
||||
return try out.toOwnedSlice(allocator);
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse `data` (the contents of a `transaction_log.srf` file) into a
|
||||
/// `TransactionLog`. String fields on each returned record are duped
|
||||
/// into `allocator`, so `data` can be freed immediately after this
|
||||
/// call returns successfully.
|
||||
///
|
||||
/// Malformed records are silently skipped — matches the resilience
|
||||
/// pattern in `parseAccountsFile` / `parseClassificationFile`. The
|
||||
/// only hard errors are allocator failures and SRF-level parse errors
|
||||
/// that prevent the iterator from starting at all.
|
||||
///
|
||||
/// The SRF record layout is `transfer::<date>,type::<t>,amount:num:<n>,
|
||||
/// from::<a>,to::<b>,dest_lot::<dl>[,note::<n>]`. SRF's
|
||||
/// `fields.to(TransferRecord)` does the coercion: each key matches a
|
||||
/// struct field by name, defaults fill in elided optional fields,
|
||||
/// `DestLot.srfParse` handles `dest_lot`, and `TransferType` gets its
|
||||
/// enum value from the string.
|
||||
pub fn parseTransactionLogFile(
|
||||
allocator: std.mem.Allocator,
|
||||
data: []const u8,
|
||||
) !TransactionLog {
|
||||
var out: std.ArrayList(TransferRecord) = .empty;
|
||||
errdefer {
|
||||
for (out.items) |r| {
|
||||
allocator.free(r.from);
|
||||
allocator.free(r.to);
|
||||
if (r.note) |n| allocator.free(n);
|
||||
switch (r.dest_lot) {
|
||||
.cash => {},
|
||||
.lot => |l| allocator.free(l.symbol),
|
||||
}
|
||||
}
|
||||
out.deinit(allocator);
|
||||
}
|
||||
|
||||
var reader = std.Io.Reader.fixed(data);
|
||||
var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData;
|
||||
defer it.deinit();
|
||||
|
||||
while (try it.next()) |fields| {
|
||||
const parsed = fields.to(TransferRecord) catch |err| {
|
||||
logger.warn("skipping malformed transfer record: {s}", .{@errorName(err)});
|
||||
continue;
|
||||
};
|
||||
// String fields on `parsed` point into the iterator's internal
|
||||
// buffer — dupe them before the next `it.next()` call.
|
||||
const dest_lot_owned: DestLot = switch (parsed.dest_lot) {
|
||||
.cash => .{ .cash = {} },
|
||||
.lot => |l| .{ .lot = .{
|
||||
.symbol = try allocator.dupe(u8, l.symbol),
|
||||
.open_date = l.open_date,
|
||||
} },
|
||||
};
|
||||
errdefer switch (dest_lot_owned) {
|
||||
.cash => {},
|
||||
.lot => |l| allocator.free(l.symbol),
|
||||
};
|
||||
try out.append(allocator, .{
|
||||
.transfer = parsed.transfer,
|
||||
.type = parsed.type,
|
||||
.amount = parsed.amount,
|
||||
.from = try allocator.dupe(u8, parsed.from),
|
||||
.to = try allocator.dupe(u8, parsed.to),
|
||||
.dest_lot = dest_lot_owned,
|
||||
.note = if (parsed.note) |n| try allocator.dupe(u8, n) else null,
|
||||
});
|
||||
}
|
||||
|
||||
return .{
|
||||
.transfers = try out.toOwnedSlice(allocator),
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "DestLot.srfParse: cash token (lowercase)" {
|
||||
const d = try DestLot.srfParse("cash");
|
||||
try testing.expect(d == .cash);
|
||||
}
|
||||
|
||||
test "DestLot.srfParse: cash token (mixed case)" {
|
||||
const d = try DestLot.srfParse("Cash");
|
||||
try testing.expect(d == .cash);
|
||||
const d2 = try DestLot.srfParse("CASH");
|
||||
try testing.expect(d2 == .cash);
|
||||
}
|
||||
|
||||
test "DestLot.srfParse: SYMBOL@DATE" {
|
||||
const d = try DestLot.srfParse("SYM@2026-05-03");
|
||||
try testing.expect(d == .lot);
|
||||
try testing.expectEqualStrings("SYM", d.lot.symbol);
|
||||
try testing.expectEqual(Date.fromYmd(2026, 5, 3).days, d.lot.open_date.days);
|
||||
}
|
||||
|
||||
test "DestLot.srfParse: multi-char symbol with hyphen" {
|
||||
const d = try DestLot.srfParse("SYM-ABC@2026-05-03");
|
||||
try testing.expect(d == .lot);
|
||||
try testing.expectEqualStrings("SYM-ABC", d.lot.symbol);
|
||||
}
|
||||
|
||||
test "DestLot.srfParse: missing @ rejected (non-cash)" {
|
||||
try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM2026-05-03"));
|
||||
}
|
||||
|
||||
test "DestLot.srfParse: empty symbol rejected" {
|
||||
try testing.expectError(error.InvalidDestLot, DestLot.srfParse("@2026-05-03"));
|
||||
}
|
||||
|
||||
test "DestLot.srfParse: missing date rejected" {
|
||||
try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM@"));
|
||||
}
|
||||
|
||||
test "DestLot.srfParse: malformed date rejected" {
|
||||
try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM@2026/05/03"));
|
||||
try testing.expectError(error.InvalidDestLot, DestLot.srfParse("SYM@not-a-date"));
|
||||
}
|
||||
|
||||
test "DestLot.srfFormat: cash" {
|
||||
var buf: [64]u8 = undefined;
|
||||
var fba = std.heap.FixedBufferAllocator.init(&buf);
|
||||
const v = try (DestLot{ .cash = {} }).srfFormat(fba.allocator(), "dest_lot");
|
||||
try testing.expectEqualStrings("cash", v.string);
|
||||
}
|
||||
|
||||
test "DestLot.srfFormat: lot round-trip" {
|
||||
var buf: [64]u8 = undefined;
|
||||
var fba = std.heap.FixedBufferAllocator.init(&buf);
|
||||
const orig: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
|
||||
const v = try orig.srfFormat(fba.allocator(), "dest_lot");
|
||||
try testing.expectEqualStrings("SYM@2026-05-03", v.string);
|
||||
// Round-trip back through parse
|
||||
const parsed = try DestLot.srfParse(v.string);
|
||||
try testing.expect(orig.eql(parsed));
|
||||
}
|
||||
|
||||
test "DestLot.eql: cash vs cash" {
|
||||
try testing.expect((DestLot{ .cash = {} }).eql(.{ .cash = {} }));
|
||||
}
|
||||
|
||||
test "DestLot.eql: cash vs lot" {
|
||||
const c: DestLot = .{ .cash = {} };
|
||||
const l: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
|
||||
try testing.expect(!c.eql(l));
|
||||
try testing.expect(!l.eql(c));
|
||||
}
|
||||
|
||||
test "DestLot.eql: same lot" {
|
||||
const a: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
|
||||
const b: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
|
||||
try testing.expect(a.eql(b));
|
||||
}
|
||||
|
||||
test "DestLot.eql: different symbol" {
|
||||
const a: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
|
||||
const b: DestLot = .{ .lot = .{ .symbol = "SYM2", .open_date = Date.fromYmd(2026, 5, 3) } };
|
||||
try testing.expect(!a.eql(b));
|
||||
}
|
||||
|
||||
test "DestLot.eql: different date" {
|
||||
const a: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 3) } };
|
||||
const b: DestLot = .{ .lot = .{ .symbol = "SYM", .open_date = Date.fromYmd(2026, 5, 4) } };
|
||||
try testing.expect(!a.eql(b));
|
||||
}
|
||||
|
||||
test "parseTransactionLogFile: empty file" {
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
try testing.expectEqual(@as(usize, 0), log.transfers.len);
|
||||
}
|
||||
|
||||
test "parseTransactionLogFile: single cash transfer" {
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), log.transfers.len);
|
||||
const r = log.transfers[0];
|
||||
try testing.expectEqual(Date.fromYmd(2026, 5, 2).days, r.transfer.days);
|
||||
try testing.expectEqual(TransferType.cash, r.type);
|
||||
try testing.expectEqual(@as(f64, 5000), r.amount);
|
||||
try testing.expectEqualStrings("Acct A", r.from);
|
||||
try testing.expectEqualStrings("Acct B", r.to);
|
||||
try testing.expect(r.dest_lot == .cash);
|
||||
try testing.expect(r.note == null);
|
||||
}
|
||||
|
||||
test "parseTransactionLogFile: single lot-destination transfer" {
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-02,type::cash,amount:num:7000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03,note::paycheck
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), log.transfers.len);
|
||||
const r = log.transfers[0];
|
||||
try testing.expect(r.dest_lot == .lot);
|
||||
try testing.expectEqualStrings("SYM", r.dest_lot.lot.symbol);
|
||||
try testing.expectEqual(Date.fromYmd(2026, 5, 3).days, r.dest_lot.lot.open_date.days);
|
||||
try testing.expect(r.note != null);
|
||||
try testing.expectEqualStrings("paycheck", r.note.?);
|
||||
}
|
||||
|
||||
test "parseTransactionLogFile: sweep encoded as two records" {
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-02,type::cash,amount:num:145300,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||
\\transfer::2026-05-02,type::cash,amount:num:4700,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
try testing.expectEqual(@as(usize, 2), log.transfers.len);
|
||||
|
||||
try testing.expectEqual(@as(f64, 145300), log.transfers[0].amount);
|
||||
try testing.expect(log.transfers[0].dest_lot == .lot);
|
||||
try testing.expectEqualStrings("SYM", log.transfers[0].dest_lot.lot.symbol);
|
||||
|
||||
try testing.expectEqual(@as(f64, 4700), log.transfers[1].amount);
|
||||
try testing.expect(log.transfers[1].dest_lot == .cash);
|
||||
|
||||
// Sanity: both records preserved their (date, from, to) pairing.
|
||||
try testing.expectEqual(log.transfers[0].transfer.days, log.transfers[1].transfer.days);
|
||||
try testing.expectEqualStrings(log.transfers[0].from, log.transfers[1].from);
|
||||
try testing.expectEqualStrings(log.transfers[0].to, log.transfers[1].to);
|
||||
}
|
||||
|
||||
test "parseTransactionLogFile: type defaults to cash when elided" {
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-02,amount:num:5000,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), log.transfers.len);
|
||||
try testing.expectEqual(TransferType.cash, log.transfers[0].type);
|
||||
}
|
||||
|
||||
test "parseTransactionLogFile: type::in_kind parses but is preserved (rejected downstream)" {
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-02,type::in_kind,amount:num:50000,from::Acct A,to::Acct B,dest_lot::SYM@2026-05-03
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), log.transfers.len);
|
||||
try testing.expectEqual(TransferType.in_kind, log.transfers[0].type);
|
||||
}
|
||||
|
||||
test "parseTransactionLogFile: malformed record skipped, subsequent record survives" {
|
||||
// Resilience contract: one bad record doesn't wedge the parser.
|
||||
//
|
||||
// This is the only malformation shape we can test cleanly. The
|
||||
// other two fail in ways that break the test runner itself:
|
||||
//
|
||||
// 1. Bad `dest_lot` value (e.g. `dest_lot::garbage-no-at`):
|
||||
// `DestLot.srfParse` returns `error.InvalidDestLot`. SRF
|
||||
// logs that at `err` level from inside `fields.to`, and the
|
||||
// Zig test runner counts `log.err` calls BEFORE applying
|
||||
// `std.testing.log_level` — so the test is marked "logged
|
||||
// errors" regardless of how the test tries to suppress it.
|
||||
// 2. Wrong value shape for a typed field (e.g. `amount::text`
|
||||
// where `amount: f64` expects a `:num:` value): SRF's
|
||||
// `coerce` panics on the union-tag mismatch
|
||||
// (`@floatCast(val.?.number)` while `val.?` is `.string`).
|
||||
//
|
||||
// Missing-required-field is the one path SRF handles cleanly —
|
||||
// `fields.to` returns `FieldNotFoundOnFieldWithoutDefaultValue`
|
||||
// (logged only at `debug` level). That's what we exercise here.
|
||||
//
|
||||
// TODO: upstream a per-call suppression knob on SRF (or downgrade
|
||||
// the custom-parse log to `warn`) and re-enable a `dest_lot`-
|
||||
// shape malformation test. Until then, `DestLot.srfParse`'s unit
|
||||
// tests above cover that parser in isolation.
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-02,type::cash,amount:num:5000,from::Acct A,dest_lot::cash
|
||||
\\transfer::2026-05-03,type::cash,amount:num:3000,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
// First record missing `to` → skipped; second record survives.
|
||||
try testing.expectEqual(@as(usize, 1), log.transfers.len);
|
||||
try testing.expectEqual(@as(f64, 3000), log.transfers[0].amount);
|
||||
}
|
||||
|
||||
test "transfersInWindow: inclusive on both ends" {
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-04-30,type::cash,amount:num:100,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\transfer::2026-05-01,type::cash,amount:num:200,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\transfer::2026-05-15,type::cash,amount:num:300,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\transfer::2026-05-31,type::cash,amount:num:400,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\transfer::2026-06-01,type::cash,amount:num:500,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
|
||||
const slice = try log.transfersInWindow(
|
||||
testing.allocator,
|
||||
Date.fromYmd(2026, 5, 1),
|
||||
Date.fromYmd(2026, 5, 31),
|
||||
);
|
||||
defer testing.allocator.free(slice);
|
||||
try testing.expectEqual(@as(usize, 3), slice.len);
|
||||
try testing.expectEqual(@as(f64, 200), slice[0].amount);
|
||||
try testing.expectEqual(@as(f64, 300), slice[1].amount);
|
||||
try testing.expectEqual(@as(f64, 400), slice[2].amount);
|
||||
}
|
||||
|
||||
test "transfersInWindow: empty window returns empty slice" {
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-01,type::cash,amount:num:100,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
|
||||
const slice = try log.transfersInWindow(
|
||||
testing.allocator,
|
||||
Date.fromYmd(2027, 1, 1),
|
||||
Date.fromYmd(2027, 12, 31),
|
||||
);
|
||||
defer testing.allocator.free(slice);
|
||||
try testing.expectEqual(@as(usize, 0), slice.len);
|
||||
}
|
||||
|
||||
test "parseTransactionLogFile: preserves file order (not sorted)" {
|
||||
var log = try parseTransactionLogFile(testing.allocator,
|
||||
\\#!srfv1
|
||||
\\transfer::2026-05-15,type::cash,amount:num:3,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\transfer::2026-05-01,type::cash,amount:num:1,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\transfer::2026-05-08,type::cash,amount:num:2,from::Acct A,to::Acct B,dest_lot::cash
|
||||
\\
|
||||
);
|
||||
defer log.deinit();
|
||||
try testing.expectEqual(@as(usize, 3), log.transfers.len);
|
||||
// File order preserved — see `transfersInWindow` docstring for why.
|
||||
try testing.expectEqual(@as(f64, 3), log.transfers[0].amount);
|
||||
try testing.expectEqual(@as(f64, 1), log.transfers[1].amount);
|
||||
try testing.expectEqual(@as(f64, 2), log.transfers[2].amount);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ const Config = @import("Config.zig");
|
|||
const cache = @import("cache/store.zig");
|
||||
const srf = @import("srf");
|
||||
const analysis = @import("analytics/analysis.zig");
|
||||
const transaction_log = @import("models/transaction_log.zig");
|
||||
const TwelveData = @import("providers/twelvedata.zig").TwelveData;
|
||||
const Polygon = @import("providers/polygon.zig").Polygon;
|
||||
const Fmp = @import("providers/fmp.zig").Fmp;
|
||||
|
|
@ -1470,6 +1471,25 @@ pub const DataService = struct {
|
|||
|
||||
return analysis.parseAccountsFile(self.allocator(), data) catch null;
|
||||
}
|
||||
|
||||
/// Load and parse `transaction_log.srf` from the same directory as
|
||||
/// the given portfolio path. Returns null if the file doesn't
|
||||
/// exist or can't be parsed — the contributions pipeline falls
|
||||
/// back to the pre-transaction-log behavior (no transfer netting)
|
||||
/// when null is returned.
|
||||
///
|
||||
/// Caller owns the returned `TransactionLog` and must call
|
||||
/// `deinit()`.
|
||||
pub fn loadTransferLog(self: *DataService, portfolio_path: []const u8) ?transaction_log.TransactionLog {
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||||
const path = std.fmt.allocPrint(self.allocator(), "{s}transaction_log.srf", .{portfolio_path[0..dir_end]}) catch return null;
|
||||
defer self.allocator().free(path);
|
||||
|
||||
const data = std.fs.cwd().readFileAlloc(self.allocator(), path, 1024 * 1024) catch return null;
|
||||
defer self.allocator().free(data);
|
||||
|
||||
return transaction_log.parseTransactionLogFile(self.allocator(), data) catch null;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue