use candles for pricing/implement --as-of parameter

This commit is contained in:
Emil Lerch 2026-04-22 22:04:45 -07:00
parent 52e7a703bd
commit abd5d08af7
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 307 additions and 41 deletions

View file

@ -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",

View file

@ -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/<as_of_date>-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/<as_of_date>-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 <path> override output path
//! --as-of <YYYY-MM-DD> 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::<meta|total|tax_type|account|lot>`. 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, .{