track multiple portfolio files in past revisions as well
This commit is contained in:
parent
4d3aae2d69
commit
4d6060552a
7 changed files with 724 additions and 182 deletions
70
AGENTS.md
70
AGENTS.md
|
|
@ -341,9 +341,9 @@ Ask the user instead.**
|
||||||
add it as a new top-level section; don't leave the parent entry around
|
add it as a new top-level section; don't leave the parent entry around
|
||||||
as a historical wrapper.
|
as a historical wrapper.
|
||||||
- **`REPORT.md` is untracked on purpose.** It's a personal workflow doc
|
- **`REPORT.md` is untracked on purpose.** It's a personal workflow doc
|
||||||
living in the repo root only until it moves to `~/portfolio`. Edit it
|
living in the repo root only until it moves to the user's portfolio
|
||||||
freely when asked; don't treat it as part of the repo surface. Don't
|
directory. Edit it freely when asked; don't treat it as part of the
|
||||||
mention it in commit messages for unrelated work.
|
repo surface. Don't mention it in commit messages for unrelated work.
|
||||||
|
|
||||||
### Lint warnings — there are no "pre-existing" warnings
|
### Lint warnings — there are no "pre-existing" warnings
|
||||||
|
|
||||||
|
|
@ -465,7 +465,7 @@ across the codebase so search-and-replace stays trivial):
|
||||||
`smpl_1234`, `smpl-ira-1234`
|
`smpl_1234`, `smpl-ira-1234`
|
||||||
- Account numbers: `1234`, `5678`, `9012`, `3456`, `7890`, or
|
- Account numbers: `1234`, `5678`, `9012`, `3456`, `7890`, or
|
||||||
alphanumeric like `Z123`, `Z111`, `Z222`. Do not use real
|
alphanumeric like `Z123`, `Z111`, `Z222`. Do not use real
|
||||||
trailing-digit values from `~/portfolio/`.
|
trailing-digit values from the user's actual accounts file.
|
||||||
- Portfolio file names: `portfolio_other.srf`, never
|
- Portfolio file names: `portfolio_other.srf`, never
|
||||||
`portfolio_<real-name>.srf`.
|
`portfolio_<real-name>.srf`.
|
||||||
- Schwab/Fidelity-style: `Schwab Trust`, `Sample Inherited IRA`, `Roth
|
- Schwab/Fidelity-style: `Schwab Trust`, `Sample Inherited IRA`, `Roth
|
||||||
|
|
@ -474,23 +474,22 @@ across the codebase so search-and-replace stays trivial):
|
||||||
|
|
||||||
**Things that are NEVER OK in source:**
|
**Things that are NEVER OK in source:**
|
||||||
|
|
||||||
- Real first names of family members. (No `Emil`, `Sample`,
|
- Real first names of family members.
|
||||||
`Riley`, `Mom`, `Dad`, etc.)
|
- Real account-number trailing digits used in the user's actual
|
||||||
- Real account-number trailing digits used in
|
accounts file. Any number that came from a real brokerage entry
|
||||||
`~/portfolio/accounts.srf` (e.g. `6135`, `3522`, `7891`, `716`,
|
is PII, regardless of length or format.
|
||||||
`901`, `503`, `311`, `152`, `118`, `Z11111111`,
|
- Real portfolio filenames matching `portfolio_<real-name>.srf`.
|
||||||
`222222222`, etc.). Any number that came from a real
|
- Composite identifiers that combine real names and real
|
||||||
brokerage entry is PII.
|
account-number digits.
|
||||||
- Real portfolio filenames like `portfolio_other.srf`,
|
- The user's actual portfolio directory path (leaks filesystem
|
||||||
`portfolio_<name>.srf`.
|
layout) — refer generically to "the portfolio's `history/`
|
||||||
- Composite identifiers that combine the above (`Sample Roth IRA`,
|
directory" or similar.
|
||||||
`Joint Account ...1234`, etc.).
|
|
||||||
|
|
||||||
**Workflow rule when adding a test based on a real-world
|
**Workflow rule when adding a test based on a real-world
|
||||||
scenario:**
|
scenario:**
|
||||||
|
|
||||||
1. Reproduce the bug locally with real data (in `~/portfolio/`,
|
1. Reproduce the bug locally with real data (the user's portfolio
|
||||||
never staged).
|
directory; never staged).
|
||||||
2. Write the test using **placeholder names and numbers** that
|
2. Write the test using **placeholder names and numbers** that
|
||||||
preserve the structural shape of the bug (same string
|
preserve the structural shape of the bug (same string
|
||||||
lengths, same pattern of digits-vs-letters, same separator
|
lengths, same pattern of digits-vs-letters, same separator
|
||||||
|
|
@ -503,24 +502,35 @@ scenario:**
|
||||||
|
|
||||||
**Workflow rule when finding existing PII:**
|
**Workflow rule when finding existing PII:**
|
||||||
|
|
||||||
If you grep for the placeholder vocabulary while working in any
|
If you grep for PII while working in any file and find a real
|
||||||
file and find a real name or number that snuck in, fix it in
|
name or number that snuck in, fix it in the same change. Don't
|
||||||
the same change. Don't add to TODO; PII removal is never
|
add to TODO; PII removal is never optional, and it never lands
|
||||||
optional, and it never lands in a separate commit unless the
|
in a separate commit unless the user explicitly asks.
|
||||||
user explicitly asks.
|
|
||||||
|
|
||||||
**One-line grep that should ALWAYS return zero non-`ie_data.csv`
|
**Pre-commit grep recipe.** Read the user's actual accounts
|
||||||
hits before committing:**
|
metadata locally (NOT staged, NOT in this file), extract the
|
||||||
|
identifying tokens, and grep `src/` for any of them. Conceptual
|
||||||
|
shape:
|
||||||
|
|
||||||
```
|
```
|
||||||
grep -rn "\bMom\b\|Sample\|Joint Account\|portfolio_other\|\bEmil\b\|Fidelity Sample\|6135\|Z11111111" src/ \
|
# Build the alternation FROM the user's accounts file at runtime —
|
||||||
| grep -v ie_data.csv
|
# do NOT hardcode the values into source-tracked tooling.
|
||||||
|
local_pii_tokens=$(awk -F: '...' "$ZFIN_HOME/accounts.srf" ...)
|
||||||
|
grep -rn -E "$local_pii_tokens" src/ | grep -v ie_data.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
(Update the alternation as new real-world identifiers come up.
|
The grep should always return zero non-`ie_data.csv` hits before
|
||||||
The `ie_data.csv` exclusion is because the Shiller dataset
|
committing. The `ie_data.csv` exclusion is because the Shiller
|
||||||
contains coincidental numeric matches in historical-year fields
|
dataset contains coincidental numeric matches in historical-year
|
||||||
that aren't PII.)
|
fields that aren't PII.
|
||||||
|
|
||||||
|
If you're uncertain whether something is PII, **ask before
|
||||||
|
committing.** PII can be surgically removed from a working
|
||||||
|
tree, but once it's in `git log` it's effectively permanent.
|
||||||
|
**That includes this file** — never put real identifiers in
|
||||||
|
AGENTS.md or any other tracked file as "examples of what to look
|
||||||
|
for." The placeholder vocabulary above is the only safe way to
|
||||||
|
illustrate the patterns.
|
||||||
|
|
||||||
If you're uncertain whether something is PII, **ask before
|
If you're uncertain whether something is PII, **ask before
|
||||||
committing.** PII can be surgically removed from a working
|
committing.** PII can be surgically removed from a working
|
||||||
|
|
|
||||||
|
|
@ -1003,7 +1003,8 @@ test "parsePaste: trailing cash section emits a cash position" {
|
||||||
test "parsePaste: cash section absent is a no-op" {
|
test "parsePaste: cash section absent is a no-op" {
|
||||||
// When the user only pastes the positions table (no cash
|
// When the user only pastes the positions table (no cash
|
||||||
// section), parsePaste returns just the positions. Regression
|
// section), parsePaste returns just the positions. Regression
|
||||||
// for the original 3522.txt-style paste shape.
|
// for the simpler positions-only paste shape (no trailing
|
||||||
|
// cash table).
|
||||||
const allocator = testing.allocator;
|
const allocator = testing.allocator;
|
||||||
const data =
|
const data =
|
||||||
"GSLC , popup\n" ++
|
"GSLC , popup\n" ++
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,14 @@ pub const RunCtx = struct {
|
||||||
/// portfolio file. Returns the default pattern's first match
|
/// portfolio file. Returns the default pattern's first match
|
||||||
/// when -p is not set; falls through to a literal default if
|
/// when -p is not set; falls through to a literal default if
|
||||||
/// nothing matched.
|
/// nothing matched.
|
||||||
|
///
|
||||||
|
/// **Do NOT use this for portfolio CONTENT loading.** Portfolio
|
||||||
|
/// content must come from the multi-file glob via
|
||||||
|
/// `cli.loadPortfolio` (live) or
|
||||||
|
/// `portfolio_loader.loadPortfolioFromPathsAtRev` (git
|
||||||
|
/// historical). This singular helper is for choosing ONE
|
||||||
|
/// concrete path to derive sibling files from — never for
|
||||||
|
/// reading lots out of.
|
||||||
pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath {
|
pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath {
|
||||||
var paths = self.resolvePortfolioPaths() catch {
|
var paths = self.resolvePortfolioPaths() catch {
|
||||||
return .{ .path = zfin.Config.default_portfolio_filename, .resolved = null };
|
return .{ .path = zfin.Config.default_portfolio_filename, .resolved = null };
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,17 @@
|
||||||
//!
|
//!
|
||||||
//! Flow:
|
//! Flow:
|
||||||
//! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p).
|
//! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p).
|
||||||
|
//! The first resolved file's directory determines the
|
||||||
|
//! `history/` location (sibling-of-first convention).
|
||||||
//! 2. Derive `history/` dir as `dirname(portfolio.srf)/history/`.
|
//! 2. Derive `history/` dir as `dirname(portfolio.srf)/history/`.
|
||||||
//! 3. Load portfolio composition. Normally from the working-copy
|
//! 3. Load portfolio composition as the **union of all matched
|
||||||
//! file; with `--as-of`, from git history at or before the
|
//! portfolio files** (multi-file glob support). Normally from
|
||||||
//! requested date (falling back to working copy if pre-git).
|
//! the working tree via `cli.loadPortfolio`; with `--as-of`,
|
||||||
|
//! from git history at the repo-wide latest sha ≤ the
|
||||||
|
//! requested date via `loadPortfolioFromPathsAtRev`. Files
|
||||||
|
//! that didn't exist at that sha are silently skipped — the
|
||||||
|
//! union just doesn't include those lots. Falls back to
|
||||||
|
//! working copy if git is unavailable.
|
||||||
//! 4. Refresh the candle cache via `cli.loadPortfolioPrices`
|
//! 4. Refresh the candle cache via `cli.loadPortfolioPrices`
|
||||||
//! (skipped under `--as-of` — past candles don't change).
|
//! (skipped under `--as-of` — past candles don't change).
|
||||||
//! 5. Compute `as_of_date`: explicit `--as-of` wins; otherwise mode
|
//! 5. Compute `as_of_date`: explicit `--as-of` wins; otherwise mode
|
||||||
|
|
@ -17,6 +24,11 @@
|
||||||
//! `--force` wasn't passed, skip (exit 0, stderr message).
|
//! `--force` wasn't passed, skip (exit 0, stderr message).
|
||||||
//! 8. Build the snapshot records and write them atomically.
|
//! 8. Build the snapshot records and write them atomically.
|
||||||
//!
|
//!
|
||||||
|
//! The snapshot file itself is a SINGLE union'd record set —
|
||||||
|
//! consumers (compare, projections --vs, TUI history tab) read one
|
||||||
|
//! `<date>-portfolio.srf` per date and don't need to know how many
|
||||||
|
//! source files contributed to it.
|
||||||
|
//!
|
||||||
//! Flags:
|
//! Flags:
|
||||||
//! --force overwrite existing snapshot for as_of_date
|
//! --force overwrite existing snapshot for as_of_date
|
||||||
//! --dry-run compute + print to stdout, do not write
|
//! --dry-run compute + print to stdout, do not write
|
||||||
|
|
@ -40,6 +52,7 @@ const portfolio_mod = @import("../models/portfolio.zig");
|
||||||
const Date = @import("../Date.zig");
|
const Date = @import("../Date.zig");
|
||||||
const model = @import("../models/snapshot.zig");
|
const model = @import("../models/snapshot.zig");
|
||||||
const git = @import("../git.zig");
|
const git = @import("../git.zig");
|
||||||
|
const portfolio_loader = @import("../portfolio_loader.zig");
|
||||||
|
|
||||||
// Re-export record types so callers that reach `commands/snapshot.zig`
|
// Re-export record types so callers that reach `commands/snapshot.zig`
|
||||||
// (tests, mostly) still see the familiar names. New code should prefer
|
// (tests, mostly) still see the familiar names. New code should prefer
|
||||||
|
|
@ -150,6 +163,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
const out = ctx.out;
|
const out = ctx.out;
|
||||||
const color = ctx.color;
|
const color = ctx.color;
|
||||||
const now_s = ctx.now_s;
|
const now_s = ctx.now_s;
|
||||||
|
const today = ctx.today;
|
||||||
|
|
||||||
const force = parsed.force;
|
const force = parsed.force;
|
||||||
const dry_run = parsed.dry_run;
|
const dry_run = parsed.dry_run;
|
||||||
|
|
@ -160,22 +174,24 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
defer pf.deinit(allocator);
|
defer pf.deinit(allocator);
|
||||||
const portfolio_path = pf.path;
|
const portfolio_path = pf.path;
|
||||||
|
|
||||||
// Load portfolio bytes. In normal (no --as-of) mode this is the
|
// Load portfolio. In normal (no --as-of) mode this is the
|
||||||
// current working-copy of portfolio.srf. With --as-of, we first try
|
// current working-copy union of all matched portfolio files.
|
||||||
// to retrieve the portfolio state from git history at or before the
|
// With --as-of, we first try to retrieve the portfolio state
|
||||||
// target date — that gives accurate composition for past snapshots.
|
// from git history at or before the target date — that gives
|
||||||
// If git lookup fails (portfolio not tracked, no commits before the
|
// accurate composition for past snapshots. If git lookup fails
|
||||||
// date, git unavailable), we warn and fall back to the working copy,
|
// (portfolio not tracked, no commits before the date, git
|
||||||
// which at least approximates "positions the user currently holds"
|
// unavailable), we warn and fall back to the working-copy
|
||||||
// and is better than erroring out.
|
// union, which at least approximates "positions the user
|
||||||
const pf_data = try loadPortfolioAtDate(io, allocator, portfolio_path, as_of_override);
|
// currently holds" and is better than erroring out.
|
||||||
defer allocator.free(pf_data);
|
//
|
||||||
|
// `portfolio_path` (singular, first-resolved) is used only for
|
||||||
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
|
// sibling-derivation (history dir, snapshot file path); the
|
||||||
cli.stderrPrint(io, "Error parsing portfolio file.\n");
|
// portfolio CONTENT comes from the multi-file glob.
|
||||||
return error.WriteFailed;
|
var loaded = try loadPortfolioForSnapshot(ctx, today, as_of_override);
|
||||||
};
|
defer loaded.deinit(allocator);
|
||||||
defer portfolio.deinit();
|
var portfolio = loaded.portfolio;
|
||||||
|
// We don't deinit `portfolio` separately — `loaded.deinit`
|
||||||
|
// handles it.
|
||||||
|
|
||||||
if (portfolio.lots.len == 0) {
|
if (portfolio.lots.len == 0) {
|
||||||
cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n");
|
cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n");
|
||||||
|
|
@ -393,80 +409,88 @@ pub fn deriveSnapshotPath(
|
||||||
/// file" — the file's current state IS its mtime state. A clean exit
|
/// file" — the file's current state IS its mtime state. A clean exit
|
||||||
/// lets bulk-backfill loops keep moving.
|
/// lets bulk-backfill loops keep moving.
|
||||||
///
|
///
|
||||||
/// Caller owns returned bytes.
|
/// Caller owns the returned `LoadedPortfolio`.
|
||||||
fn loadPortfolioAtDate(
|
///
|
||||||
io: std.Io,
|
/// In normal (no `--as-of`) mode this loads the working-copy union
|
||||||
allocator: std.mem.Allocator,
|
/// of all matched portfolio files via `cli.loadPortfolio`. With
|
||||||
portfolio_path: []const u8,
|
/// `--as-of`, it discovers the repo-wide latest sha at or before
|
||||||
|
/// the target date (`git.shaAtOrBefore`) and reads each file in
|
||||||
|
/// the glob at that sha (`portfolio_loader.loadPortfolioFromPathsAtRev`).
|
||||||
|
/// Files that didn't exist at that sha are silently skipped — the
|
||||||
|
/// union just doesn't include those lots, which is correct for
|
||||||
|
/// "snapshot of state on this date."
|
||||||
|
///
|
||||||
|
/// On git lookup failure (not in a repo, no commit before the
|
||||||
|
/// target, etc.), warns and falls back to the working-copy union
|
||||||
|
/// — better to capture today's state than fail entirely.
|
||||||
|
fn loadPortfolioForSnapshot(
|
||||||
|
ctx: *framework.RunCtx,
|
||||||
|
today: Date,
|
||||||
as_of: ?Date,
|
as_of: ?Date,
|
||||||
) ![]const u8 {
|
) !portfolio_loader.LoadedPortfolio {
|
||||||
|
const io = ctx.io;
|
||||||
|
const allocator = ctx.allocator;
|
||||||
|
|
||||||
const target = as_of orelse {
|
const target = as_of orelse {
|
||||||
// Normal mode — just read the file.
|
// Normal mode — load working-copy union via the shared
|
||||||
return std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
// multi-file loader. `today` is used as the as_of for
|
||||||
cli.stderrPrint(io, "Error reading portfolio file: ");
|
// position computation.
|
||||||
cli.stderrPrint(io, @errorName(err));
|
return cli.loadPortfolio(ctx, today) orelse return error.WriteFailed;
|
||||||
cli.stderrPrint(io, "\n");
|
|
||||||
return err;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try git first.
|
// --as-of mode: find the repo-wide sha at or before target.
|
||||||
if (loadPortfolioFromGit(io, allocator, portfolio_path, target)) |bytes| return bytes else |err| switch (err) {
|
// Need a single concrete path to discover the repo from; use
|
||||||
error.NotInGitRepo, error.GitUnavailable, error.PathMissingInRev, error.UnknownRevision, error.NoCommitBeforeDate => {
|
// the first resolved portfolio path.
|
||||||
// Fall through to working-copy fallback below.
|
const pf = ctx.resolvePortfolioPath();
|
||||||
var buf: [256]u8 = undefined;
|
defer pf.deinit(allocator);
|
||||||
const msg = std.fmt.bufPrint(
|
const portfolio_path = pf.path;
|
||||||
&buf,
|
|
||||||
"warning: no git history for portfolio at {f}; using working copy as approximation\n",
|
const info = git.findRepo(io, allocator, portfolio_path) catch |err| switch (err) {
|
||||||
.{target},
|
error.NotInGitRepo, error.GitUnavailable => {
|
||||||
) catch "warning: no git history for portfolio at requested date\n";
|
warnGitFallback(io, target, "no git repo");
|
||||||
cli.stderrPrint(io, msg);
|
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||||||
},
|
},
|
||||||
else => |e| return e,
|
else => return err,
|
||||||
}
|
|
||||||
|
|
||||||
return std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| {
|
|
||||||
cli.stderrPrint(io, "Error reading portfolio file: ");
|
|
||||||
cli.stderrPrint(io, @errorName(err));
|
|
||||||
cli.stderrPrint(io, "\n");
|
|
||||||
return err;
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/// `git show`-based retrieval of portfolio bytes at or before `target`.
|
|
||||||
/// Returns the raw bytes (caller owns) or an error classifying the
|
|
||||||
/// failure mode so `loadPortfolioAtDate` can decide whether to fall
|
|
||||||
/// back.
|
|
||||||
fn loadPortfolioFromGit(
|
|
||||||
io: std.Io,
|
|
||||||
allocator: std.mem.Allocator,
|
|
||||||
portfolio_path: []const u8,
|
|
||||||
target: Date,
|
|
||||||
) ![]const u8 {
|
|
||||||
const info = try git.findRepo(io, allocator, portfolio_path);
|
|
||||||
defer allocator.free(info.root);
|
defer allocator.free(info.root);
|
||||||
defer allocator.free(info.rel_path);
|
defer allocator.free(info.rel_path);
|
||||||
|
|
||||||
// List all commits that touched this path, newest-first.
|
var date_buf: [10]u8 = undefined;
|
||||||
const commits = try git.listCommitsTouching(io, allocator, info.root, info.rel_path, null);
|
const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{target}) catch "????-??-??";
|
||||||
defer git.freeCommitTouches(allocator, commits);
|
const sha_opt = git.shaAtOrBefore(io, allocator, info.root, date_str) catch |err| switch (err) {
|
||||||
|
error.GitUnavailable, error.GitLogFailed => {
|
||||||
if (commits.len == 0) return error.PathMissingInRev;
|
warnGitFallback(io, target, "git lookup failed");
|
||||||
|
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||||||
// Find the latest commit whose committer timestamp falls on or before
|
},
|
||||||
// the end of `target` day (23:59:59 UTC). The list is newest-first
|
else => return err,
|
||||||
// per git log's default order, so we scan linearly.
|
|
||||||
const target_end_ts = target.toEpoch() + std.time.s_per_day - 1;
|
|
||||||
|
|
||||||
const sha: ?[]const u8 = blk: {
|
|
||||||
for (commits) |c| {
|
|
||||||
if (c.timestamp <= target_end_ts) break :blk c.commit;
|
|
||||||
}
|
|
||||||
break :blk null;
|
|
||||||
};
|
};
|
||||||
|
const sha = sha_opt orelse {
|
||||||
|
warnGitFallback(io, target, "no commit at or before target date");
|
||||||
|
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||||||
|
};
|
||||||
|
defer allocator.free(sha);
|
||||||
|
|
||||||
const chosen = sha orelse return error.NoCommitBeforeDate;
|
// Resolve the full glob and load each file at `sha`.
|
||||||
return try git.show(io, allocator, info.root, chosen, info.rel_path);
|
var resolved = ctx.resolvePortfolioPaths() catch {
|
||||||
|
warnGitFallback(io, target, "could not resolve portfolio glob");
|
||||||
|
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||||||
|
};
|
||||||
|
defer resolved.deinit();
|
||||||
|
|
||||||
|
return portfolio_loader.loadPortfolioFromPathsAtRev(io, allocator, resolved.paths, sha, target) orelse {
|
||||||
|
warnGitFallback(io, target, "could not load portfolio at rev");
|
||||||
|
return cli.loadPortfolio(ctx, target) orelse return error.WriteFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warnGitFallback(io: std.Io, target: Date, reason: []const u8) void {
|
||||||
|
var buf: [256]u8 = undefined;
|
||||||
|
const msg = std.fmt.bufPrint(
|
||||||
|
&buf,
|
||||||
|
"warning: {s} for portfolio at {f}; using working copy as approximation\n",
|
||||||
|
.{ reason, target },
|
||||||
|
) catch "warning: git history unavailable; using working copy as approximation\n";
|
||||||
|
cli.stderrPrint(io, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quote-date / as_of_date helpers ──────────────────────────
|
// ── Quote-date / as_of_date helpers ──────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
//! Imported portfolio history values from a manually-curated spreadsheet.
|
//! Imported portfolio history values from a manually-curated spreadsheet.
|
||||||
//!
|
//!
|
||||||
//! Pre-dates `~/portfolio/history/` snapshot capture. Contains weekly
|
//! Pre-dates the `history/` snapshot capture. Contains weekly
|
||||||
//! `(date, liquid, expected_return, projected_retirement)` tuples
|
//! `(date, liquid, expected_return, projected_retirement)` tuples
|
||||||
//! transcribed from a third-party spreadsheet (the "K+E Funds"
|
//! transcribed from a third-party spreadsheet (the "K+E Funds"
|
||||||
//! workbook for the primary portfolio, plus optional sibling
|
//! workbook for the primary portfolio, plus optional sibling
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
//! 1. The user exports the spreadsheet to CSV via `ssconvert -S`.
|
//! 1. The user exports the spreadsheet to CSV via `ssconvert -S`.
|
||||||
//! 2. `tools/import_values.zig` (a one-shot Zig program) consumes
|
//! 2. `tools/import_values.zig` (a one-shot Zig program) consumes
|
||||||
//! the CSV and emits an `imported_values.srf` next to the
|
//! the CSV and emits an `imported_values.srf` next to the
|
||||||
//! repo's `~/portfolio/history/` snapshot directory.
|
//! portfolio's `history/` snapshot directory.
|
||||||
//! 3. This module loads that SRF for downstream consumers
|
//! 3. This module loads that SRF for downstream consumers
|
||||||
//! (`zfin milestones`, projection overlay, forecast-vs-actual).
|
//! (`zfin milestones`, projection overlay, forecast-vs-actual).
|
||||||
//!
|
//!
|
||||||
|
|
|
||||||
77
src/git.zig
77
src/git.zig
|
|
@ -399,6 +399,52 @@ pub fn commitAtOrBeforeDate(
|
||||||
return try allocator.dupe(u8, trimmed);
|
return try allocator.dupe(u8, trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Repo-wide variant of `commitAtOrBeforeDate`. Returns the SHA of
|
||||||
|
/// the latest commit on HEAD whose committer-date is ≤ `date_iso`
|
||||||
|
/// 23:59:59 local, regardless of which paths the commit touched.
|
||||||
|
/// Returns null when no such commit exists.
|
||||||
|
///
|
||||||
|
/// Used by `zfin snapshot --as-of` to pick a single repo-wide
|
||||||
|
/// "as of date" reference; each portfolio file in the multi-file
|
||||||
|
/// glob is then read at that one SHA via `git show`. This produces
|
||||||
|
/// a coherent point-in-time snapshot — files that didn't exist at
|
||||||
|
/// that SHA (`error.PathMissingInRev`) are treated as absent
|
||||||
|
/// rather than as errors.
|
||||||
|
pub fn shaAtOrBefore(
|
||||||
|
io: std.Io,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
root: []const u8,
|
||||||
|
date_iso: []const u8,
|
||||||
|
) Error!?[]const u8 {
|
||||||
|
// Same end-of-day pinning as `commitAtOrBeforeDate` (see that
|
||||||
|
// function's comment). No `-- <path>` filter — we want the
|
||||||
|
// repo-wide latest.
|
||||||
|
const until_arg = try std.fmt.allocPrint(allocator, "--until={s} 23:59:59", .{date_iso});
|
||||||
|
defer allocator.free(until_arg);
|
||||||
|
|
||||||
|
const result = std.process.run(allocator, io, .{
|
||||||
|
.argv = &.{
|
||||||
|
"git", "-C", root,
|
||||||
|
"log", "-1", "--format=%H",
|
||||||
|
"HEAD", until_arg,
|
||||||
|
},
|
||||||
|
.stdout_limit = .limited(64 * 1024),
|
||||||
|
}) catch return error.GitUnavailable;
|
||||||
|
defer allocator.free(result.stdout);
|
||||||
|
defer allocator.free(result.stderr);
|
||||||
|
|
||||||
|
switch (result.term) {
|
||||||
|
.exited => |code| if (code != 0) return error.GitLogFailed,
|
||||||
|
else => return error.GitLogFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = std.mem.trim(u8, result.stdout, " \t\r\n");
|
||||||
|
if (trimmed.len == 0) return null;
|
||||||
|
if (trimmed.len < 40) return error.GitLogFailed;
|
||||||
|
for (trimmed) |c| if (!std.ascii.isHex(c)) return error.GitLogFailed;
|
||||||
|
return try allocator.dupe(u8, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the committer-date (Unix timestamp, seconds since epoch) of
|
/// Get the committer-date (Unix timestamp, seconds since epoch) of
|
||||||
/// a given ref. Accepts anything `git log -1 --format=%ct <ref>`
|
/// a given ref. Accepts anything `git log -1 --format=%ct <ref>`
|
||||||
/// accepts: SHAs, HEAD, HEAD~N. Used by the contributions pipeline
|
/// accepts: SHAs, HEAD, HEAD~N. Used by the contributions pipeline
|
||||||
|
|
@ -521,7 +567,8 @@ fn resolveSpec(io: std.Io, arena: std.mem.Allocator, repo: RepoInfo, spec: Commi
|
||||||
.git_ref => |r| r,
|
.git_ref => |r| r,
|
||||||
.date_at_or_before => |d| blk: {
|
.date_at_or_before => |d| blk: {
|
||||||
var buf: [10]u8 = undefined;
|
var buf: [10]u8 = undefined;
|
||||||
const date_str = std.fmt.bufPrint(&buf, "{f}", .{d}) catch unreachable;
|
// SAFETY: 10-byte buffer is exactly the size of "YYYY-MM-DD".
|
||||||
|
const date_str = std.fmt.bufPrint(&buf, "{f}", .{d}) catch buf[0..];
|
||||||
const sha = (try commitAtOrBeforeDate(io, arena, repo.root, repo.rel_path, date_str)) orelse
|
const sha = (try commitAtOrBeforeDate(io, arena, repo.root, repo.rel_path, date_str)) orelse
|
||||||
return error.NoCommitAtOrBefore;
|
return error.NoCommitAtOrBefore;
|
||||||
break :blk sha;
|
break :blk sha;
|
||||||
|
|
@ -655,6 +702,34 @@ test "commitAtOrBeforeDate: --until=DATE covers end of day, not current time-of-
|
||||||
if (sha_opt) |s| allocator.free(s);
|
if (sha_opt) |s| allocator.free(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "shaAtOrBefore returns a SHA for a past date in the ambient repo" {
|
||||||
|
// Repo-wide variant of `commitAtOrBeforeDate` — finds the latest
|
||||||
|
// commit on HEAD ≤ the given date, regardless of paths touched.
|
||||||
|
// Same future-date trick as the path-scoped variant: a date well
|
||||||
|
// beyond now should always return the tip of history.
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||||
|
defer allocator.free(info.root);
|
||||||
|
defer allocator.free(info.rel_path);
|
||||||
|
|
||||||
|
const sha_opt = shaAtOrBefore(std.testing.io, allocator, info.root, "2099-01-01") catch return;
|
||||||
|
try std.testing.expect(sha_opt != null);
|
||||||
|
const sha = sha_opt.?;
|
||||||
|
defer allocator.free(sha);
|
||||||
|
try std.testing.expect(sha.len == 40 or sha.len == 64);
|
||||||
|
for (sha) |c| try std.testing.expect(std.ascii.isHex(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "shaAtOrBefore returns null for date before repo existed" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const info = findRepo(std.testing.io, allocator, "build.zig") catch return;
|
||||||
|
defer allocator.free(info.root);
|
||||||
|
defer allocator.free(info.rel_path);
|
||||||
|
|
||||||
|
const sha_opt = shaAtOrBefore(std.testing.io, allocator, info.root, "1970-01-02") catch return;
|
||||||
|
try std.testing.expect(sha_opt == null);
|
||||||
|
}
|
||||||
|
|
||||||
test "resolveCommitRange: legacy clean → HEAD~1..HEAD" {
|
test "resolveCommitRange: legacy clean → HEAD~1..HEAD" {
|
||||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
defer arena_state.deinit();
|
defer arena_state.deinit();
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,19 @@
|
||||||
//! the TUI's reload-button path (re-uses the original resolved
|
//! the TUI's reload-button path (re-uses the original resolved
|
||||||
//! path slice without re-globbing).
|
//! path slice without re-globbing).
|
||||||
//!
|
//!
|
||||||
|
//! - `loadPortfolioFromPathsAtRev(io, alloc, paths, rev, as_of)` —
|
||||||
|
//! git-historical variant: read each file at `rev` via
|
||||||
|
//! `git.show` instead of from the working tree. Files that don't
|
||||||
|
//! exist at the rev are silently skipped (the union just doesn't
|
||||||
|
//! include those lots). Used by `zfin snapshot --as-of` to
|
||||||
|
//! capture point-in-time portfolio state across a multi-file
|
||||||
|
//! glob from git history.
|
||||||
|
//!
|
||||||
|
//! All three loaders share a single private `loadFromBytes` core
|
||||||
|
//! that does the deserialize + union-merge + position-compute +
|
||||||
|
//! symbol-extract pass. Tests target `loadFromBytes` directly with
|
||||||
|
//! synthetic byte literals to avoid filesystem I/O.
|
||||||
|
//!
|
||||||
//! - `PortfolioData` + `buildPortfolioData(...)` — second-stage
|
//! - `PortfolioData` + `buildPortfolioData(...)` — second-stage
|
||||||
//! pipeline: turn a `LoadedPortfolio` (or its parts) plus a
|
//! pipeline: turn a `LoadedPortfolio` (or its parts) plus a
|
||||||
//! `prices` map into a `PortfolioSummary` with allocations,
|
//! `prices` map into a `PortfolioSummary` with allocations,
|
||||||
|
|
@ -45,6 +58,7 @@ const std = @import("std");
|
||||||
const zfin = @import("root.zig");
|
const zfin = @import("root.zig");
|
||||||
const framework = @import("commands/framework.zig");
|
const framework = @import("commands/framework.zig");
|
||||||
const stderr = @import("stderr.zig");
|
const stderr = @import("stderr.zig");
|
||||||
|
const git = @import("git.zig");
|
||||||
|
|
||||||
// ── Portfolio loading ────────────────────────────────────────
|
// ── Portfolio loading ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -178,6 +192,119 @@ pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: [
|
||||||
return loadFromPaths(io, allocator, paths_owned, null, as_of);
|
return loadFromPaths(io, allocator, paths_owned, null, as_of);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `loadPortfolioFromPaths`, but reads each file from a git
|
||||||
|
/// revision instead of the working tree. The result is the union
|
||||||
|
/// of all files at that rev, deserialized + union-merged the same
|
||||||
|
/// way the live loader does.
|
||||||
|
///
|
||||||
|
/// Files that don't exist at the requested rev (e.g.
|
||||||
|
/// `portfolio_other.srf` was added later than `rev`) are silently
|
||||||
|
/// skipped — the union just doesn't include those lots. This is the
|
||||||
|
/// right behavior for `zfin snapshot --as-of <past-date>` against a
|
||||||
|
/// portfolio that's been split into multiple files over time.
|
||||||
|
///
|
||||||
|
/// All `paths` must live within the same git repository as the
|
||||||
|
/// first path. Repository discovery uses `git.findRepo` on the
|
||||||
|
/// first path; the rest derive their rel-paths by trimming the
|
||||||
|
/// repo-root prefix.
|
||||||
|
///
|
||||||
|
/// Returns null on:
|
||||||
|
/// - empty paths slice (no portfolio file to load);
|
||||||
|
/// - first path not in any git repo;
|
||||||
|
/// - any deserialize / merge / position-compute failure (same as
|
||||||
|
/// the working-tree loader).
|
||||||
|
///
|
||||||
|
/// `git.show` failures other than `PathMissingInRev` (e.g.
|
||||||
|
/// `UnknownRevision`) propagate as null; the function prints a
|
||||||
|
/// stderr message describing which file/rev failed.
|
||||||
|
pub fn loadPortfolioFromPathsAtRev(
|
||||||
|
io: std.Io,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
paths: []const []const u8,
|
||||||
|
rev: []const u8,
|
||||||
|
as_of: zfin.Date,
|
||||||
|
) ?LoadedPortfolio {
|
||||||
|
if (paths.len == 0) {
|
||||||
|
stderr.print(io, "Error: No portfolio file found\n");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover the repo from the first path. All other paths are
|
||||||
|
// assumed to live in the same repo (typical for sibling
|
||||||
|
// portfolio files in the same directory); we derive their
|
||||||
|
// rel-paths below.
|
||||||
|
const info = git.findRepo(io, allocator, paths[0]) catch {
|
||||||
|
var msg_buf: [512]u8 = undefined;
|
||||||
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: portfolio path not in a git repo: {s}\n", .{paths[0]}) catch "Error: portfolio path not in a git repo\n";
|
||||||
|
stderr.print(io, msg);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
defer allocator.free(info.root);
|
||||||
|
defer allocator.free(info.rel_path);
|
||||||
|
|
||||||
|
// Read each file at `rev`. On `PathMissingInRev` we substitute
|
||||||
|
// empty bytes; `loadFromBytes` knows to treat that as "file
|
||||||
|
// absent at this rev, skip without parsing." Any other git
|
||||||
|
// error is fatal.
|
||||||
|
var datas: std.ArrayList([]const u8) = .empty;
|
||||||
|
var datas_consumed = false;
|
||||||
|
defer if (!datas_consumed) {
|
||||||
|
for (datas.items) |d| allocator.free(d);
|
||||||
|
datas.deinit(allocator);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (paths) |abs_path| {
|
||||||
|
// Derive rel_path from this file's absolute path. The first
|
||||||
|
// file's rel_path was computed by findRepo; for the rest we
|
||||||
|
// strip the repo root prefix manually. realpath ensures
|
||||||
|
// canonicalization (the user could pass a non-canonical
|
||||||
|
// path).
|
||||||
|
const real = std.Io.Dir.cwd().realPathFileAlloc(io, abs_path, allocator) catch {
|
||||||
|
var msg_buf: [512]u8 = undefined;
|
||||||
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: cannot resolve real path for: {s}\n", .{abs_path}) catch "Error: cannot resolve real path\n";
|
||||||
|
stderr.print(io, msg);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
defer allocator.free(real);
|
||||||
|
|
||||||
|
const rel = if (std.mem.startsWith(u8, real, info.root) and real.len > info.root.len)
|
||||||
|
std.mem.trimStart(u8, real[info.root.len..], "/")
|
||||||
|
else
|
||||||
|
std.fs.path.basename(real);
|
||||||
|
|
||||||
|
const data = git.show(io, allocator, info.root, rev, rel) catch |err| switch (err) {
|
||||||
|
error.PathMissingInRev => empty_blk: {
|
||||||
|
// File didn't exist at this rev. Substitute empty
|
||||||
|
// bytes; loadFromBytes skips empty entries.
|
||||||
|
break :empty_blk allocator.dupe(u8, "") catch return null;
|
||||||
|
},
|
||||||
|
else => {
|
||||||
|
var msg_buf: [512]u8 = undefined;
|
||||||
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: git show {s}:{s}: {t}\n", .{ rev, rel, err }) catch "Error: git show failed\n";
|
||||||
|
stderr.print(io, msg);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
datas.append(allocator, data) catch {
|
||||||
|
allocator.free(data);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const datas_owned = datas.toOwnedSlice(allocator) catch return null;
|
||||||
|
datas_consumed = true;
|
||||||
|
|
||||||
|
// Hand off ownership of the slice we just dupe'd from the
|
||||||
|
// caller. `loadFromBytes` frees both `paths_owned` and
|
||||||
|
// `datas_owned` on any failure.
|
||||||
|
const paths_owned = allocator.dupe([]const u8, paths) catch {
|
||||||
|
for (datas_owned) |d| allocator.free(d);
|
||||||
|
allocator.free(datas_owned);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return loadFromBytes(io, allocator, paths_owned, null, datas_owned, as_of);
|
||||||
|
}
|
||||||
|
|
||||||
/// Internal: load+merge given a pre-resolved paths slice. The slice
|
/// Internal: load+merge given a pre-resolved paths slice. The slice
|
||||||
/// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`).
|
/// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`).
|
||||||
/// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to
|
/// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to
|
||||||
|
|
@ -190,21 +317,8 @@ fn loadFromPaths(
|
||||||
resolved_paths_opt: ?zfin.Config.ResolvedPaths,
|
resolved_paths_opt: ?zfin.Config.ResolvedPaths,
|
||||||
as_of: zfin.Date,
|
as_of: zfin.Date,
|
||||||
) ?LoadedPortfolio {
|
) ?LoadedPortfolio {
|
||||||
// On any error after this point we must free the slice we just
|
|
||||||
// took ownership of, plus deinit the `resolved_paths_opt` so the
|
|
||||||
// path strings aren't leaked.
|
|
||||||
var error_cleanup_armed = true;
|
|
||||||
defer if (error_cleanup_armed) {
|
|
||||||
allocator.free(paths_owned);
|
|
||||||
if (resolved_paths_opt) |rp| rp.deinit();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read every file up front; bail on first error.
|
// Read every file up front; bail on first error.
|
||||||
var file_datas: std.ArrayList([]const u8) = .empty;
|
var file_datas: std.ArrayList([]const u8) = .empty;
|
||||||
errdefer {
|
|
||||||
for (file_datas.items) |d| allocator.free(d);
|
|
||||||
file_datas.deinit(allocator);
|
|
||||||
}
|
|
||||||
for (paths_owned) |p| {
|
for (paths_owned) |p| {
|
||||||
const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch {
|
const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch {
|
||||||
var msg_buf: [512]u8 = undefined;
|
var msg_buf: [512]u8 = undefined;
|
||||||
|
|
@ -212,64 +326,109 @@ fn loadFromPaths(
|
||||||
stderr.print(io, msg);
|
stderr.print(io, msg);
|
||||||
for (file_datas.items) |d| allocator.free(d);
|
for (file_datas.items) |d| allocator.free(d);
|
||||||
file_datas.deinit(allocator);
|
file_datas.deinit(allocator);
|
||||||
|
allocator.free(paths_owned);
|
||||||
|
if (resolved_paths_opt) |rp| rp.deinit();
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
file_datas.append(allocator, data) catch {
|
file_datas.append(allocator, data) catch {
|
||||||
allocator.free(data);
|
allocator.free(data);
|
||||||
for (file_datas.items) |d| allocator.free(d);
|
for (file_datas.items) |d| allocator.free(d);
|
||||||
file_datas.deinit(allocator);
|
file_datas.deinit(allocator);
|
||||||
|
allocator.free(paths_owned);
|
||||||
|
if (resolved_paths_opt) |rp| rp.deinit();
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const file_datas_owned = file_datas.toOwnedSlice(allocator) catch {
|
||||||
|
for (file_datas.items) |d| allocator.free(d);
|
||||||
|
file_datas.deinit(allocator);
|
||||||
|
allocator.free(paths_owned);
|
||||||
|
if (resolved_paths_opt) |rp| rp.deinit();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hand off to the bytes-only path. `loadFromBytes` takes
|
||||||
|
// ownership of all four slices and frees them on any error.
|
||||||
|
return loadFromBytes(io, allocator, paths_owned, resolved_paths_opt, file_datas_owned, as_of);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes-only union-merge core. Takes ownership of `paths_owned`,
|
||||||
|
/// `resolved_paths_opt` (if non-null), and `file_datas_owned` (each
|
||||||
|
/// element must be `allocator`-owned). Frees them on any error.
|
||||||
|
///
|
||||||
|
/// Tests use this directly with synthetic byte literals; production
|
||||||
|
/// callers come through `loadFromPaths` (working-tree reads) or
|
||||||
|
/// `loadPortfolioFromPathsAtRev` (git-historical reads). All three
|
||||||
|
/// share this single deserialize-merge-positions-syms pass.
|
||||||
|
///
|
||||||
|
/// `paths_owned` and `file_datas_owned` must have the same length;
|
||||||
|
/// each `file_datas_owned[i]` is the bytes loaded from
|
||||||
|
/// `paths_owned[i]`. Path strings are used only for diagnostics
|
||||||
|
/// (which file failed to parse) and in the returned struct.
|
||||||
|
fn loadFromBytes(
|
||||||
|
io: std.Io,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
paths_owned: []const []const u8,
|
||||||
|
resolved_paths_opt: ?zfin.Config.ResolvedPaths,
|
||||||
|
file_datas_owned: []const []const u8,
|
||||||
|
as_of: zfin.Date,
|
||||||
|
) ?LoadedPortfolio {
|
||||||
|
std.debug.assert(paths_owned.len == file_datas_owned.len);
|
||||||
|
|
||||||
|
// Cleanup is staged: lots live on `merged` until
|
||||||
|
// `toOwnedSlice`, then move into `combined` (which has its
|
||||||
|
// own deinit). The two cleanup paths are mutually exclusive,
|
||||||
|
// tracked by `lots_owner`. On success, neither fires.
|
||||||
|
var merged: std.ArrayList(zfin.Lot) = .empty;
|
||||||
|
var combined: zfin.Portfolio = .{ .lots = &.{}, .allocator = allocator };
|
||||||
|
const LotsOwner = enum { merged_list, combined_struct, none };
|
||||||
|
var lots_owner: LotsOwner = .merged_list;
|
||||||
|
var success = false;
|
||||||
|
|
||||||
|
defer if (!success) {
|
||||||
|
switch (lots_owner) {
|
||||||
|
.merged_list => {
|
||||||
|
for (merged.items) |lot| {
|
||||||
|
allocator.free(lot.symbol);
|
||||||
|
if (lot.note) |n| allocator.free(n);
|
||||||
|
if (lot.account) |a| allocator.free(a);
|
||||||
|
if (lot.ticker) |t| allocator.free(t);
|
||||||
|
if (lot.underlying) |u| allocator.free(u);
|
||||||
|
}
|
||||||
|
merged.deinit(allocator);
|
||||||
|
},
|
||||||
|
.combined_struct => combined.deinit(),
|
||||||
|
.none => {},
|
||||||
|
}
|
||||||
|
for (file_datas_owned) |d| allocator.free(d);
|
||||||
|
allocator.free(file_datas_owned);
|
||||||
|
allocator.free(paths_owned);
|
||||||
|
if (resolved_paths_opt) |rp| rp.deinit();
|
||||||
|
};
|
||||||
|
|
||||||
// Deserialize each into an owned Portfolio, then merge their
|
// Deserialize each into an owned Portfolio, then merge their
|
||||||
// lot slices into a single combined slice. We can't simply
|
// lot slices into a single combined slice. We can't simply
|
||||||
// concat the underlying slices because each Portfolio expects
|
// concat the underlying slices because each Portfolio expects
|
||||||
// to free its own lots in `deinit()`; instead, we steal each
|
// to free its own lots in `deinit()`; instead, we steal each
|
||||||
// Portfolio's lots[] (string fields are already dupe'd into
|
// Portfolio's lots[] (string fields are already dupe'd into
|
||||||
// `allocator`) and free only the empty Portfolio struct.
|
// `allocator`) and free only the empty Portfolio struct.
|
||||||
var merged: std.ArrayList(zfin.Lot) = .empty;
|
for (file_datas_owned, 0..) |data, idx| {
|
||||||
errdefer {
|
// Empty bytes are a legitimate "file absent" signal —
|
||||||
for (merged.items) |lot| {
|
// `loadPortfolioFromPathsAtRev` uses this to indicate a
|
||||||
allocator.free(lot.symbol);
|
// file that didn't exist at the requested git rev. Skip
|
||||||
if (lot.note) |n| allocator.free(n);
|
// without trying to parse.
|
||||||
if (lot.account) |a| allocator.free(a);
|
if (data.len == 0) continue;
|
||||||
if (lot.ticker) |t| allocator.free(t);
|
|
||||||
if (lot.underlying) |u| allocator.free(u);
|
|
||||||
}
|
|
||||||
merged.deinit(allocator);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (file_datas.items, 0..) |data, idx| {
|
|
||||||
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
|
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
|
||||||
var msg_buf: [512]u8 = undefined;
|
var msg_buf: [512]u8 = undefined;
|
||||||
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n";
|
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n";
|
||||||
stderr.print(io, msg);
|
stderr.print(io, msg);
|
||||||
for (merged.items) |lot| {
|
|
||||||
allocator.free(lot.symbol);
|
|
||||||
if (lot.note) |n| allocator.free(n);
|
|
||||||
if (lot.account) |a| allocator.free(a);
|
|
||||||
if (lot.ticker) |t| allocator.free(t);
|
|
||||||
if (lot.underlying) |u| allocator.free(u);
|
|
||||||
}
|
|
||||||
merged.deinit(allocator);
|
|
||||||
for (file_datas.items) |d| allocator.free(d);
|
|
||||||
file_datas.deinit(allocator);
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
for (portfolio.lots) |lot| {
|
for (portfolio.lots) |lot| {
|
||||||
merged.append(allocator, lot) catch {
|
merged.append(allocator, lot) catch {
|
||||||
portfolio.deinit();
|
portfolio.deinit();
|
||||||
for (merged.items) |existing| {
|
|
||||||
allocator.free(existing.symbol);
|
|
||||||
if (existing.note) |n| allocator.free(n);
|
|
||||||
if (existing.account) |a| allocator.free(a);
|
|
||||||
if (existing.ticker) |t| allocator.free(t);
|
|
||||||
if (existing.underlying) |u| allocator.free(u);
|
|
||||||
}
|
|
||||||
merged.deinit(allocator);
|
|
||||||
for (file_datas.items) |d| allocator.free(d);
|
|
||||||
file_datas.deinit(allocator);
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -278,42 +437,25 @@ fn loadFromPaths(
|
||||||
allocator.free(portfolio.lots);
|
allocator.free(portfolio.lots);
|
||||||
}
|
}
|
||||||
|
|
||||||
const merged_slice = merged.toOwnedSlice(allocator) catch {
|
const merged_slice = merged.toOwnedSlice(allocator) catch return null;
|
||||||
for (file_datas.items) |d| allocator.free(d);
|
combined = .{
|
||||||
file_datas.deinit(allocator);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
var combined: zfin.Portfolio = .{
|
|
||||||
.lots = merged_slice,
|
.lots = merged_slice,
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
};
|
};
|
||||||
|
lots_owner = .combined_struct;
|
||||||
|
|
||||||
const positions = combined.positions(as_of, allocator) catch {
|
const positions = combined.positions(as_of, allocator) catch {
|
||||||
combined.deinit();
|
|
||||||
for (file_datas.items) |d| allocator.free(d);
|
|
||||||
file_datas.deinit(allocator);
|
|
||||||
stderr.print(io, "Error: Cannot compute positions\n");
|
stderr.print(io, "Error: Cannot compute positions\n");
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const syms = combined.stockSymbols(allocator) catch {
|
const syms = combined.stockSymbols(allocator) catch {
|
||||||
allocator.free(positions);
|
allocator.free(positions);
|
||||||
combined.deinit();
|
|
||||||
for (file_datas.items) |d| allocator.free(d);
|
|
||||||
file_datas.deinit(allocator);
|
|
||||||
stderr.print(io, "Error: Cannot get stock symbols\n");
|
stderr.print(io, "Error: Cannot get stock symbols\n");
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const file_datas_owned = file_datas.toOwnedSlice(allocator) catch {
|
success = true;
|
||||||
allocator.free(syms);
|
|
||||||
allocator.free(positions);
|
|
||||||
combined.deinit();
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
error_cleanup_armed = false;
|
|
||||||
return .{
|
return .{
|
||||||
.paths = paths_owned,
|
.paths = paths_owned,
|
||||||
.resolved_paths = resolved_paths_opt,
|
.resolved_paths = resolved_paths_opt,
|
||||||
|
|
@ -399,3 +541,285 @@ pub fn buildPortfolioData(
|
||||||
.snapshots = snapshots,
|
.snapshots = snapshots,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── loadFromBytes tests (in-memory; no disk I/O) ─────────────
|
||||||
|
//
|
||||||
|
// All tests here construct synthetic SRF byte literals and route
|
||||||
|
// them through `loadFromBytes` directly. Both production callers
|
||||||
|
// (working-tree `loadFromPaths` and git-historical
|
||||||
|
// `loadPortfolioFromPathsAtRev`) share this core, so unit-testing
|
||||||
|
// it covers the union-merge / parse-error / empty-bytes behavior
|
||||||
|
// without spinning up temp filesystems or git repos.
|
||||||
|
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
/// Test helper: dupe a slice of byte-string literals into the
|
||||||
|
/// allocator so `loadFromBytes` can free them on its own.
|
||||||
|
fn dupeBytes(allocator: std.mem.Allocator, datas: []const []const u8) ![]const []const u8 {
|
||||||
|
var owned = try allocator.alloc([]const u8, datas.len);
|
||||||
|
errdefer {
|
||||||
|
for (owned[0..]) |d| allocator.free(d);
|
||||||
|
allocator.free(owned);
|
||||||
|
}
|
||||||
|
var filled: usize = 0;
|
||||||
|
for (datas, 0..) |d, i| {
|
||||||
|
owned[i] = try allocator.dupe(u8, d);
|
||||||
|
filled = i + 1;
|
||||||
|
}
|
||||||
|
return owned;
|
||||||
|
}
|
||||||
|
|
||||||
|
test "loadFromBytes: union of two synthetic SRF files" {
|
||||||
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
|
const file_a =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
const file_b =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
|
||||||
|
const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" });
|
||||||
|
const datas = try dupeBytes(allocator, &.{ file_a, file_b });
|
||||||
|
|
||||||
|
var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
||||||
|
try testing.expect(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer loaded.deinit(allocator);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
|
||||||
|
// Lots are appended in file order.
|
||||||
|
try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
|
||||||
|
try testing.expectEqualStrings("MSFT", loaded.portfolio.lots[1].symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "loadFromBytes: single file with valid contents" {
|
||||||
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
|
const file_a =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
|
||||||
|
const paths = try allocator.dupe([]const u8, &.{"a.srf"});
|
||||||
|
const datas = try dupeBytes(allocator, &.{file_a});
|
||||||
|
|
||||||
|
var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
||||||
|
try testing.expect(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer loaded.deinit(allocator);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len);
|
||||||
|
try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "loadFromBytes: empty-bytes entry is treated as absent file" {
|
||||||
|
// Production use case: `loadPortfolioFromPathsAtRev` returns
|
||||||
|
// empty bytes for a path that didn't exist at the requested
|
||||||
|
// git rev. The union-merge silently skips it instead of
|
||||||
|
// failing — matches "snapshot at a date before this file
|
||||||
|
// was added" semantics.
|
||||||
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
|
const file_a =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
const file_b: []const u8 = "";
|
||||||
|
|
||||||
|
const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" });
|
||||||
|
const datas = try dupeBytes(allocator, &.{ file_a, file_b });
|
||||||
|
|
||||||
|
var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
||||||
|
try testing.expect(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer loaded.deinit(allocator);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len);
|
||||||
|
try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "loadFromBytes: all-empty bytes returns empty portfolio" {
|
||||||
|
// Production use case: snapshot at a rev that predates ALL
|
||||||
|
// tracked portfolio files. No lots; no error.
|
||||||
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
|
const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" });
|
||||||
|
const datas = try dupeBytes(allocator, &.{ "", "" });
|
||||||
|
|
||||||
|
var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
||||||
|
try testing.expect(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer loaded.deinit(allocator);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(usize, 0), loaded.portfolio.lots.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── loadPortfolioFromPathsAtRev tests (disk + git) ───────────
|
||||||
|
//
|
||||||
|
// These spin up a temp git repo so they exercise the full
|
||||||
|
// `git.show + skip-on-PathMissingInRev` contract end-to-end.
|
||||||
|
// Kept minimal (one happy-path, one missing-file case); the
|
||||||
|
// bulk of the logic is in `loadFromBytes`, covered above.
|
||||||
|
|
||||||
|
/// Run a one-shot git command in `cwd` for test setup. Panics on
|
||||||
|
/// failure — these tests can't proceed without a working repo.
|
||||||
|
fn gitInTestRepo(allocator: std.mem.Allocator, cwd: []const u8, argv: []const []const u8) !void {
|
||||||
|
const full_argv = try allocator.alloc([]const u8, argv.len + 3);
|
||||||
|
defer allocator.free(full_argv);
|
||||||
|
full_argv[0] = "git";
|
||||||
|
full_argv[1] = "-C";
|
||||||
|
full_argv[2] = cwd;
|
||||||
|
@memcpy(full_argv[3..], argv);
|
||||||
|
const result = try std.process.run(allocator, testing.io, .{
|
||||||
|
.argv = full_argv,
|
||||||
|
.stdout_limit = .limited(64 * 1024),
|
||||||
|
});
|
||||||
|
defer allocator.free(result.stdout);
|
||||||
|
defer allocator.free(result.stderr);
|
||||||
|
switch (result.term) {
|
||||||
|
.exited => |code| if (code != 0) {
|
||||||
|
std.debug.print("git command failed (code {d}): {s}\nstderr: {s}\n", .{ code, std.mem.join(allocator, " ", argv) catch "?", result.stderr });
|
||||||
|
return error.GitFailed;
|
||||||
|
},
|
||||||
|
else => return error.GitFailed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "loadPortfolioFromPathsAtRev: union of two committed files at HEAD" {
|
||||||
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
|
// Skip if `git` isn't on PATH (CI sandbox without git).
|
||||||
|
{
|
||||||
|
const probe = std.process.run(allocator, testing.io, .{
|
||||||
|
.argv = &.{ "git", "--version" },
|
||||||
|
.stdout_limit = .limited(1024),
|
||||||
|
}) catch return;
|
||||||
|
defer allocator.free(probe.stdout);
|
||||||
|
defer allocator.free(probe.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmp = std.testing.tmpDir(.{});
|
||||||
|
defer tmp.cleanup();
|
||||||
|
|
||||||
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
|
const dir_len = try tmp.dir.realPathFile(testing.io, ".", &path_buf);
|
||||||
|
const dir = path_buf[0..dir_len];
|
||||||
|
|
||||||
|
const file_a =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
const file_b =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a });
|
||||||
|
try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b });
|
||||||
|
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "init", "-q" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "config", "user.email", "test@example.com" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "config", "user.name", "Test" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "config", "commit.gpgsign", "false" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "add", "portfolio.srf", "portfolio_other.srf" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "commit", "-q", "-m", "initial" });
|
||||||
|
|
||||||
|
const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||||||
|
defer allocator.free(p1);
|
||||||
|
const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" });
|
||||||
|
defer allocator.free(p2);
|
||||||
|
const paths = [_][]const u8{ p1, p2 };
|
||||||
|
|
||||||
|
var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &paths, "HEAD", zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
||||||
|
try testing.expect(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer loaded.deinit(allocator);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "loadPortfolioFromPathsAtRev: file added later is silently skipped at earlier rev" {
|
||||||
|
const allocator = testing.allocator;
|
||||||
|
|
||||||
|
// Skip if `git` isn't on PATH.
|
||||||
|
{
|
||||||
|
const probe = std.process.run(allocator, testing.io, .{
|
||||||
|
.argv = &.{ "git", "--version" },
|
||||||
|
.stdout_limit = .limited(1024),
|
||||||
|
}) catch return;
|
||||||
|
defer allocator.free(probe.stdout);
|
||||||
|
defer allocator.free(probe.stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmp = std.testing.tmpDir(.{});
|
||||||
|
defer tmp.cleanup();
|
||||||
|
|
||||||
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
|
const dir_len = try tmp.dir.realPathFile(testing.io, ".", &path_buf);
|
||||||
|
const dir = path_buf[0..dir_len];
|
||||||
|
|
||||||
|
const file_a =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a });
|
||||||
|
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "init", "-q" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "config", "user.email", "test@example.com" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "config", "user.name", "Test" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "config", "commit.gpgsign", "false" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "add", "portfolio.srf" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "commit", "-q", "-m", "initial" });
|
||||||
|
|
||||||
|
// Capture commit 1 SHA before adding file_b.
|
||||||
|
const result = try std.process.run(allocator, testing.io, .{
|
||||||
|
.argv = &.{ "git", "-C", dir, "rev-parse", "HEAD" },
|
||||||
|
.stdout_limit = .limited(1024),
|
||||||
|
});
|
||||||
|
defer allocator.free(result.stdout);
|
||||||
|
defer allocator.free(result.stderr);
|
||||||
|
const sha = std.mem.trim(u8, result.stdout, " \t\r\n");
|
||||||
|
const sha_owned = try allocator.dupe(u8, sha);
|
||||||
|
defer allocator.free(sha_owned);
|
||||||
|
|
||||||
|
// Add and commit a second portfolio file.
|
||||||
|
const file_b =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth
|
||||||
|
\\
|
||||||
|
;
|
||||||
|
try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "add", "portfolio_other.srf" });
|
||||||
|
try gitInTestRepo(allocator, dir, &.{ "commit", "-q", "-m", "add second" });
|
||||||
|
|
||||||
|
const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" });
|
||||||
|
defer allocator.free(p1);
|
||||||
|
const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" });
|
||||||
|
defer allocator.free(p2);
|
||||||
|
const paths = [_][]const u8{ p1, p2 };
|
||||||
|
|
||||||
|
// Load at commit 1 — second file didn't exist yet. Expect
|
||||||
|
// only AAPL from portfolio.srf, no error.
|
||||||
|
var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &paths, sha_owned, zfin.Date.fromYmd(2026, 5, 23)) orelse {
|
||||||
|
try testing.expect(false);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer loaded.deinit(allocator);
|
||||||
|
|
||||||
|
try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len);
|
||||||
|
try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue