diff --git a/src/analytics/valuation.zig b/src/analytics/valuation.zig index 4eb1472..e33f10c 100644 --- a/src/analytics/valuation.zig +++ b/src/analytics/valuation.zig @@ -154,6 +154,53 @@ pub fn netWorth(portfolio: portfolio_mod.Portfolio, summary: PortfolioSummary) f return summary.total_value + portfolio.totalIlliquid(); } +/// Result of a date-targeted candle lookup. +pub const CandleAtDate = struct { + close: f64, + /// The candle's actual date. Equals `target` when an exact match + /// was found; earlier than `target` when we carried forward (e.g., + /// target fell on a weekend/holiday or cache doesn't reach that + /// far yet). + date: Date, + /// True iff `date < target`. + stale: bool, +}; + +/// Look up the close price on-or-before `target` in a date-sorted +/// (ascending) candle slice. Returns null if every candle in the slice +/// is strictly after `target`, or if the slice is empty. +/// +/// This is the core primitive for candle-native pricing: +/// - Snapshot writes: "what was the close on `as_of_date`?" +/// - Historical backfill: "what was the close on some past date?" +/// +/// Carry-forward semantics handle weekends and holidays naturally — +/// Monday's snapshot for a Saturday `as_of_date` would use Friday's +/// close with `stale = true`. +/// +/// Input is expected to be sorted ascending by date (the cache +/// guarantees this). O(log n) via binary search. +pub fn candleCloseOnOrBefore(candles: []const Candle, target: Date) ?CandleAtDate { + if (candles.len == 0) return null; + + // Binary search for largest index with candles[i].date <= target. + // Standard lower-bound on "date > target", then step back. + var lo: usize = 0; + var hi: usize = candles.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (candles[mid].date.lessThan(target) or candles[mid].date.eql(target)) { + lo = mid + 1; + } else { + hi = mid; + } + } + // lo is the first index with date > target; lo - 1 is the answer. + if (lo == 0) return null; + const c = candles[lo - 1]; + return .{ .close = c.close, .date = c.date, .stale = !c.date.eql(target) }; +} + /// Compute portfolio summary given positions and current prices. /// `prices` maps symbol -> current price. /// `manual_prices` optionally marks symbols whose price came from manual override (not live API). @@ -325,6 +372,9 @@ pub const HistoricalSnapshot = struct { /// Find the closing price on or just before `target_date` in a sorted candle array. /// Returns null if no candle is within 5 trading days before the target. +/// +/// For snapshot/backfill usage prefer `candleCloseOnOrBefore` — it has +/// no slack cap and reports the matched candle's date + staleness. fn findPriceAtDate(candles: []const Candle, target: Date) ?f64 { if (candles.len == 0) return null; @@ -472,13 +522,57 @@ test "findPriceAtDate empty" { test "findPriceAtDate before all candles" { const candles = [_]Candle{ - makeCandle(Date.fromYmd(2024, 6, 1), 150), - makeCandle(Date.fromYmd(2024, 6, 2), 151), + .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100.5, .adj_close = 100.5, .volume = 0 }, }; - // Target is way before all candles try std.testing.expect(findPriceAtDate(&candles, Date.fromYmd(2020, 1, 1)) == null); } +test "candleCloseOnOrBefore: exact date match" { + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100.5, .adj_close = 100.5, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 3), .open = 101, .high = 102, .low = 100, .close = 101.5, .adj_close = 101.5, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 4), .open = 102, .high = 103, .low = 101, .close = 102.5, .adj_close = 102.5, .volume = 0 }, + }; + const r = candleCloseOnOrBefore(&candles, Date.fromYmd(2024, 1, 3)).?; + try std.testing.expectEqual(@as(f64, 101.5), r.close); + try std.testing.expect(r.date.eql(Date.fromYmd(2024, 1, 3))); + try std.testing.expect(!r.stale); +} + +test "candleCloseOnOrBefore: weekend carry-forward marks stale" { + // Target lands on Saturday; expect to fall back to Friday's close. + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 4), .open = 0, .high = 0, .low = 0, .close = 100, .adj_close = 100, .volume = 0 }, + .{ .date = Date.fromYmd(2024, 1, 5), .open = 0, .high = 0, .low = 0, .close = 101, .adj_close = 101, .volume = 0 }, // Fri + }; + const r = candleCloseOnOrBefore(&candles, Date.fromYmd(2024, 1, 6)).?; // Sat + try std.testing.expectEqual(@as(f64, 101), r.close); + try std.testing.expect(r.date.eql(Date.fromYmd(2024, 1, 5))); + try std.testing.expect(r.stale); +} + +test "candleCloseOnOrBefore: far-past carry-forward still works (no slack cap)" { + // Unlike findPriceAtDate, this helper has no 7-day cap. + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2020, 1, 1), .open = 0, .high = 0, .low = 0, .close = 50, .adj_close = 50, .volume = 0 }, + }; + const r = candleCloseOnOrBefore(&candles, Date.fromYmd(2026, 4, 21)).?; + try std.testing.expectEqual(@as(f64, 50), r.close); + try std.testing.expect(r.stale); +} + +test "candleCloseOnOrBefore: target before all candles returns null" { + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 10), .open = 0, .high = 0, .low = 0, .close = 100, .adj_close = 100, .volume = 0 }, + }; + try std.testing.expect(candleCloseOnOrBefore(&candles, Date.fromYmd(2024, 1, 5)) == null); +} + +test "candleCloseOnOrBefore: empty candles returns null" { + const candles: []const Candle = &.{}; + try std.testing.expect(candleCloseOnOrBefore(candles, Date.fromYmd(2024, 1, 1)) == null); +} + test "HistoricalSnapshot change and changePct" { const snap = HistoricalSnapshot{ .period = .@"1Y", diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index e49510a..a7734d4 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -3,12 +3,27 @@ //! Flow: //! 1. Locate portfolio.srf via `config.resolveUserFile` (or -p). //! 2. Derive `history/` dir as `dirname(portfolio.srf)/history/`. -//! 3. Load portfolio + prices (via `cli.loadPortfolioPrices`, TTL-driven). -//! 4. Compute `as_of_date` from cached candle dates of held non-MM -//! stock symbols. -//! 5. If `history/-portfolio.srf` already exists and -//! --force wasn't passed, skip (exit 0, stderr message). -//! 6. Build the snapshot records and write them atomically. +//! 3. Load portfolio composition. Normally from the working-copy +//! file; with `--as-of`, from git history at or before the +//! requested date (falling back to working copy if pre-git). +//! 4. Refresh the candle cache via `cli.loadPortfolioPrices` +//! (skipped under `--as-of` — past candles don't change). +//! 5. Compute `as_of_date`: explicit `--as-of` wins; otherwise mode +//! of cached candle dates of held non-MM stock symbols. +//! 6. For each symbol, look up the close price ≤ `as_of_date` from +//! its candle cache. Carry-forward gets flagged as +//! `quote_stale = true`. +//! 7. If `history/-portfolio.srf` already exists and +//! `--force` wasn't passed, skip (exit 0, stderr message). +//! 8. Build the snapshot records and write them atomically. +//! +//! Flags: +//! --force overwrite existing snapshot for as_of_date +//! --dry-run compute + print to stdout, do not write +//! --out override output path +//! --as-of write a snapshot for a historical date +//! (uses git to recover portfolio state and +//! candle cache for pricing) //! //! The output format is discriminated SRF: every record starts with //! `kind::`. Readers demux on that @@ -23,6 +38,7 @@ const version = @import("../version.zig"); const portfolio_mod = @import("../models/portfolio.zig"); const Date = @import("../models/date.zig").Date; const model = @import("../models/snapshot.zig"); +const git = @import("../git.zig"); // Re-export record types so callers that reach `commands/snapshot.zig` // (tests, mostly) still see the familiar names. New code should prefer @@ -63,6 +79,7 @@ pub fn run( var force = false; var dry_run = false; var out_override: ?[]const u8 = null; + var as_of_override: ?Date = null; var i: usize = 0; while (i < args.len) : (i += 1) { const a = args[i]; @@ -77,6 +94,16 @@ pub fn run( return error.UnexpectedArg; } out_override = args[i]; + } else if (std.mem.eql(u8, a, "--as-of")) { + i += 1; + if (i >= args.len) { + try cli.stderrPrint("Error: --as-of requires a date (YYYY-MM-DD)\n"); + return error.UnexpectedArg; + } + as_of_override = Date.parse(args[i]) catch { + try cli.stderrPrint("Error: --as-of: invalid date (expected YYYY-MM-DD)\n"); + return error.UnexpectedArg; + }; } else { try cli.stderrPrint("Error: unknown argument to 'snapshot': "); try cli.stderrPrint(a); @@ -85,13 +112,15 @@ pub fn run( } } - // Load portfolio. - const pf_data = std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch |err| { - try cli.stderrPrint("Error reading portfolio file: "); - try cli.stderrPrint(@errorName(err)); - try cli.stderrPrint("\n"); - return err; - }; + // Load portfolio bytes. In normal (no --as-of) mode this is the + // current working-copy of portfolio.srf. With --as-of, we first try + // to retrieve the portfolio state from git history at or before the + // target date — that gives accurate composition for past snapshots. + // If git lookup fails (portfolio not tracked, no commits before the + // date, git unavailable), we warn and fall back to the working copy, + // which at least approximates "positions the user currently holds" + // and is better than erroring out. + const pf_data = try loadPortfolioAtDate(allocator, portfolio_path, as_of_override); defer allocator.free(pf_data); var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch { @@ -115,7 +144,13 @@ pub fn run( // single stale symbol means a refresh might bring forward a newer // `last_date`, which would change as_of_date and make the existing // snapshot file no longer a duplicate. - if (!force and out_override == null) { + // + // Only runs when as_of is inferred from the cache (the common path). + // With --as-of, the target date is explicit and might not even + // correspond to "latest cache state," so we skip the fast path and + // let the normal flow handle the duplicate-skip check against the + // explicit date below. + if (!force and out_override == null and as_of_override == null) { if (try probeFreshAsOfDate(allocator, svc, syms)) |candidate| { var cand_buf: [10]u8 = undefined; const cand_str = candidate.format(&cand_buf); @@ -136,20 +171,59 @@ pub fn run( } } - // Fetch prices via the shared TTL-driven loader. - var prices = std.StringHashMap(f64).init(allocator); - defer prices.deinit(); - - if (syms.len > 0) { + // Candle-native pricing: + // + // 1. Run the shared price loader to refresh the candle cache. We + // ignore its `prices` output (which is "last candle close per + // symbol") because we want a specific-date lookup instead. + // Skipped under --as-of: we're backfilling a historical date, + // so refreshing won't add data for the past. + // 2. Compute `as_of_date` from the refreshed cache (or the + // user-provided --as-of). + // 3. For every stock symbol, look up the close-on-or-before + // `as_of_date` in its candles. This is semantically stronger + // than "latest close": it means every lot's price reflects + // market state at the same instant, and backfill to a past + // date is just a matter of passing a different `as_of`. + // + // The duplicate-skip fast path above already handled the common + // "cache is fresh, snapshot exists" case without any of this work. + if (syms.len > 0 and as_of_override == null) { var load_result = cli.loadPortfolioPrices(svc, syms, &.{}, false, color); - defer load_result.deinit(); - var it = load_result.prices.iterator(); - while (it.next()) |entry| { - try prices.put(entry.key_ptr.*, entry.value_ptr.*); + load_result.deinit(); + } + + // Compute as_of_date. Explicit --as-of wins; otherwise derive from + // the cached candle dates of held non-MM stock symbols (MM symbols + // are excluded because their quote dates are often weeks stale — + // dollar impact is nil, but they'd pollute the mode calculation). + const qdates = try collectQuoteDates(allocator, svc, syms); + defer allocator.free(qdates.dates); + const as_of = as_of_override orelse (computeAsOfDate(qdates.dates) orelse Date.fromEpoch(std.time.timestamp())); + + // Per-symbol candle close lookup keyed on `as_of`. Owns no string + // memory (keys borrow from the caller's `syms`). + var symbol_prices = std.StringHashMap(zfin.valuation.CandleAtDate).init(allocator); + defer symbol_prices.deinit(); + + for (syms) |sym| { + if (svc.getCachedCandles(sym)) |cs| { + defer allocator.free(cs); + if (zfin.valuation.candleCloseOnOrBefore(cs, as_of)) |cad| { + try symbol_prices.put(sym, cad); + } } } - // Manual `price::` overrides from portfolio.srf still win. + // Build the flat-price map that `portfolioSummary` expects, plus + // apply manual `price::` overrides from portfolio.srf (which win + // over the candle lookup). + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + var sp_it = symbol_prices.iterator(); + while (sp_it.next()) |entry| { + try prices.put(entry.key_ptr.*, entry.value_ptr.close); + } for (portfolio.lots) |lot| { if (lot.price) |p| { if (!prices.contains(lot.priceSymbol())) { @@ -158,14 +232,6 @@ pub fn run( } } - // Compute as_of_date from the cached candle dates of held non-MM - // stock symbols. MM symbols are excluded because their quote dates - // are often weeks stale (dollar impact is nil, but they'd pollute - // the mode calculation). - const qdates = try collectQuoteDates(allocator, svc, syms); - defer allocator.free(qdates.dates); - const as_of = computeAsOfDate(qdates.dates) orelse Date.fromEpoch(std.time.timestamp()); - // Derive output path. var as_of_buf: [10]u8 = undefined; const as_of_str = as_of.format(&as_of_buf); @@ -187,7 +253,7 @@ pub fn run( } // Build and render the snapshot. - var snap = try buildSnapshot(allocator, &portfolio, portfolio_path, svc, prices, syms, as_of, qdates); + var snap = try buildSnapshot(allocator, &portfolio, portfolio_path, svc, prices, symbol_prices, syms, as_of, qdates); defer snap.deinit(allocator); const rendered = try renderSnapshot(allocator, snap); @@ -236,6 +302,100 @@ pub fn deriveSnapshotPath( return std.fs.path.join(allocator, &.{ dir, "history", filename }); } +// ── Portfolio-at-date loader ───────────────────────────────── + +/// Load portfolio bytes for the given `as_of` date. When `as_of` is +/// null, simply reads the current working-copy of `portfolio_path`. +/// +/// When `as_of` is provided, attempts to find the latest git commit +/// touching the portfolio file at or before the end of `as_of`, then +/// `git show`s that revision's content. Falls back to the working copy +/// (with a stderr warning) on: +/// - git not available +/// - portfolio.srf not tracked in git +/// - no commit exists at or before as_of (pre-git dates) +/// +/// The working-copy fallback is intentional: the user's note is that +/// pre-git dates "need either mtime fallback or get skipped (not +/// errored)," and mtime-fallback is equivalent to "use the current +/// file" — the file's current state IS its mtime state. A clean exit +/// lets bulk-backfill loops keep moving. +/// +/// Caller owns returned bytes. +fn loadPortfolioAtDate( + allocator: std.mem.Allocator, + portfolio_path: []const u8, + as_of: ?Date, +) ![]const u8 { + const target = as_of orelse { + // Normal mode — just read the file. + return std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch |err| { + try cli.stderrPrint("Error reading portfolio file: "); + try cli.stderrPrint(@errorName(err)); + try cli.stderrPrint("\n"); + return err; + }; + }; + + // Try git first. + if (loadPortfolioFromGit(allocator, portfolio_path, target)) |bytes| return bytes else |err| switch (err) { + error.NotInGitRepo, error.GitUnavailable, error.PathMissingInRev, error.UnknownRevision, error.NoCommitBeforeDate => { + // Fall through to working-copy fallback below. + var date_buf: [10]u8 = undefined; + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint( + &buf, + "warning: no git history for portfolio at {s}; using working copy as approximation\n", + .{target.format(&date_buf)}, + ) catch "warning: no git history for portfolio at requested date\n"; + try cli.stderrPrint(msg); + }, + else => |e| return e, + } + + return std.fs.cwd().readFileAlloc(allocator, portfolio_path, 10 * 1024 * 1024) catch |err| { + try cli.stderrPrint("Error reading portfolio file: "); + try cli.stderrPrint(@errorName(err)); + try cli.stderrPrint("\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( + allocator: std.mem.Allocator, + portfolio_path: []const u8, + target: Date, +) ![]const u8 { + const info = try git.findRepo(allocator, portfolio_path); + defer allocator.free(info.root); + defer allocator.free(info.rel_path); + + // List all commits that touched this path, newest-first. + const commits = try git.listCommitsTouching(allocator, info.root, info.rel_path, null); + defer git.freeCommitTouches(allocator, commits); + + if (commits.len == 0) return error.PathMissingInRev; + + // 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 + // 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 chosen = sha orelse return error.NoCommitBeforeDate; + return try git.show(allocator, info.root, chosen, info.rel_path); +} + // ── Quote-date / as_of_date helpers ────────────────────────── /// Per-symbol quote-date info gathered from the candle cache. @@ -373,12 +533,18 @@ pub fn quoteDateRange(infos: []const QuoteInfo) ?struct { min: Date, max: Date } // them without depending on a `commands/` module. /// Build the full snapshot in memory. Does not touch disk. +/// +/// `prices` is a flat `symbol -> price` map derived from `symbol_prices` +/// plus manual overrides. `symbol_prices` carries richer per-symbol +/// info (matched candle date, staleness) for the per-lot `quote_date` +/// / `quote_stale` fields. fn buildSnapshot( allocator: std.mem.Allocator, portfolio: *zfin.Portfolio, portfolio_path: []const u8, svc: *zfin.DataService, prices: std.StringHashMap(f64), + symbol_prices: std.StringHashMap(zfin.valuation.CandleAtDate), syms: []const []const u8, as_of: Date, qdates: QuoteDates, @@ -450,14 +616,20 @@ fn buildSnapshot( const effective_price = if (is_manual) raw_price else raw_price * lot.price_ratio; const value = lot.shares * effective_price; + // Candle-native quote_date/quote_stale: `symbol_prices` + // already ran `candleCloseOnOrBefore(as_of)` per symbol, + // which records both the matched candle's actual date + // and whether it was a carry-forward. Manual price + // overrides don't appear in symbol_prices and have no + // quote_date (explicitly nil, not stale). var quote_date: ?Date = null; - for (qdates.dates) |qi| { - if (std.mem.eql(u8, qi.symbol, price_sym)) { - quote_date = qi.last_date; - break; + var stale = false; + if (!is_manual) { + if (symbol_prices.get(price_sym)) |cad| { + quote_date = cad.date; + stale = cad.stale; } } - const stale = if (quote_date) |qd| !qd.eql(as_of) else false; if (stale and !portfolio_mod.isMoneyMarketSymbol(price_sym)) stale_count += 1; try lots_list.append(allocator, .{