Compare commits
3 commits
4a86ecb95e
...
44e8c65df4
| Author | SHA1 | Date | |
|---|---|---|---|
| 44e8c65df4 | |||
| a9b923f766 | |||
| 914dfca356 |
8 changed files with 774 additions and 119 deletions
9
src/cache/freshness.zig
vendored
9
src/cache/freshness.zig
vendored
|
|
@ -406,6 +406,15 @@ test "scan: kinds are judged separately, so fund NAV lag is not staleness" {
|
|||
defer r.deinit(a);
|
||||
|
||||
try testing.expectEqual(@as(usize, 0), r.stale.len);
|
||||
// And not merely absent from `stale` - absent from BOTH lists. A fund that
|
||||
// slipped into `far_behind` would exit the server's refresh non-zero and mail
|
||||
// the operator every evening, which is a worse failure than a noisy list.
|
||||
try testing.expectEqual(@as(usize, 0), r.far_behind.len);
|
||||
// The peer maximum is what protects this: each kind's reference moves with
|
||||
// its own group, so a schedule shared by every member of a group can never
|
||||
// put any member behind it.
|
||||
try testing.expect(r.groups[0].peer_date.?.eql(fri));
|
||||
try testing.expect(r.groups[1].peer_date.?.eql(thu));
|
||||
// Both groups are caught up against their own schedules.
|
||||
try testing.expectEqual(market.CandleFreshness.current, r.groups[0].freshness.?);
|
||||
try testing.expectEqual(market.CandleFreshness.current, r.groups[1].freshness.?);
|
||||
|
|
|
|||
|
|
@ -31,9 +31,15 @@ pub const meta: framework.Meta = .{
|
|||
\\ age, and freshness state. Stale entries (past TTL)
|
||||
\\ are flagged. Includes the cusip_tickers.srf file
|
||||
\\ if present.
|
||||
\\ refresh Force-refresh candle data, bypassing the TTL and the
|
||||
\\ shared server. With no arguments, refreshes exactly what
|
||||
\\ `stale` reports. With symbols, refreshes those.
|
||||
\\ refresh Force-refresh candle data in the LOCAL cache, bypassing
|
||||
\\ the TTL and the shared server. With no arguments,
|
||||
\\ refreshes exactly what `stale` reports. With symbols,
|
||||
\\ refreshes those.
|
||||
\\
|
||||
\\ Note the direction: this deliberately does NOT go through
|
||||
\\ ZFIN_SERVER. To refresh the SERVER's copy instead, use
|
||||
\\ `zfin server refresh SYMBOL...`. `zfin diagnose SYMBOL`
|
||||
\\ says which side is actually behind.
|
||||
\\ stale Find symbols whose newest candle is behind their
|
||||
\\ peers'. Compares each symbol against others of the
|
||||
\\ same kind (equity vs mutual fund) rather than against
|
||||
|
|
|
|||
|
|
@ -177,9 +177,12 @@
|
|||
//! `new_cash` Change (a single cash delta can be drained by
|
||||
//! multiple records, which `kind` can't represent). Instead the
|
||||
//! matcher appends a synthetic `transfer_in` Change for the
|
||||
//! Transfers section and records the attributed amount in
|
||||
//! `Report.cash_attributed_by_account`, which the per-account totals
|
||||
//! and attribution summary subtract from cash-side contributions.
|
||||
//! Transfers section and accumulates the attributed amount onto each
|
||||
//! consumed Change's `transfer_attributed` field. `attributedValue()`
|
||||
//! then reports the unattributed residual, which is what the
|
||||
//! per-account totals, the contributions sections, and audit's
|
||||
//! large-lot filter all consume - one mechanism, so no two views of
|
||||
//! the same attribution can drift apart.
|
||||
//!
|
||||
//! ### Which records does the matcher consider?
|
||||
//!
|
||||
|
|
@ -1036,28 +1039,6 @@ pub fn findUnmatchedLargeLots(
|
|||
return .{ .lots = lots, .arena = arena_state };
|
||||
}
|
||||
|
||||
/// Pure filter: pick out new-side Changes with `value() >= threshold`
|
||||
/// and dupe their string fields into `arena`. Split out so tests can
|
||||
/// feed a synthetic `[]Change` without running the git/IO pipeline.
|
||||
///
|
||||
/// Cash-destination transfers don't flip their original `new_cash` /
|
||||
/// `cash_contribution` Change (a single cash delta can be drained by
|
||||
/// multiple records, which the kind field can't represent). Instead
|
||||
/// the matcher records the attributed amount in
|
||||
/// `cash_attributed_by_account`. Subtract that here so a fully-
|
||||
/// attributed cash lot doesn't re-surface as "unmatched large lot."
|
||||
/// A partially-attributed cash lot surfaces only on the residual,
|
||||
/// matching the user's mental model: "this much of the lot is
|
||||
/// already documented, check the rest."
|
||||
///
|
||||
/// Deliberately excludes `partial_transfer_in`. A partial lot already
|
||||
/// has an explicit transfer record acknowledging the large movement;
|
||||
/// the unmatched residual is typically small (pre-existing cash that
|
||||
/// topped the lot off) and surfacing it again would nag on something
|
||||
/// the user has already documented. If a residual is large enough to
|
||||
/// care about independently, the user can review the lot's full value
|
||||
/// via `zfin contributions` - this filter's job is to catch
|
||||
/// *unrecorded* large movements, not to re-flag partial ones.
|
||||
/// Pure filter: pick out new-side Changes whose unattributed value
|
||||
/// (`attributedValue()`) is at or above `threshold`, and dupe their
|
||||
/// string fields into `arena`. Split out so tests can feed a
|
||||
|
|
@ -1131,8 +1112,6 @@ fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
|
|||
// + cash_contribution (opt-in cash_delta)
|
||||
// + partial_transfer_in residual
|
||||
// (`value()` - `transfer_attributed`)
|
||||
// - cash-dest transfer totals
|
||||
// (from `cash_attributed_by_account`)
|
||||
// - DRIP: new_drip_lot + drip_confirmed + rollup_delta
|
||||
// `rollup_delta` is the ambiguous "share increased on a drip::false
|
||||
// lot" case. Lumping it with DRIP here matches the report's own
|
||||
|
|
@ -1152,10 +1131,10 @@ fn summarizeAttribution(ctx: ReportContext) AttributionSummary {
|
|||
.partial_transfer_in => new_contributions += c.attributedValue(),
|
||||
else => {},
|
||||
};
|
||||
// Note: cash-dest transfer attribution is already removed by
|
||||
// `attributedValue()` on the per-Change side (matchCashDestination
|
||||
// accumulates into `transfer_attributed`). No second subtraction
|
||||
// off `cash_attributed_by_account` needed here.
|
||||
// Cash-dest transfer attribution is already removed by `attributedValue()` on
|
||||
// the per-Change side: `matchCashDestination` accumulates into
|
||||
// `transfer_attributed`, so a fully-attributed cash Change contributes zero
|
||||
// here without any separate per-account subtraction.
|
||||
|
||||
return .{ .new_contributions = new_contributions, .drip = drip };
|
||||
}
|
||||
|
|
@ -1329,11 +1308,6 @@ const Report = struct {
|
|||
changes: []Change,
|
||||
/// Per-account rollups for the summary section.
|
||||
account_totals: std.StringHashMap(AccountTotal),
|
||||
/// Per-account cash amounts matched to transfer records. Subtracted
|
||||
/// from cash-side totals in the per-account summary so transferred
|
||||
/// cash doesn't double-count. Keys borrow from Change.account
|
||||
/// strings (arena-owned, same lifetime as the Report).
|
||||
cash_attributed_by_account: std.StringHashMap(f64),
|
||||
|
||||
const AccountTotal = struct {
|
||||
new_money: f64 = 0, // stock+cd+cash new lots (drip::false)
|
||||
|
|
@ -1873,18 +1847,10 @@ fn computeReport(
|
|||
// Transfer reclassification pass: rewrite destination/source
|
||||
// Change kinds for records the caller passed in (typically the
|
||||
// diff between before-side and after-side
|
||||
// `transaction_log.srf`), and accumulate per-account cash
|
||||
// attribution so transferred cash doesn't double-count in
|
||||
// per-account totals. No-op when no records are supplied. See
|
||||
// `transaction_log.srf`). No-op when no records are supplied. See
|
||||
// `matchTransfers` docstring for the matching algorithm.
|
||||
var cash_attributed_by_account: std.StringHashMap(f64) = .init(allocator);
|
||||
if (opts.transfer_log) |records| {
|
||||
try matchTransfers(
|
||||
allocator,
|
||||
&changes,
|
||||
&cash_attributed_by_account,
|
||||
records,
|
||||
);
|
||||
try matchTransfers(allocator, &changes, records);
|
||||
}
|
||||
|
||||
// Intra-account purchase netting: a decrease in an account's cash
|
||||
|
|
@ -1941,18 +1907,13 @@ fn computeReport(
|
|||
}
|
||||
}
|
||||
|
||||
// Note: cash-dest transfer attribution is already removed by
|
||||
// `attributedValue()` on each cash-side Change (the matcher
|
||||
// accumulates into `transfer_attributed`). No second subtraction
|
||||
// off `cash_attributed_by_account` needed here. The bucket is
|
||||
// still populated for downstream consumers (e.g. callers that
|
||||
// want a per-account view of attributed transfers) but isn't
|
||||
// used in the totals math.
|
||||
// Cash-dest transfer attribution is already removed by `attributedValue()` on
|
||||
// each cash-side Change - the matcher accumulates into `transfer_attributed`,
|
||||
// so the residual is what reaches these totals. Nothing further to subtract.
|
||||
|
||||
return .{
|
||||
.changes = try changes.toOwnedSlice(allocator),
|
||||
.account_totals = acct_totals,
|
||||
.cash_attributed_by_account = cash_attributed_by_account,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2042,10 +2003,10 @@ fn diffTransferLogs(
|
|||
/// cash_contribution summed) can cover the record's amount
|
||||
/// (minus any prior cash-dest records on the same account).
|
||||
/// Success appends a synthetic `transfer_in` Change for
|
||||
/// display AND accumulates into
|
||||
/// `cash_attributed_by_account[to]`, which the caller
|
||||
/// subtracts from cash-side per-account totals. Failure
|
||||
/// (budget underflow) emits `unmatched_transfer`.
|
||||
/// display AND accumulates onto the consumed cash Changes'
|
||||
/// `transfer_attributed`, so `attributedValue()` reports only
|
||||
/// the unattributed residual. Failure (budget underflow) emits
|
||||
/// `unmatched_transfer`.
|
||||
///
|
||||
/// - For the `from` side: try to find a matching negative
|
||||
/// `cash_delta` or `lot_removed` on the sending account and
|
||||
|
|
@ -2065,15 +2026,9 @@ fn diffTransferLogs(
|
|||
/// `transfer::DATE`. This allows a user to back-date a record
|
||||
/// (e.g. add a `transfer::2026-05-20` entry on 2026-05-23) and
|
||||
/// have it pair against the working-copy diff that introduced it.
|
||||
///
|
||||
/// Populates `cash_attributed_by_account` (caller-owned) with the
|
||||
/// per-account total of amounts matched to cash-destination records;
|
||||
/// these amounts are subtracted from the cash bucket in the
|
||||
/// per-account totals pass so transferred cash doesn't double-count.
|
||||
fn matchTransfers(
|
||||
allocator: std.mem.Allocator,
|
||||
changes: *std.ArrayList(Change),
|
||||
cash_attributed_by_account: *std.StringHashMap(f64),
|
||||
records: []const transaction_log.TransferRecord,
|
||||
) !void {
|
||||
// Bookkeeping: track which Change indices have already been
|
||||
|
|
@ -2132,7 +2087,7 @@ fn matchTransfers(
|
|||
try matchLotDestination(allocator, changes, &consumed_lot_idx, rec, dl);
|
||||
},
|
||||
.cash => {
|
||||
try matchCashDestination(allocator, changes, &cash_budget, cash_attributed_by_account, &transfer_funding, rec);
|
||||
try matchCashDestination(allocator, changes, &cash_budget, &transfer_funding, rec);
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -2295,14 +2250,17 @@ fn appendUnmatchedWithOwnedNote(
|
|||
|
||||
/// Verify the `to` account's cash budget has capacity for this
|
||||
/// record, draw from it, and either attach to an existing cash
|
||||
/// Change or append a synthetic one. The per-account attribution
|
||||
/// bucket (`cash_attributed_by_account`) is what actually drives
|
||||
/// totals math - the Change-level reclassification is for display.
|
||||
/// Change or append a synthetic one.
|
||||
///
|
||||
/// Attribution is recorded per-Change on `transfer_attributed`, which
|
||||
/// `attributedValue()` turns into the unattributed residual. That single
|
||||
/// mechanism drives every consumer - the totals math, the contributions
|
||||
/// sections, and audit's large-lot filter - so there is no separate
|
||||
/// per-account bucket to keep in agreement with it.
|
||||
fn matchCashDestination(
|
||||
allocator: std.mem.Allocator,
|
||||
changes: *std.ArrayList(Change),
|
||||
cash_budget: *std.StringHashMap(f64),
|
||||
cash_attributed_by_account: *std.StringHashMap(f64),
|
||||
transfer_funding: *std.StringHashMap(FundingShortfall),
|
||||
rec: transaction_log.TransferRecord,
|
||||
) !void {
|
||||
|
|
@ -2333,15 +2291,6 @@ fn matchCashDestination(
|
|||
// capacity.
|
||||
if (budget_entry) |p| p.* -= credited;
|
||||
|
||||
// Accumulate into per-account attribution bucket. The per-
|
||||
// account totals pass subtracts this from cash-side totals so
|
||||
// transferred cash doesn't double-count.
|
||||
const gop = try cash_attributed_by_account.getOrPut(rec.to);
|
||||
if (!gop.found_existing) gop.value_ptr.* = 0;
|
||||
// Only the cash that was actually observed; the rest is attributed to the
|
||||
// new lots instead, so adding the full amount here would double-count.
|
||||
gop.value_ptr.* += credited;
|
||||
|
||||
// Distribute the record amount across the destination account's
|
||||
// cash-side Changes by accumulating into each Change's
|
||||
// `transfer_attributed`. We deliberately do NOT flip the
|
||||
|
|
@ -2355,10 +2304,10 @@ fn matchCashDestination(
|
|||
// return the unattributed residual - fully-attributed Changes
|
||||
// drop out of the "New contributions" section, audit's
|
||||
// "Large new lots" filter, and any other consumer that asks
|
||||
// "how much of this Change is real new money?". The summary
|
||||
// pass continues to use `cash_attributed_by_account` for its
|
||||
// per-account math - the two views agree because the same
|
||||
// amount is subtracted on both sides.
|
||||
// "how much of this Change is real new money?". Every consumer -
|
||||
// per-account totals, the contributions sections, audit's large-lot
|
||||
// filter - reads that same residual, so there is no second view to
|
||||
// keep in agreement.
|
||||
var remaining = credited;
|
||||
for (changes.items) |*c| {
|
||||
if (remaining <= 0) break;
|
||||
|
|
@ -4930,9 +4879,9 @@ test "matchTransfers: cash-to-cash happy path" {
|
|||
// The new_cash Change stays as new_cash in the display (we don't
|
||||
// flip cash-side Changes because a single cash_delta can be
|
||||
// drained by multiple records). A synthetic transfer_in Change
|
||||
// is appended for Transfers-section display, and
|
||||
// `cash_attributed_by_account` carries the $5k subtraction for
|
||||
// attribution math.
|
||||
// is appended for Transfers-section display, and the $5k lands on
|
||||
// the cash Change's `transfer_attributed` so `attributedValue()`
|
||||
// reports the residual.
|
||||
var n_new_cash: usize = 0;
|
||||
var n_transfer_in: usize = 0;
|
||||
for (report.changes) |c| switch (c.kind) {
|
||||
|
|
@ -5829,17 +5778,21 @@ test "matchTransfers: attribution excludes transferred amount" {
|
|||
.transfer_log = tlog.transfers,
|
||||
});
|
||||
|
||||
// Replicate summarizeAttribution's logic directly.
|
||||
// Replicate summarizeAttribution's logic directly. Uses
|
||||
// `attributedValue()` on the new-side kinds, exactly as the real
|
||||
// summary does: the per-Change residual IS the subtraction, so there
|
||||
// is nothing further to net off. An earlier version of this test
|
||||
// summed raw `value()` and then subtracted a separate per-account
|
||||
// bucket - the superseded mechanism - which meant it reproduced an
|
||||
// old formula rather than exercising the shipped one.
|
||||
var new_contributions: f64 = 0;
|
||||
var drip: f64 = 0;
|
||||
for (report.changes) |c| switch (c.kind) {
|
||||
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.value(),
|
||||
.new_stock, .new_cash, .new_cd, .new_option, .cash_contribution => new_contributions += c.attributedValue(),
|
||||
.new_drip_lot, .drip_confirmed, .rollup_delta => drip += c.value(),
|
||||
.partial_transfer_in => new_contributions += c.attributedValue(),
|
||||
else => {},
|
||||
};
|
||||
var cait = report.cash_attributed_by_account.iterator();
|
||||
while (cait.next()) |entry| new_contributions -= entry.value_ptr.*;
|
||||
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), new_contributions, 0.01);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), drip, 0.01);
|
||||
|
|
@ -5994,7 +5947,7 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" {
|
|||
// Sample Trust funded by a transfer record dated 2026-05-20 was
|
||||
// surfacing in audit's "Large new lots - confirm source" because
|
||||
// the cash matcher doesn't flip the original `new_cash` Change's
|
||||
// kind (it draws from `cash_attributed_by_account` instead).
|
||||
// kind (the attribution rides on `transfer_attributed` instead).
|
||||
// Without subtracting that attribution, the audit filter
|
||||
// re-flagged a lot that's already explained.
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
|
|
@ -6021,7 +5974,7 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" {
|
|||
// Cash-dest matching does NOT flip the original new_cash Change
|
||||
// (a single delta can be drained by multiple records). The
|
||||
// matcher records the attributed amount in
|
||||
// `cash_attributed_by_account` instead.
|
||||
// `transfer_attributed` instead.
|
||||
var saw_new_cash = false;
|
||||
var saw_synthetic_transfer = false;
|
||||
for (report.changes) |c| switch (c.kind) {
|
||||
|
|
@ -6031,10 +5984,16 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" {
|
|||
};
|
||||
try std.testing.expect(saw_new_cash);
|
||||
try std.testing.expect(saw_synthetic_transfer);
|
||||
const attributed = report.cash_attributed_by_account.get("Sample Trust") orelse 0;
|
||||
try std.testing.expectEqual(@as(f64, 73158.33), attributed);
|
||||
// The attribution lands on the consumed cash Change, so the residual - not a
|
||||
// separate per-account bucket - is what every consumer sees. Assert it there,
|
||||
// which is also the value the audit filter below acts on.
|
||||
var residual: f64 = 0;
|
||||
for (report.changes) |c| {
|
||||
if (c.kind == .new_cash) residual += c.attributedValue();
|
||||
}
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 0.0), residual, 0.01);
|
||||
|
||||
// The audit filter must subtract the attribution and stay quiet.
|
||||
// The audit filter must see a fully-attributed lot and stay quiet.
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||
}
|
||||
|
|
@ -6554,13 +6513,10 @@ test "printReport: empty report says no changes" {
|
|||
var w: std.Io.Writer = .fixed(&buf);
|
||||
var account_totals = std.StringHashMap(Report.AccountTotal).init(testing.allocator);
|
||||
defer account_totals.deinit();
|
||||
var cash_attr = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer cash_attr.deinit();
|
||||
var changes = [_]Change{};
|
||||
const report = Report{
|
||||
.changes = changes[0..],
|
||||
.account_totals = account_totals,
|
||||
.cash_attributed_by_account = cash_attr,
|
||||
};
|
||||
try printReport(&w, &report, "portfolio.srf", false);
|
||||
const out = w.buffered();
|
||||
|
|
@ -6599,12 +6555,10 @@ test "printReport: full report renders every section and sub-printer" {
|
|||
try account_totals.put("Roth", .{ .new_money = 3800, .drip_confirmed = 475, .rollup = 0, .cash_delta = 0 });
|
||||
try account_totals.put("CD Acct", .{ .cash_delta = 10500 });
|
||||
try account_totals.put("", .{}); // exercises "(no account)" label + all-zero summary cells
|
||||
const cash_attr = std.StringHashMap(f64).init(arena);
|
||||
|
||||
const report = Report{
|
||||
.changes = changes[0..],
|
||||
.account_totals = account_totals,
|
||||
.cash_attributed_by_account = cash_attr,
|
||||
};
|
||||
|
||||
var buf: [16384]u8 = undefined;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@ pub const meta: framework.Meta = .{
|
|||
\\Reports, in order:
|
||||
\\ local newest cached bar, TTL state, provider, failure count
|
||||
\\ peers newest bar held by other symbols of the same kind
|
||||
\\ server what ZFIN_SERVER offers, and whether it is ahead or behind
|
||||
\\ server what ZFIN_SERVER offers, whether it is ahead or behind, and
|
||||
\\ whether it refreshes this symbol at all
|
||||
\\ provider what the upstream provider actually has right now
|
||||
\\
|
||||
\\Then a verdict naming where the chain breaks, and what to do about it.
|
||||
|
|
@ -92,6 +93,10 @@ pub const Observed = struct {
|
|||
/// than routinely, so "nothing tracks this" is true but misleading - the
|
||||
/// remedy is `zfin projections`, not a config fix.
|
||||
benchmark: bool = false,
|
||||
/// Does the SHARED SERVER's refresh loop include this symbol? Null when the
|
||||
/// server did not say: none configured, a build predating `/diagnostics`, or
|
||||
/// a failed request. Only a definite `false` is ever concluded from.
|
||||
server_tracked: ?bool = null,
|
||||
};
|
||||
|
||||
pub const Verdict = enum {
|
||||
|
|
@ -113,6 +118,12 @@ pub const Verdict = enum {
|
|||
/// The provider has a newer bar, the TTL has lapsed, and the shared server is
|
||||
/// also behind - so a normal run syncs the server's older copy and stops.
|
||||
held_by_server,
|
||||
/// As `held_by_server`, plus the server does not track the symbol at all - so
|
||||
/// its copy is not merely behind, nothing there will ever move it. A local
|
||||
/// force-refresh fixes today; only registering the symbol on the server stops
|
||||
/// it recurring. Separated from `held_by_server` because the two have
|
||||
/// different remedies and only one of them is durable.
|
||||
server_untracked,
|
||||
/// The provider has a newer bar and nothing is holding us back; a refresh
|
||||
/// should simply work.
|
||||
refreshable,
|
||||
|
|
@ -131,6 +142,7 @@ pub const Verdict = enum {
|
|||
.not_tracked => "nothing in the fetch set asks for this symbol, so no normal run will update it - however stale it gets",
|
||||
.held_by_ttl => "the bar exists upstream, but the local TTL has not lapsed - nothing will fetch it until it does",
|
||||
.held_by_server => "the bar exists upstream; the shared cache is behind too, so a normal run syncs its older copy and stops",
|
||||
.server_untracked => "the shared cache serves this symbol but never refreshes it, so it will drift behind again after any local fix - register it there to make a fix stick",
|
||||
.refreshable => "the bar exists upstream and nothing is holding it back",
|
||||
.provider_behind_peers => "peers have a newer bar but the provider has nothing newer for this symbol - the gap is upstream, not in this cache",
|
||||
.unknown => "no provider answer, so the chain cannot be traced past the cache",
|
||||
|
|
@ -148,7 +160,7 @@ pub const Verdict = enum {
|
|||
|
||||
pub fn action(self: Verdict) ?Action {
|
||||
return switch (self) {
|
||||
.not_tracked, .held_by_ttl, .held_by_server, .refreshable => .{ .subcommand = "cache refresh", .takes_symbol = true },
|
||||
.not_tracked, .held_by_ttl, .held_by_server, .server_untracked, .refreshable => .{ .subcommand = "cache refresh", .takes_symbol = true },
|
||||
.not_cached => .{ .subcommand = "quote", .takes_symbol = true },
|
||||
.demand_fetched => .{ .subcommand = "projections", .takes_symbol = false },
|
||||
.current, .provider_behind_peers, .unknown => null,
|
||||
|
|
@ -187,8 +199,20 @@ pub fn classify(o: Observed) Verdict {
|
|||
}
|
||||
if (o.local_fresh) return .held_by_ttl;
|
||||
if (o.server) |srv| {
|
||||
if (srv.lessThan(provider)) return .held_by_server;
|
||||
if (srv.lessThan(provider)) {
|
||||
// The server being behind is the proximate cause either way. Whether
|
||||
// it can EVER catch up is the part worth separating: a tracked symbol
|
||||
// will move on the server's next pass, an untracked one never will.
|
||||
if (o.server_tracked) |st| {
|
||||
if (!st) return .server_untracked;
|
||||
}
|
||||
return .held_by_server;
|
||||
}
|
||||
}
|
||||
// Deliberately NOT reporting an untracked-on-server symbol whose copy is
|
||||
// current as a fault. A refresh works right now, which is what the verdict
|
||||
// answers; the standing risk is carried by the `server` output line instead of
|
||||
// being promoted into a blocker that does not exist yet.
|
||||
return .refreshable;
|
||||
}
|
||||
|
||||
|
|
@ -269,21 +293,62 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
|
||||
// ── server ───────────────────────────────────────────────
|
||||
if (ctx.config.server_url) |base| {
|
||||
if (serverNewest(io, arena, base, ctx.config.server_api_key, symbol)) |srv| {
|
||||
obs.server = srv;
|
||||
try out.print("server offers {f}", .{srv});
|
||||
if (obs.local) |l| {
|
||||
if (srv.lessThan(l)) {
|
||||
try out.print(" - BEHIND your local copy", .{});
|
||||
} else if (l.lessThan(srv)) {
|
||||
try out.print(" - ahead of your local copy", .{});
|
||||
switch (serverDiagnostics(io, arena, base, ctx.config.server_api_key, symbol)) {
|
||||
.view => |v| {
|
||||
obs.server = v.last_date;
|
||||
obs.server_tracked = v.tracked;
|
||||
if (v.last_date) |srv| {
|
||||
try out.print("server offers {f}", .{srv});
|
||||
try printRelativeToLocal(out, srv, obs.local);
|
||||
} else {
|
||||
try out.print(" - same as local", .{});
|
||||
try out.print("server nothing cached there", .{});
|
||||
}
|
||||
}
|
||||
try out.print("\n", .{});
|
||||
} else {
|
||||
try out.print("server no answer for this symbol\n", .{});
|
||||
if (v.created) |c| {
|
||||
try out.print(", written {f}", .{Date.fromEpoch(c)});
|
||||
}
|
||||
if (v.tracked) |t| {
|
||||
try out.print(", tracked: {s}", .{if (t) "yes" else "NO"});
|
||||
}
|
||||
try out.print("\n", .{});
|
||||
// The line the endpoint exists for. Said plainly, because "not
|
||||
// tracked" is jargon for a state whose consequence is total.
|
||||
if (v.tracked) |t| {
|
||||
if (!t) try out.print(" the server will never refresh this symbol on its own\n", .{});
|
||||
}
|
||||
// Its OWN peer gap, labelled as the server's rather than folded
|
||||
// into the local one - different corpora, and merging them would
|
||||
// invent a number neither side reported.
|
||||
if (v.days_behind) |db| {
|
||||
if (db > 0) {
|
||||
try out.print(" and is {d}d behind its own peers there", .{db});
|
||||
// The original bug's signature in one line: stamped good,
|
||||
// yet behind. Only worth saying when both hold - `fresh`
|
||||
// on a current copy is just healthy.
|
||||
if (v.fresh orelse false) {
|
||||
try out.print(", with its TTL still unlapsed - so a normal run there will not look either", .{});
|
||||
}
|
||||
try out.print("\n", .{});
|
||||
}
|
||||
}
|
||||
if (v.fail_count) |fc| {
|
||||
if (fc > 0) try out.print(" {d} consecutive provider failure(s) recorded there\n", .{fc});
|
||||
}
|
||||
},
|
||||
// Fall back to the pre-`/diagnostics` probe so this command keeps
|
||||
// working against a server that has not been redeployed.
|
||||
.unsupported => {
|
||||
if (serverNewest(io, arena, base, ctx.config.server_api_key, symbol)) |srv| {
|
||||
obs.server = srv;
|
||||
try out.print("server offers {f}", .{srv});
|
||||
try printRelativeToLocal(out, srv, obs.local);
|
||||
try out.print(" (no /diagnostics - intent unknown)\n", .{});
|
||||
} else {
|
||||
try out.print("server no answer for this symbol\n", .{});
|
||||
}
|
||||
},
|
||||
// Named, not collapsed: an auth rejection and a DNS failure send the
|
||||
// operator to entirely different places.
|
||||
.failed => |why| try out.print("server could not ask: {s}\n", .{why}),
|
||||
}
|
||||
} else {
|
||||
try out.print("server ZFIN_SERVER not set\n", .{});
|
||||
|
|
@ -335,6 +400,123 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
/// A plain GET of the server's `candles_meta`, parsed for `last_date`. Read-only
|
||||
/// by construction: nothing is written to the local cache, which matters because
|
||||
/// the normal sync path would overwrite local bytes with the server's.
|
||||
/// The shared server's own account of a symbol, from `GET /:symbol/diagnostics`.
|
||||
///
|
||||
/// Every field optional: an older server has no such endpoint at all, and a
|
||||
/// newer one may add fields this build does not know. Absent means "the server
|
||||
/// did not say", never a default value that would be indistinguishable from one
|
||||
/// it did say.
|
||||
pub const ServerView = struct {
|
||||
/// Does the SERVER's refresh loop include this symbol? This is the field the
|
||||
/// whole endpoint exists for: a symbol the server serves but never refreshes
|
||||
/// looks identical to a maintained one from the outside.
|
||||
tracked: ?bool = null,
|
||||
/// Is the server's own copy stamped fresh by its `#!expires=`?
|
||||
fresh: ?bool = null,
|
||||
fail_count: ?u32 = null,
|
||||
/// Calendar days the server's copy is behind its same-kind peers ON THE
|
||||
/// SERVER. Not comparable to the local peer gap - different corpus.
|
||||
days_behind: ?i64 = null,
|
||||
last_date: ?Date = null,
|
||||
peer_date: ?Date = null,
|
||||
/// Unix seconds when the server wrote its copy.
|
||||
created: ?i64 = null,
|
||||
};
|
||||
|
||||
/// Outcome of asking the server about a symbol. A tagged union rather than
|
||||
/// `?ServerView` because "this server is too old to ask" and "the request broke"
|
||||
/// lead to different output, and collapsing them would report an infrastructure
|
||||
/// problem as a missing feature.
|
||||
pub const ServerProbe = union(enum) {
|
||||
view: ServerView,
|
||||
/// The endpoint is absent: a server predating it. Fall back to `candles_meta`.
|
||||
unsupported,
|
||||
/// Anything else. Carries the error NAME so the output can say which, rather
|
||||
/// than reporting a bare "unavailable" for a DNS failure and an auth
|
||||
/// rejection alike.
|
||||
failed: []const u8,
|
||||
};
|
||||
|
||||
/// Parse a diagnostics body. Separated from the request so the shape can be
|
||||
/// tested without a server.
|
||||
///
|
||||
/// Unknown fields are ignored and malformed ones are left null rather than
|
||||
/// failing the parse: a newer server adding a field must not blind an older
|
||||
/// client to the fields it does understand.
|
||||
pub fn parseServerView(arena: std.mem.Allocator, body: []const u8) ?ServerView {
|
||||
const parsed = std.json.parseFromSlice(std.json.Value, arena, body, .{}) catch return null;
|
||||
const obj = switch (parsed.value) {
|
||||
.object => |o| o,
|
||||
else => return null,
|
||||
};
|
||||
var v = ServerView{};
|
||||
if (obj.get("tracked")) |x| if (x == .bool) {
|
||||
v.tracked = x.bool;
|
||||
};
|
||||
if (obj.get("fresh")) |x| if (x == .bool) {
|
||||
v.fresh = x.bool;
|
||||
};
|
||||
if (obj.get("fail_count")) |x| if (x == .integer and x.integer >= 0) {
|
||||
v.fail_count = @intCast(x.integer);
|
||||
};
|
||||
if (obj.get("days_behind")) |x| if (x == .integer) {
|
||||
v.days_behind = x.integer;
|
||||
};
|
||||
if (obj.get("created")) |x| if (x == .integer) {
|
||||
v.created = x.integer;
|
||||
};
|
||||
if (obj.get("last_date")) |x| if (x == .string) {
|
||||
v.last_date = Date.parse(x.string) catch null;
|
||||
};
|
||||
if (obj.get("peer_date")) |x| if (x == .string) {
|
||||
v.peer_date = Date.parse(x.string) catch null;
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
/// " - ahead of your local copy" and friends. Extracted because both the
|
||||
/// `/diagnostics` path and the older `candles_meta` fallback print it, and two
|
||||
/// copies would have drifted on the equal case.
|
||||
fn printRelativeToLocal(out: *std.Io.Writer, srv: Date, local: ?Date) !void {
|
||||
const l = local orelse return;
|
||||
if (srv.lessThan(l)) {
|
||||
try out.print(" - BEHIND your local copy", .{});
|
||||
} else if (l.lessThan(srv)) {
|
||||
try out.print(" - ahead of your local copy", .{});
|
||||
} else {
|
||||
try out.print(" - same as local", .{});
|
||||
}
|
||||
}
|
||||
|
||||
fn serverDiagnostics(
|
||||
io: std.Io,
|
||||
arena: std.mem.Allocator,
|
||||
base: []const u8,
|
||||
api_key: ?[]const u8,
|
||||
symbol: []const u8,
|
||||
) ServerProbe {
|
||||
const url = std.fmt.allocPrint(arena, "{s}/{s}/diagnostics", .{ base, symbol }) catch |e|
|
||||
return .{ .failed = @errorName(e) };
|
||||
var client = http.Client.init(io, arena);
|
||||
defer client.deinit();
|
||||
var hdr: [1]std.http.Header = .{.{ .name = "", .value = "" }};
|
||||
const extra: []const std.http.Header = if (api_key) |k| blk: {
|
||||
hdr[0] = .{ .name = "X-API-Key", .value = k };
|
||||
break :blk hdr[0..1];
|
||||
} else &.{};
|
||||
var resp = client.request(.GET, url, null, extra) catch |err| switch (err) {
|
||||
// The one error that means "ask the old way" rather than "something is
|
||||
// wrong". Every other status is a real failure and says so by name.
|
||||
error.NotFound => return .unsupported,
|
||||
else => return .{ .failed = @errorName(err) },
|
||||
};
|
||||
defer resp.deinit();
|
||||
return if (parseServerView(arena, resp.body)) |v|
|
||||
.{ .view = v }
|
||||
else
|
||||
.{ .failed = "MalformedBody" };
|
||||
}
|
||||
|
||||
fn serverNewest(
|
||||
io: std.Io,
|
||||
arena: std.mem.Allocator,
|
||||
|
|
@ -472,7 +654,12 @@ test "classify: absent tiers degrade to a named unknown, never a guess" {
|
|||
}
|
||||
|
||||
test "classify: every verdict offering an action names a runnable command" {
|
||||
for ([_]Verdict{ .not_cached, .current, .demand_fetched, .not_tracked, .held_by_ttl, .held_by_server, .refreshable, .provider_behind_peers, .unknown }) |v| {
|
||||
// Enumerated from the type, NOT a hand-written list. The hand-written version
|
||||
// of this claimed to cover "every verdict" and silently skipped
|
||||
// `server_untracked` the moment it was added - a test that quietly stops
|
||||
// covering new cases is worse than no test, because the green tick is read as
|
||||
// coverage.
|
||||
for (std.enums.values(Verdict)) |v| {
|
||||
try testing.expect(v.summary().len > 0);
|
||||
// A subcommand, not a full command line - the caller appends the symbol,
|
||||
// so this must never already contain one.
|
||||
|
|
@ -596,3 +783,124 @@ test "classify: a current benchmark is still just current" {
|
|||
.benchmark = true,
|
||||
}));
|
||||
}
|
||||
|
||||
test "parseServerView: the live production record for SPCX" {
|
||||
// Verbatim from `GET /SPCX/diagnostics` against the deployed server. The
|
||||
// combination is the one this endpoint was built to surface: served, behind,
|
||||
// and refreshed by nothing.
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const body =
|
||||
\\{"symbol":"SPCX","tracked":false,"fresh":false,"fail_count":0,"days_behind":44,"last_date":"2026-06-29","peer_date":"2026-08-12","created":1782773387}
|
||||
;
|
||||
const v = parseServerView(arena.allocator(), body).?;
|
||||
try testing.expectEqual(false, v.tracked.?);
|
||||
try testing.expectEqual(false, v.fresh.?);
|
||||
try testing.expectEqual(@as(u32, 0), v.fail_count.?);
|
||||
try testing.expectEqual(@as(i64, 44), v.days_behind.?);
|
||||
try testing.expect(v.last_date.?.eql(d(2026, 6, 29)));
|
||||
try testing.expect(v.peer_date.?.eql(d(2026, 8, 12)));
|
||||
try testing.expectEqual(@as(i64, 1782773387), v.created.?);
|
||||
}
|
||||
|
||||
test "parseServerView: nulls stay null rather than becoming defaults" {
|
||||
// An uncached symbol answers 200 with nulls. `tracked:false` and "the server
|
||||
// did not say" must not both arrive as false - one is a finding, the other is
|
||||
// an absence of information.
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const body =
|
||||
\\{"symbol":"NOSUCH","tracked":false,"fresh":false,"fail_count":0,"days_behind":0,"last_date":null,"peer_date":"2026-08-12","created":null}
|
||||
;
|
||||
const v = parseServerView(arena.allocator(), body).?;
|
||||
try testing.expectEqual(@as(?Date, null), v.last_date);
|
||||
try testing.expectEqual(@as(?i64, null), v.created);
|
||||
try testing.expectEqual(false, v.tracked.?);
|
||||
}
|
||||
|
||||
test "parseServerView: a newer server's extra fields do not blind an older client" {
|
||||
// Forward compatibility is the whole reason fields are read individually
|
||||
// rather than by struct coercion: an added field must not cost the client
|
||||
// every field it does understand.
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const body =
|
||||
\\{"tracked":true,"unheard_of":{"nested":[1,2]},"days_behind":3,"expires":123}
|
||||
;
|
||||
const v = parseServerView(arena.allocator(), body).?;
|
||||
try testing.expectEqual(true, v.tracked.?);
|
||||
try testing.expectEqual(@as(i64, 3), v.days_behind.?);
|
||||
}
|
||||
|
||||
test "parseServerView: junk yields null, and a wrong-typed field is skipped not fatal" {
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
// Not JSON at all - e.g. an HTML error page from a proxy.
|
||||
try testing.expectEqual(@as(?ServerView, null), parseServerView(a, "<html>502</html>"));
|
||||
// Valid JSON, wrong shape.
|
||||
try testing.expectEqual(@as(?ServerView, null), parseServerView(a, "[1,2,3]"));
|
||||
// A field of the wrong type is dropped; the rest still parses. Better than
|
||||
// failing the whole record over one bad value.
|
||||
const v = parseServerView(a, "{\"tracked\":\"yes\",\"days_behind\":7}").?;
|
||||
try testing.expectEqual(@as(?bool, null), v.tracked);
|
||||
try testing.expectEqual(@as(i64, 7), v.days_behind.?);
|
||||
// An unparseable date is null, not an error.
|
||||
const v2 = parseServerView(a, "{\"last_date\":\"not-a-date\"}").?;
|
||||
try testing.expectEqual(@as(?Date, null), v2.last_date);
|
||||
}
|
||||
|
||||
test "classify: a server that never refreshes the symbol outranks plain held_by_server" {
|
||||
// SPCX's real state. `held_by_server` is true but stops short of the part
|
||||
// that matters: no pass on the server will ever move this copy, so a local
|
||||
// refresh fixes today and nothing else.
|
||||
const base = Observed{
|
||||
.local = d(2026, 6, 29),
|
||||
.local_fresh = false,
|
||||
.peer = d(2026, 8, 12),
|
||||
.server = d(2026, 6, 29),
|
||||
.provider = d(2026, 8, 12),
|
||||
.tracked = true,
|
||||
};
|
||||
var o = base;
|
||||
o.server_tracked = false;
|
||||
try testing.expectEqual(Verdict.server_untracked, classify(o));
|
||||
|
||||
// Tracked on the server: it will catch up on the next pass, so the weaker
|
||||
// verdict is the correct one.
|
||||
o.server_tracked = true;
|
||||
try testing.expectEqual(Verdict.held_by_server, classify(o));
|
||||
|
||||
// The server did not say (older build, or no server): must not be read as
|
||||
// untracked. Silence is not a finding.
|
||||
o.server_tracked = null;
|
||||
try testing.expectEqual(Verdict.held_by_server, classify(o));
|
||||
}
|
||||
|
||||
test "classify: untracked on the server is not a blocker while its copy is current" {
|
||||
// A refresh works right now - that is what the verdict answers. The standing
|
||||
// risk that nothing will refresh it later belongs on the `server` output line,
|
||||
// not promoted into a blocker that does not exist yet.
|
||||
try testing.expectEqual(Verdict.refreshable, classify(.{
|
||||
.local = d(2026, 8, 6),
|
||||
.local_fresh = false,
|
||||
.server = d(2026, 8, 12),
|
||||
.provider = d(2026, 8, 12),
|
||||
.tracked = true,
|
||||
.server_tracked = false,
|
||||
}));
|
||||
}
|
||||
|
||||
test "classify: the local TTL still outranks an untracked server" {
|
||||
// Ordering by proximate cause, matching `held_by_ttl` over `held_by_server`:
|
||||
// an unlapsed TTL stops the fetch inside `getCandles`, before any sync is
|
||||
// attempted, so the server's intent has not come into play yet.
|
||||
try testing.expectEqual(Verdict.held_by_ttl, classify(.{
|
||||
.local = d(2026, 6, 29),
|
||||
.local_fresh = true,
|
||||
.server = d(2026, 6, 29),
|
||||
.provider = d(2026, 8, 12),
|
||||
.tracked = true,
|
||||
.server_tracked = false,
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
355
src/commands/server.zig
Normal file
355
src/commands/server.zig
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
//! `zfin server refresh SYMBOL...` - ask the shared server to force-refresh
|
||||
//! symbols in ITS cache, right now.
|
||||
//!
|
||||
//! The distinction from `zfin cache refresh` matters and is easy to get backwards:
|
||||
//!
|
||||
//! `cache refresh` acts on the LOCAL cache, bypassing the TTL *and* the
|
||||
//! shared server, so it fetches straight from the provider.
|
||||
//! `server refresh` asks the SERVER to refresh its own copy. Nothing local
|
||||
//! changes until the next run syncs from it.
|
||||
//!
|
||||
//! Which one you want depends on where the stale bar lives. `zfin diagnose SYMBOL`
|
||||
//! answers that: when it reports the server offering a bar behind your local copy,
|
||||
//! or tracking the symbol at all, this is the lever. When the server is fine and
|
||||
//! only your copy is behind, `cache refresh` is.
|
||||
//!
|
||||
//! Exists because a symbol the server serves but does not refresh will hand every
|
||||
//! client the same stale bar indefinitely, and until now the only fix was an ssh
|
||||
//! session and a cron run.
|
||||
|
||||
const std = @import("std");
|
||||
const cli = @import("common.zig");
|
||||
const framework = @import("framework.zig");
|
||||
const http = @import("../net/http.zig");
|
||||
|
||||
pub const ParsedArgs = struct {
|
||||
symbols: []const []const u8,
|
||||
};
|
||||
|
||||
pub const meta: framework.Meta = .{
|
||||
.name = "server",
|
||||
.group = .infra,
|
||||
.synopsis = "Ask the shared server to refresh its own cache",
|
||||
.help =
|
||||
\\Usage: zfin server refresh SYMBOL [SYMBOL...]
|
||||
\\
|
||||
\\Force-refreshes candle data in the SHARED SERVER's cache (ZFIN_SERVER),
|
||||
\\bypassing its TTL. Reports, per symbol, whether its newest bar actually
|
||||
\\moved - a fetch can succeed and change nothing when the provider has no
|
||||
\\newer data, which is a finding rather than a success.
|
||||
\\
|
||||
\\Not the same as `zfin cache refresh`, which acts on your LOCAL cache and
|
||||
\\deliberately bypasses the server. Use `zfin diagnose SYMBOL` to see which
|
||||
\\side is behind before picking one.
|
||||
\\
|
||||
\\Nothing local changes: your next normal run picks up the server's new copy.
|
||||
\\
|
||||
\\Requires ZFIN_SERVER, and ZFIN_SERVER_API_KEY when the server enforces one.
|
||||
\\The server caps a single request (currently 25 symbols) and refuses a second
|
||||
\\concurrent refresh rather than queueing it.
|
||||
\\
|
||||
,
|
||||
.uppercase_first_arg = false,
|
||||
.user_errors = error{ MissingSubcommand, UnknownSubcommand, MissingSymbol },
|
||||
};
|
||||
|
||||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
if (cmd_args.len < 1) {
|
||||
cli.stderrPrint(ctx.io, "Error: 'server' requires a subcommand (refresh)\n");
|
||||
return error.MissingSubcommand;
|
||||
}
|
||||
// Only one subcommand today. Matched explicitly rather than ignored so a typo
|
||||
// is an error instead of silently refreshing whatever came next - `zfin server
|
||||
// referesh AMZN` must not treat "referesh" as a symbol.
|
||||
if (!std.mem.eql(u8, cmd_args[0], "refresh")) {
|
||||
cli.stderrPrint(ctx.io, "Error: unknown 'server' subcommand (expected: refresh)\n");
|
||||
return error.UnknownSubcommand;
|
||||
}
|
||||
if (cmd_args.len < 2) {
|
||||
cli.stderrPrint(ctx.io, "Error: 'server refresh' requires at least one symbol\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
return .{ .symbols = cmd_args[1..] };
|
||||
}
|
||||
|
||||
/// One symbol's outcome, as the server reported it.
|
||||
pub const Outcome = struct {
|
||||
symbol: []const u8,
|
||||
ok: bool,
|
||||
/// Did the newest bar actually advance? The question the caller has: a
|
||||
/// successful fetch that moves nothing means the provider had nothing newer,
|
||||
/// which is a different situation from a fix.
|
||||
moved: bool = false,
|
||||
last_date: ?[]const u8 = null,
|
||||
/// Server-side error NAME when `ok` is false, passed through rather than
|
||||
/// reworded - an auth failure, a rate limit and a delisted ticker each send
|
||||
/// you somewhere different.
|
||||
err: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
/// Parse the server's `{"results":[...]}` body.
|
||||
///
|
||||
/// Returns null only when the body is not the expected shape at all. Individual
|
||||
/// fields are read defensively: a newer server adding fields must not cost an
|
||||
/// older client the ones it understands. Separated from the request so the shape
|
||||
/// is testable without a server.
|
||||
pub fn parseResults(arena: std.mem.Allocator, body: []const u8) ?[]const Outcome {
|
||||
const parsed = std.json.parseFromSlice(std.json.Value, arena, body, .{}) catch return null;
|
||||
const obj = switch (parsed.value) {
|
||||
.object => |o| o,
|
||||
else => return null,
|
||||
};
|
||||
const results = switch (obj.get("results") orelse return null) {
|
||||
.array => |a| a,
|
||||
else => return null,
|
||||
};
|
||||
|
||||
var out = std.ArrayList(Outcome).empty;
|
||||
for (results.items) |item| {
|
||||
const r = switch (item) {
|
||||
.object => |o| o,
|
||||
else => continue,
|
||||
};
|
||||
const sym = switch (r.get("symbol") orelse continue) {
|
||||
.string => |s| s,
|
||||
else => continue,
|
||||
};
|
||||
var o = Outcome{ .symbol = sym, .ok = false };
|
||||
if (r.get("ok")) |v| if (v == .bool) {
|
||||
o.ok = v.bool;
|
||||
};
|
||||
if (r.get("moved")) |v| if (v == .bool) {
|
||||
o.moved = v.bool;
|
||||
};
|
||||
if (r.get("last_date")) |v| if (v == .string) {
|
||||
o.last_date = v.string;
|
||||
};
|
||||
if (r.get("error")) |v| if (v == .string) {
|
||||
o.err = v.string;
|
||||
};
|
||||
out.append(arena, o) catch return null;
|
||||
}
|
||||
return out.items;
|
||||
}
|
||||
|
||||
/// Human-readable guidance for a transport-level failure.
|
||||
///
|
||||
/// Every branch names the actual condition rather than "request failed": the
|
||||
/// whole point of `HttpError.Conflict` existing is that "a refresh is already
|
||||
/// running" and "your key is wrong" are different problems, and collapsing them
|
||||
/// sends you to the wrong place.
|
||||
pub fn failureAdvice(err: anyerror) []const u8 {
|
||||
return switch (err) {
|
||||
error.Conflict => "a refresh is already running there - retry once it finishes",
|
||||
error.Unauthorized => "rejected: check ZFIN_SERVER_API_KEY",
|
||||
error.NotFound => "this server has no /refresh endpoint - it predates the feature",
|
||||
error.RateLimited => "the server is rate-limiting; retry shortly",
|
||||
error.ServerError => "the server errored; check its logs",
|
||||
// 400 lands here, and the server's 400s are specific ("Too many symbols:
|
||||
// 26 (limit 25)", "Invalid symbol: NK E"). That text is already emitted by
|
||||
// the transport as an `http rejection body` warning, so point at it rather
|
||||
// than reprinting "request failed" directly beneath the real reason.
|
||||
//
|
||||
// Deliberately NOT solved by mapping 400 to its own HttpError variant:
|
||||
// `InvalidResponse` is load-bearing elsewhere - `service.zig` routes it to
|
||||
// the Yahoo fallback and `isPermanentProviderFailure` counts it transient -
|
||||
// so renaming it would alter the provider chain to improve one CLI message.
|
||||
error.InvalidResponse => "the server rejected the request - its reason is on the `http rejection body` line above",
|
||||
else => "request failed",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||
const io = ctx.io;
|
||||
const out = ctx.out;
|
||||
|
||||
const base = ctx.config.server_url orelse {
|
||||
cli.stderrPrint(io, "Error: ZFIN_SERVER is not set - there is no server to ask\n");
|
||||
return;
|
||||
};
|
||||
|
||||
var arena_state = std.heap.ArenaAllocator.init(ctx.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
// Uppercased here so the request matches what the server stores and what the
|
||||
// response echoes; the framework's `uppercase_first_arg` only covers the first
|
||||
// operand, which for this command is the subcommand.
|
||||
var joined = std.ArrayList(u8).empty;
|
||||
for (parsed.symbols, 0..) |sym, i| {
|
||||
if (i > 0) try joined.append(arena, ',');
|
||||
const upper = try arena.alloc(u8, sym.len);
|
||||
for (sym, 0..) |c, j| upper[j] = std.ascii.toUpper(c);
|
||||
try joined.appendSlice(arena, upper);
|
||||
}
|
||||
|
||||
// Percent-encoded rather than interpolated. A hand-built query string put a
|
||||
// malformed symbol's space straight into the request line, so httpz rejected
|
||||
// the request at the HTTP layer and answered with its own generic "Invalid
|
||||
// Request" - the operator saw a protocol complaint instead of the server's
|
||||
// "Invalid symbol: NK E". An `&` would have been worse: it would silently
|
||||
// truncate the symbol list. `isQueryValueChar` leaves `,` unencoded, which is
|
||||
// what the server splits on.
|
||||
const endpoint = try std.fmt.allocPrint(arena, "{s}/refresh", .{base});
|
||||
const url = try http.buildUrl(arena, endpoint, &.{.{ "symbols", joined.items }});
|
||||
var client = http.Client.init(io, arena);
|
||||
defer client.deinit();
|
||||
|
||||
var hdr: [1]std.http.Header = .{.{ .name = "", .value = "" }};
|
||||
const extra: []const std.http.Header = if (ctx.config.server_api_key) |k| blk: {
|
||||
hdr[0] = .{ .name = "X-API-Key", .value = k };
|
||||
break :blk hdr[0..1];
|
||||
} else &.{};
|
||||
|
||||
try out.print("Asking {s} to refresh {d} symbol(s)...\n", .{ base, parsed.symbols.len });
|
||||
try out.flush();
|
||||
|
||||
// POST, with an empty body: the symbols travel as query parameters, matching
|
||||
// the server's other operator endpoints and keeping the call curl-able.
|
||||
var resp = client.request(.POST, url, "", extra) catch |err| {
|
||||
// Both the advice and the underlying error name: the advice is what to do,
|
||||
// the name is what actually happened, and dropping either leaves the
|
||||
// operator guessing at one of them.
|
||||
const msg = std.fmt.allocPrint(arena, "Error: {s} ({s})\n", .{
|
||||
failureAdvice(err),
|
||||
@errorName(err),
|
||||
}) catch "Error: request failed\n";
|
||||
cli.stderrPrint(io, msg);
|
||||
return;
|
||||
};
|
||||
defer resp.deinit();
|
||||
|
||||
const results = parseResults(arena, resp.body) orelse {
|
||||
cli.stderrPrint(io, "Error: could not parse the server's reply\n");
|
||||
return;
|
||||
};
|
||||
|
||||
var moved: usize = 0;
|
||||
var failed: usize = 0;
|
||||
for (results) |r| {
|
||||
if (!r.ok) {
|
||||
failed += 1;
|
||||
try out.print(" {s:<10} FAILED {s}\n", .{ r.symbol, r.err orelse "unknown error" });
|
||||
continue;
|
||||
}
|
||||
if (r.moved) moved += 1;
|
||||
// "unchanged" rather than "ok": a successful fetch that moved nothing is
|
||||
// the shape of a provider with no newer data, and calling it ok would hide
|
||||
// exactly what the operator came to find out.
|
||||
try out.print(" {s:<10} {s:<10} {s}\n", .{
|
||||
r.symbol,
|
||||
if (r.moved) "moved" else "unchanged",
|
||||
r.last_date orelse "no bar",
|
||||
});
|
||||
}
|
||||
|
||||
try out.print("\n{d} moved, {d} unchanged, {d} failed\n", .{
|
||||
moved,
|
||||
results.len - moved - failed,
|
||||
failed,
|
||||
});
|
||||
if (moved > 0) {
|
||||
try out.print("Your local cache is untouched - a normal run will sync the new copy.\n", .{});
|
||||
}
|
||||
try out.flush();
|
||||
}
|
||||
|
||||
// ── tests ────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "parseResults: the shape zfin-server actually returns" {
|
||||
// Verbatim from `POST /refresh?symbols=NKE,AMZN,ZZZZQQ` against a live server.
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const body =
|
||||
\\{"results":[{"symbol":"NKE","ok":true,"moved":true,"last_date":"2026-08-12"},{"symbol":"AMZN","ok":true,"moved":false,"last_date":"2026-08-12"},{"symbol":"ZZZZQQ","ok":false,"error":"FetchFailed"}]}
|
||||
;
|
||||
const r = parseResults(arena.allocator(), body).?;
|
||||
try testing.expectEqual(@as(usize, 3), r.len);
|
||||
|
||||
try testing.expectEqualStrings("NKE", r[0].symbol);
|
||||
try testing.expect(r[0].ok);
|
||||
try testing.expect(r[0].moved);
|
||||
try testing.expectEqualStrings("2026-08-12", r[0].last_date.?);
|
||||
|
||||
// The distinction the command exists to surface: fetched fine, moved nothing.
|
||||
try testing.expect(r[1].ok);
|
||||
try testing.expect(!r[1].moved);
|
||||
|
||||
// A failure carries the server's error name, not a reworded summary.
|
||||
try testing.expect(!r[2].ok);
|
||||
try testing.expectEqualStrings("FetchFailed", r[2].err.?);
|
||||
}
|
||||
|
||||
test "parseResults: a null last_date survives, and junk is rejected" {
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const a = arena.allocator();
|
||||
|
||||
// An uncached symbol reports success with no bar. `null` must not become the
|
||||
// string "null" or an empty date.
|
||||
const r = parseResults(a, "{\"results\":[{\"symbol\":\"X\",\"ok\":true,\"moved\":false,\"last_date\":null}]}").?;
|
||||
try testing.expectEqual(@as(?[]const u8, null), r[0].last_date);
|
||||
|
||||
// Not JSON at all - e.g. an HTML error page from a proxy in front of it.
|
||||
try testing.expectEqual(@as(?[]const Outcome, null), parseResults(a, "<html>502</html>"));
|
||||
// JSON, wrong shape.
|
||||
try testing.expectEqual(@as(?[]const Outcome, null), parseResults(a, "[1,2,3]"));
|
||||
try testing.expectEqual(@as(?[]const Outcome, null), parseResults(a, "{\"other\":[]}"));
|
||||
// An empty result set is valid, not an error.
|
||||
try testing.expectEqual(@as(usize, 0), parseResults(a, "{\"results\":[]}").?.len);
|
||||
}
|
||||
|
||||
test "parseResults: an unknown field does not blind an older client" {
|
||||
// Forward compatibility, the same reason `diagnose` reads fields individually
|
||||
// rather than by struct coercion.
|
||||
var arena = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
const body =
|
||||
\\{"results":[{"symbol":"NKE","ok":true,"moved":true,"last_date":"2026-08-12","queued_at":123,"extra":{"a":1}}],"summary":"whatever"}
|
||||
;
|
||||
const r = parseResults(arena.allocator(), body).?;
|
||||
try testing.expectEqual(@as(usize, 1), r.len);
|
||||
try testing.expect(r[0].moved);
|
||||
}
|
||||
|
||||
test "failureAdvice: each condition points somewhere different" {
|
||||
// The justification for adding `HttpError.Conflict` at all. Before it, 409
|
||||
// collapsed into InvalidResponse and this command would have told the operator
|
||||
// their request was malformed when it just needed a retry.
|
||||
try testing.expect(std.mem.indexOf(u8, failureAdvice(error.Conflict), "already running") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, failureAdvice(error.Unauthorized), "ZFIN_SERVER_API_KEY") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, failureAdvice(error.NotFound), "predates") != null);
|
||||
// 400 arrives as InvalidResponse. The advice must send the operator to the
|
||||
// logged rejection body, which carries the server's exact complaint - observed
|
||||
// returning "Too many symbols: 26 (limit 25)" while this line said only
|
||||
// "request failed".
|
||||
try testing.expect(std.mem.indexOf(u8, failureAdvice(error.InvalidResponse), "rejection body") != null);
|
||||
try testing.expect(!std.mem.eql(u8, failureAdvice(error.InvalidResponse), failureAdvice(error.ConnectionRefused)));
|
||||
|
||||
// Every branch must say something concrete; none may be empty.
|
||||
for ([_]anyerror{
|
||||
error.Conflict, error.Unauthorized, error.NotFound,
|
||||
error.RateLimited, error.ServerError, error.InvalidResponse,
|
||||
error.ConnectionRefused,
|
||||
}) |e| {
|
||||
try testing.expect(failureAdvice(e).len > 0);
|
||||
}
|
||||
|
||||
// And the advice must never be the same for Conflict and Unauthorized, which
|
||||
// is the exact collapse this replaced.
|
||||
try testing.expect(!std.mem.eql(u8, failureAdvice(error.Conflict), failureAdvice(error.Unauthorized)));
|
||||
}
|
||||
|
||||
test "the symbols parameter is percent-encoded" {
|
||||
// Guards the bug this shipped with: an unencoded space made the request line
|
||||
// malformed, so the server never saw the symbol and could not explain what was
|
||||
// wrong with it. An `&` would have truncated the list without any error at all.
|
||||
const a = testing.allocator;
|
||||
const url = try http.buildUrl(a, "https://h/refresh", &.{.{ "symbols", "NK E,A&B" }});
|
||||
defer a.free(url);
|
||||
try testing.expectEqualStrings("https://h/refresh?symbols=NK%20E,A%26B", url);
|
||||
// The comma must NOT be encoded - it is the separator the server splits on.
|
||||
try testing.expect(std.mem.indexOfScalar(u8, url, ',') != null);
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ const command_modules = .{
|
|||
// Infrastructure
|
||||
.cache = @import("commands/cache.zig"),
|
||||
.diagnose = @import("commands/diagnose.zig"),
|
||||
.server = @import("commands/server.zig"),
|
||||
.doctor = @import("commands/doctor.zig"),
|
||||
.version = @import("commands/version.zig"),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ pub const HttpError = std.Uri.ParseError ||
|
|||
/// by the caller's current plan. Providers should translate this into
|
||||
/// "no data" rather than a hard failure.
|
||||
PaymentRequired,
|
||||
/// HTTP 409 Conflict - the request cannot run because an equivalent one
|
||||
/// is already in flight server-side. Distinct from `ServerError` because
|
||||
/// it must NOT be retried by the transport: the correct response is to
|
||||
/// wait for the in-flight operation, and a retry would only be refused
|
||||
/// again. `zfin-server`'s `POST /refresh` returns this while a refresh is
|
||||
/// running, and collapsing it into `InvalidResponse` reported a
|
||||
/// wait-and-retry condition as a malformed request.
|
||||
Conflict,
|
||||
ServerError,
|
||||
InvalidResponse,
|
||||
};
|
||||
|
|
@ -426,6 +434,7 @@ pub const Client = struct {
|
|||
.unauthorized, .forbidden => HttpError.Unauthorized,
|
||||
.payment_required => HttpError.PaymentRequired,
|
||||
.not_found => HttpError.NotFound,
|
||||
.conflict => HttpError.Conflict,
|
||||
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
|
||||
else => HttpError.InvalidResponse,
|
||||
};
|
||||
|
|
@ -501,6 +510,7 @@ test "classifyResponse maps each HTTP status to its HttpError" {
|
|||
.{ .status = .forbidden, .expected = HttpError.Unauthorized },
|
||||
.{ .status = .payment_required, .expected = HttpError.PaymentRequired },
|
||||
.{ .status = .not_found, .expected = HttpError.NotFound },
|
||||
.{ .status = .conflict, .expected = HttpError.Conflict },
|
||||
.{ .status = .internal_server_error, .expected = HttpError.ServerError },
|
||||
.{ .status = .bad_gateway, .expected = HttpError.ServerError },
|
||||
.{ .status = .service_unavailable, .expected = HttpError.ServerError },
|
||||
|
|
|
|||
|
|
@ -3300,6 +3300,18 @@ pub const DataService = struct {
|
|||
/// returns the next boundary when it is, so a genuinely caught-up fetch
|
||||
/// behaves exactly as before.
|
||||
///
|
||||
/// TWO EARLIER DIAGNOSES OF THIS WERE WRONG, recorded so they are not
|
||||
/// re-derived at the same cost:
|
||||
///
|
||||
/// - It is NOT `market.staleCandleExpiry` routing `.overdue` to the next
|
||||
/// boundary. That path was never reached on the observed run:
|
||||
/// `created=Mon 17:00` beside `expires=Tue 16:55` proves it, because five
|
||||
/// minutes past the target is well inside `provider_lag_grace_s`, so the
|
||||
/// verdict there was `.lagging`, not `.overdue`.
|
||||
/// - It is NOT the 90-minute grace window being too short. It behaved
|
||||
/// exactly as designed; the defect was upstream of it, in treating any
|
||||
/// non-empty fetch result as proof of catching up.
|
||||
///
|
||||
/// Takes the maximum rather than the last element: provider ordering is not
|
||||
/// something this decision should depend on.
|
||||
fn expiryAfterFetch(now_s: i64, kind: market.InstrumentKind, candles: []const Candle) i64 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue