Compare commits

...

3 commits

Author SHA1 Message Date
44e8c65df4
remove dead code
All checks were successful
Generic zig build / build (push) Successful in 7m13s
Generic zig build / publish-macos (push) Successful in 11s
Generic zig build / deploy (push) Successful in 18s
2026-08-13 16:32:31 -07:00
a9b923f766
add server refresh command 2026-08-13 16:15:22 -07:00
914dfca356
surface server diagnostics as well 2026-08-12 18:53:59 -07:00
8 changed files with 774 additions and 119 deletions

View file

@ -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.?);

View file

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

View file

@ -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;

View file

@ -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
View 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);
}

View file

@ -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"),
};

View file

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

View file

@ -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 {