diff --git a/docs/dev/caching-implementation.md b/docs/dev/caching-implementation.md
index e77ae1e..f683662 100644
--- a/docs/dev/caching-implementation.md
+++ b/docs/dev/caching-implementation.md
@@ -220,6 +220,16 @@ and Tiingo was never consulted again. The backoff is armed only by a
genuine 404, expires after `Ttl.tiingo_backoff` with per-symbol jitter,
and clears the moment Tiingo serves the symbol again.
+`CandleProvider` has four variants - `twelvedata`, `yahoo`, `tiingo`,
+`external` - and exactly one of them is special-cased anywhere:
+`twelvedata`, whose `adj_close` was unreliable, so a cache carrying it is
+treated as unusable. That check exists in **two** places, one per entry
+into the meta-exists branch (the `skip_network` path reports the symbol
+unavailable; the online path forces a full re-fetch). `external` is
+covered under [Externally-managed candle
+series](#externally-managed-candle-series); it is inert on every serve
+path and vetoes only the negative-cache write.
+
```mermaid
flowchart TD
S["getCandles(symbol, opts)"] --> NG{"negative candles_daily and not force_refresh?"}
@@ -227,7 +237,9 @@ flowchart TD
NG -->|no| RM{"candles_meta exists?"}
RM -->|yes| SK{"skip_network?"}
- SK -->|yes| RETS["return cached even if stale
(FetchFailed if unreadable)"]
+ SK -->|yes| TWS{"provider is twelvedata?"}
+ TWS -->|yes| FF
+ TWS -->|no| RETS["return cached even if stale
(FetchFailed if unreadable)"]
SK -->|no| TW{"provider is twelvedata?"}
TW -->|yes| FULL
TW -->|no| FR{"meta fresh and not force_refresh?"}
@@ -252,7 +264,9 @@ flowchart TD
SF2 -->|no| FULL["refetchFullHistory: Tiingo, then Yahoo"]
FULL --> RES{"result?"}
RES -->|ok| RET2
- RES -->|"NotFound (EVERY provider disclaims it)"| WN["writeNegative candles_daily"]
+ RES -->|"NotFound (EVERY provider disclaims it)"| SNC{"prior meta says provider is external?"}
+ SNC -->|yes| FF
+ SNC -->|no| WN["writeNegative candles_daily"]
WN --> FF
RES -->|transient| TR["bump fail_count, TransientError"]
RES -->|other| FF
@@ -354,6 +368,12 @@ re-run the dead lookup on every invocation. The entry is the sentinel:
writes that file; `isCandleMetaFresh`, `getCachedCandles`, and the
`getCandles` short-circuit all recognize it there. `candles_meta.srf`
is intentionally not created for a no-data symbol.
+- **Externally-managed series are exempt.** A prior meta saying
+ `provider::external` vetoes the write via
+ `DataService.shouldNegativeCache`, because for a series no provider was
+ ever going to carry, the marker is unrecoverable rather than merely
+ sticky. See [Externally-managed candle
+ series](#externally-managed-candle-series).
## Candle-less symbols (crypto and friends)
@@ -391,6 +411,88 @@ If you want a candle-less symbol to be re-checked against the provider
negative entry with `cache clear` or `--refresh-data=force`; the live
quote and manual-price paths are unaffected by the negative cache.
+## Externally-managed candle series
+
+`CandleMeta.provider == .external` labels a series that was produced and
+is maintained **outside zfin**, by something that writes
+`candles_daily.srf` and `candles_meta.srf` directly into a cache
+directory. The motivating case is a unitized trust with no ticker and no
+CUSIP, whose daily unit values come from a plan recordkeeper's feed;
+every market-data provider 404s it.
+
+Like the previous section, this is a symbol no provider carries. The
+difference is that a candle-less symbol has no history *anywhere*, so
+negative-caching it is correct, whereas an externally-managed symbol has
+a real series that simply did not come from a provider. The label exists
+so `candles_meta.srf` can say so honestly instead of naming a provider
+that never served it.
+
+Two population routes:
+
+- **Server**: a job writes the pair straight into the `ZFIN_SERVER`
+ cache directory. That copy is the master.
+- **Client**: the existing server-sync path (phase 2 of `loadAllPrices`,
+ and the two `syncCandlesFromServer` calls inside `getCandles`) pulls
+ the bytes down verbatim, exactly as it does for any other symbol. No
+ client-side special-casing is involved.
+
+The label is **pure provenance and drives no routing**. zfin still walks
+the normal provider chain for such a symbol and still 404s on every pass;
+restricting it to the server tier and skipping the chain entirely is
+deferred work. There is exactly one behavior attached to the label, and
+it is a veto rather than a routing decision:
+
+**A negative-cache entry on an external symbol is unrecoverable.**
+`writeNegative` overwrites `candles_daily.srf` with a marker, negative
+entries never expire, and `getCandles` short-circuits on `isNegative`
+*before* it reaches `syncCandlesFromServer` - so the marker makes the
+only surviving copy permanently unreachable. On a server it is worse:
+the externally-populated cache is the master copy, so nothing survives.
+`DataService.shouldNegativeCache` therefore refuses the write when the
+prior meta says `.external`.
+
+That guard is deliberately partial. It consults the prior meta, so it
+covers the case that destroys data: meta present, `candles_daily.srf`
+missing or unreadable (a partially-completed server sync writes the two
+files in sequence and needs both to report success), which falls through
+to the cold-start path. It cannot cover a **true cold start** with both
+files absent - there is no label to read - so an external symbol first
+touched while `ZFIN_SERVER` is unreachable is poisoned until
+`--refresh-data=force` or `cache clear`. Closing that needs the
+`isNegative` short-circuit moved after the server tier, which would
+re-hit the network for every legitimately candle-less symbol on every
+run. It belongs with the deferred routing work.
+
+### Adding a `CandleProvider` variant is a coordinated deployment
+
+Not a backward-compatible change. `provider` has no default, so it is
+never elided, and SRF's enum coercion has no lenient mode and no
+`srfParse` hook (custom parsers are consulted for struct/union fields
+only). A build that does not know a variant cannot fall back:
+
+- srf `>= ea2c358` (zfin's current pin) returns
+ `StringValueNotValidEnumMember`. `readCandleMeta` swallows it into a
+ `null`, which reads as a cache miss and routes the symbol down the
+ cold-start path above - destructive for precisely the series that
+ cannot be re-fetched.
+- srf `<= 4a3e5f0` unwraps a null optional and **panics**. That is what
+ zfin-server `7103ced` vendors, and a panic is not interceptable by the
+ `catch return null` every caller depends on. The blast radius is not
+ one symbol: `handleDiagnostics` runs `freshness.collect` over every key
+ in the cache directory, so a single unreadable file takes down
+ `//diagnostics` and the cron refresh sweep. The
+ byte-serving routes (`/candles`, `/candles_meta`) are unaffected, since
+ they never parse.
+
+Skew is dangerous in both directions: an old **client** syncing
+`candles_meta` bytes from a new server fails the same way, because
+`looksCompleteSrf` and the ETag check validate shape and integrity, not
+enum values.
+
+So the ordering is: release zfin, then rebuild and deploy every server
+that reads the affected cache directory, and only then may a file
+carrying the new value exist.
+
## Server sync (the optional L2 tier)
`ZFIN_SERVER` points zfin at a
@@ -447,6 +549,8 @@ the upstream provider on every request.
| Freshness check | `isFresh` (SRF), `isCandleMetaFresh` - `store.zig` |
| TTLs and expiry computation | `Ttl`, `computeExpires` - `src/cache/store.zig` |
| Negative cache | `writeNegative`, `isNegative` - `src/cache/store.zig` |
+| Negative-cache veto | `shouldNegativeCache` - `src/service.zig` |
+| Candle provenance labels | `CandleProvider` - `src/cache/store.zig` |
| NotFound classification | `isPermanentProviderFailure` - `src/service.zig` |
| Market-aware candle expiry | `nextCandleExpiry`, `shouldRefresh` - `src/market.zig` |
| Price fallback (manual/avg-cost)| `buildFallbackPrices` - `src/analytics/valuation.zig` |
diff --git a/docs/explanation/caching.md b/docs/explanation/caching.md
index bfff230..8532e03 100644
--- a/docs/explanation/caching.md
+++ b/docs/explanation/caching.md
@@ -138,6 +138,13 @@ ticker, say -- zfin records a negative cache entry so it doesn't retry
the same dead lookup on every run. (Transient failures like rate limits
are not cached this way; they're retried.)
+One exception: a price history that was loaded into the cache from
+outside zfin, rather than fetched from a provider, is never
+negative-cached. Such a series has no provider to re-fetch it from, so
+recording "nobody has this" would discard the only copy. `zfin diagnose
+SYMBOL` names the source of a symbol's bars, and reports `external` for
+these.
+
## Symbols without candle data (crypto)
A few holdings have no daily price history available from zfin's candle
diff --git a/src/cache/store.zig b/src/cache/store.zig
index f20d655..e5626c7 100644
--- a/src/cache/store.zig
+++ b/src/cache/store.zig
@@ -1415,6 +1415,18 @@ pub const Store = struct {
/// which writes a new meta file with the provider explicit.
/// The wipe happens naturally on first use post-upgrade.
///
+ /// A field that is *present but carries a value this build
+ /// does not know* - i.e. a cache written by a NEWER zfin that
+ /// has added a variant - is a different and nastier failure.
+ /// SRF's enum coercion has no lenient mode and no `srfParse`
+ /// hook (it consults custom parsers only for struct/union
+ /// fields), so the value either raises
+ /// `StringValueNotValidEnumMember` (srf >= ea2c358, swallowed
+ /// into the same destructive cache-miss path above) or panics
+ /// on an unchecked optional unwrap (srf <= 4a3e5f0). Adding a
+ /// variant is therefore a coordinated deployment, not a
+ /// backward-compatible change; see `CandleProvider.external`.
+ ///
/// This is also why no *other* field on this struct may be
/// default-less: that cold-start path is the destructive one.
/// It writes a negative-cache marker over `candles_daily.srf`
@@ -1527,12 +1539,52 @@ pub const Store = struct {
/// dividends and splits from the same response, and its
/// `adj_close` is what the analytics layer is written against.
tiingo,
-
- pub fn fromString(s: []const u8) CandleProvider {
- if (std.mem.eql(u8, s, "yahoo")) return .yahoo;
- if (std.mem.eql(u8, s, "tiingo")) return .tiingo;
- return .twelvedata;
- }
+ /// Candles were produced and are managed OUTSIDE zfin, by
+ /// something that writes `candles_daily.srf` and
+ /// `candles_meta.srf` directly into a cache directory.
+ ///
+ /// Pure provenance like every other variant - it records where
+ /// the bars came from and drives no routing. What sets it
+ /// apart is what it implies about their origin: **no
+ /// market-data provider carries the symbol at all**. The
+ /// motivating case is a unitized trust with no ticker and no
+ /// CUSIP, whose daily unit values come from a plan
+ /// recordkeeper's feed; Tiingo, Yahoo and TwelveData all 404
+ /// it. So zfin can never fetch or restate such a series
+ /// itself. It arrives either by `ZFIN_SERVER` sync (the client
+ /// case) or by direct population of the cache directory (the
+ /// server case).
+ ///
+ /// **A negative-cache entry on such a symbol is unrecoverable**,
+ /// not merely inconvenient. `writeNegative` overwrites
+ /// `candles_daily.srf` with a marker, negative entries never
+ /// expire (`isCandleMetaFresh` reports them fresh forever), and
+ /// `getCandles` short-circuits on `isNegative` *before* it
+ /// reaches `syncCandlesFromServer` - so the one copy that still
+ /// exists becomes permanently unreachable. On a server, where
+ /// the externally-populated cache IS the master copy, there is
+ /// no other copy at all. `shouldNegativeCache` in `service.zig`
+ /// refuses that write for exactly this reason; see its doc
+ /// comment for the case it cannot cover.
+ ///
+ /// **Version compatibility is a deployment ordering
+ /// constraint, not a nicety.** This value is unknown to any
+ /// zfin built before it existed, and an older reader does not
+ /// degrade gracefully:
+ ///
+ /// - srf >= ea2c358 (what zfin pins today) returns
+ /// `error.StringValueNotValidEnumMember`, which
+ /// `readCandleMeta` swallows into a `null` - i.e. a cache
+ /// miss, which routes the symbol down the destructive
+ /// cold-start path described on the `provider` field.
+ /// - srf <= 4a3e5f0 unwraps a null optional and **panics**.
+ /// That is what zfin-server 7103ced vendors, and a panic
+ /// is not interceptable by `readCandleMeta`'s `catch`.
+ ///
+ /// So no file carrying `provider::external` may exist until
+ /// every reader of that cache directory - client and server -
+ /// runs a zfin that knows the variant.
+ external,
};
// ── Private I/O ──────────────────────────────────────────────
@@ -3710,13 +3762,95 @@ test "Store.Freshness enum values" {
try std.testing.expect(Store.Freshness.fresh_only != Store.Freshness.any);
}
-test "CandleProvider.fromString parses provider names" {
- try std.testing.expectEqual(Store.CandleProvider.yahoo, Store.CandleProvider.fromString("yahoo"));
- try std.testing.expectEqual(Store.CandleProvider.tiingo, Store.CandleProvider.fromString("tiingo"));
- try std.testing.expectEqual(Store.CandleProvider.twelvedata, Store.CandleProvider.fromString("twelvedata"));
- // Unknown defaults to twelvedata
- try std.testing.expectEqual(Store.CandleProvider.twelvedata, Store.CandleProvider.fromString("unknown"));
- try std.testing.expectEqual(Store.CandleProvider.twelvedata, Store.CandleProvider.fromString(""));
+test "candles_meta with an unknown provider value reads as a cache miss" {
+ // The forward-compatibility contract for `CandleProvider`, pinned.
+ //
+ // SRF's enum coercion has no lenient mode and no `srfParse` hook
+ // (custom parsers are consulted for struct/union fields only), so a
+ // `provider::` value this build does not know cannot be mapped to a
+ // fallback. It raises `StringValueNotValidEnumMember`, which
+ // `deserializeCandleMeta` reports as `InvalidData` and
+ // `readCandleMeta` swallows into a null - i.e. the symbol looks like
+ // a cache miss and takes the destructive cold-start path. That is
+ // why adding a variant is a coordinated deployment; see
+ // `CandleProvider.external`.
+ //
+ // This also acts as a canary on the srf pin. Older srf (<= 4a3e5f0)
+ // unwrapped a null optional here instead of returning an error, so
+ // on a downgrade this test PANICS rather than failing - which is
+ // itself the signal, since a panic is not interceptable by the
+ // `catch return null` that every caller relies on.
+ const io = std.testing.io;
+ const allocator = std.testing.allocator;
+
+ const unknown_variant =
+ \\#!srfv1
+ \\#!expires=9999999999
+ \\#!created=1786748010
+ \\last_close:num:298.97,last_date::2026-05-19,provider::not_a_real_provider
+ \\
+ ;
+ try std.testing.expectError(error.InvalidData, Store.deserializeCandleMeta(allocator, unknown_variant));
+
+ // And through the real read path: null, not an error, not a partial
+ // parse with a defaulted provider.
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
+ defer allocator.free(dir_path);
+
+ var store = Store.init(io, allocator, dir_path);
+ try store.writeRaw("SMPL", .candles_meta, unknown_variant);
+ try std.testing.expect(store.readCandleMeta("SMPL") == null);
+}
+
+test "CandleMeta round-trips provider::external and never elides it" {
+ // `provider` has no default precisely so SRF cannot elide it, and
+ // `external` is the variant where that matters most: a meta file
+ // that silently omitted it would read back as whatever a future
+ // default happened to be, and an externally-managed series
+ // mislabelled as provider-sourced is exactly the lie this variant
+ // exists to prevent.
+ const allocator = std.testing.allocator;
+ const meta = Store.CandleMeta{
+ .last_close = 24.31,
+ .last_date = Date.fromYmd(2026, 8, 14),
+ .provider = .external,
+ };
+ const data = try Store.serializeCandleMeta(std.testing.io, allocator, meta, .{ .expires = 1234567890 });
+ defer allocator.free(data);
+
+ try std.testing.expect(std.mem.indexOf(u8, data, "provider::external") != null);
+
+ const parsed = try Store.deserializeCandleMeta(allocator, data);
+ try std.testing.expectEqual(Store.CandleProvider.external, parsed.provider);
+ try std.testing.expectApproxEqAbs(@as(f64, 24.31), parsed.last_close, 0.001);
+ try std.testing.expect(parsed.last_date.eql(Date.fromYmd(2026, 8, 14)));
+}
+
+test "readCandleMeta round-trips provider::external through the cache" {
+ // End to end over a real cache directory: `cacheCandles` writes the
+ // pair, `readCandleMeta` reads the label back. Non-null is the
+ // assertion that matters - a null here is the destructive cold-start
+ // path, and for an externally-managed series there is no provider to
+ // cold-start from.
+ const io = std.testing.io;
+ const allocator = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
+ defer allocator.free(dir_path);
+
+ var store = Store.init(io, allocator, dir_path);
+ const bars = [_]Candle{
+ .{ .date = Date.fromYmd(2026, 8, 13), .open = 24, .high = 24, .low = 24, .close = 24, .adj_close = 24, .volume = 0 },
+ .{ .date = Date.fromYmd(2026, 8, 14), .open = 25, .high = 25, .low = 25, .close = 25, .adj_close = 25, .volume = 0 },
+ };
+ store.cacheCandles("XTRN", bars[0..], .{ .provider = .external }, 9_999_999_999);
+
+ const mr = store.readCandleMeta("XTRN") orelse return error.NoCache;
+ try std.testing.expectEqual(Store.CandleProvider.external, mr.meta.provider);
+ try std.testing.expect(mr.meta.last_date.eql(Date.fromYmd(2026, 8, 14)));
}
test "Store init creates valid store" {
diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig
index 85d7eba..4c331e5 100644
--- a/src/models/portfolio.zig
+++ b/src/models/portfolio.zig
@@ -1958,7 +1958,7 @@ test "fetchedSymbols: unions all four sources and dedups across them" {
.{ .symbol = "NON40OR52", .ticker = "SPY", .shares = 5, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 90, .security_type = .stock },
.{ .symbol = "QTUM", .shares = 0, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 0, .security_type = .watch },
// Excluded by stockSymbols: manual price, no ticker alias.
- .{ .symbol = "ORCBI", .shares = 3, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 10, .price = 11, .security_type = .stock },
+ .{ .symbol = "XTRNL", .shares = 3, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 10, .price = 11, .security_type = .stock },
// Excluded: not a stock or watch lot.
.{ .symbol = "CASHX", .shares = 1, .open_date = Date.fromYmd(2026, 1, 2), .open_price = 1, .security_type = .cash },
};
@@ -1982,7 +1982,7 @@ test "fetchedSymbols: unions all four sources and dedups across them" {
}
// Manual-price-only and cash lots stay out.
for (syms) |s| {
- try std.testing.expect(!std.mem.eql(u8, s, "ORCBI"));
+ try std.testing.expect(!std.mem.eql(u8, s, "XTRNL"));
try std.testing.expect(!std.mem.eql(u8, s, "CASHX"));
}
}
diff --git a/src/service.zig b/src/service.zig
index 253505d..53a2a6d 100644
--- a/src/service.zig
+++ b/src/service.zig
@@ -875,6 +875,43 @@ pub const DataService = struct {
return next;
}
+ /// Whether a unanimous `NotFound` from `refetchFullHistory` earns a
+ /// sticky negative-cache entry, given whatever metadata the symbol
+ /// carried before the fetch.
+ ///
+ /// Normally yes: every provider affirmatively disclaimed the
+ /// symbol, so stop asking. The exception is `.external`, where the
+ /// bars are managed outside zfin and no provider was ever going to
+ /// carry them. A negative entry there is not "stop asking a
+ /// provider that has nothing" - it is unrecoverable data loss.
+ /// `writeNegative` overwrites `candles_daily.srf` with a marker,
+ /// negative entries never expire, and `getCandles` short-circuits
+ /// on `isNegative` *before* it reaches `syncCandlesFromServer`, so
+ /// the marker makes the only surviving copy of the series
+ /// permanently unreachable. On a server, where the
+ /// externally-populated cache IS the master copy, nothing survives
+ /// at all.
+ ///
+ /// A `null` prior meta (a true cold start, both cache files absent)
+ /// still writes the entry: there is no label to consult and nothing
+ /// local to destroy. That leaves a real hole - a cold start while
+ /// `ZFIN_SERVER` is unreachable poisons an external symbol until
+ /// `--refresh-data=force` or `cache clear`. Closing it needs the
+ /// `isNegative` short-circuit moved after the server tier, which
+ /// would re-hit the network for every legitimately candle-less
+ /// symbol (crypto, delisted tickers) on every run. That trade
+ /// belongs with the deferred external-symbol routing work, not
+ /// here.
+ ///
+ /// Deliberately pure so it can be tested directly: reaching the
+ /// `true` branch through `getCandles` requires two live provider
+ /// 404s, so an inline conditional at the call site would be
+ /// permanently uncovered.
+ fn shouldNegativeCache(prior: ?cache.Store.CandleMeta) bool {
+ const meta = prior orelse return true;
+ return meta.provider != .external;
+ }
+
/// Fetch candles from providers with error classification.
///
/// Error handling:
@@ -1336,7 +1373,18 @@ pub const DataService = struct {
// because the negative marker replaces the candle file: a
// bad negative both suppresses retries until `--refresh`
// and throws away whatever history was cached.
- if (err == error.NotFound) s.writeNegative(symbol, .candles_daily);
+ //
+ // And even a unanimous NotFound is not a verdict when the
+ // series was never a provider's to serve; see
+ // `shouldNegativeCache`.
+ if (err == error.NotFound) {
+ const prior: ?cache.Store.CandleMeta = if (meta_result) |mr| mr.meta else null;
+ if (shouldNegativeCache(prior)) {
+ s.writeNegative(symbol, .candles_daily);
+ } else {
+ log.warn("{s}: every provider disclaims it, but its cache is externally managed (provider::external) - refusing the negative entry, which would make the series unreachable via ZFIN_SERVER", .{symbol});
+ }
+ }
if (err == DataError.AuthError) return DataError.AuthError;
return DataError.FetchFailed;
};
@@ -4327,6 +4375,107 @@ test "getCandles offline never escalates a stale adjustment basis" {
try std.testing.expect(!store.isNegative("SMPL", .candles_daily));
}
+// ── externally-managed candle series (provider::external) ────
+// The label is pure provenance and must stay behavior-free, with one
+// exception: it vetoes the negative-cache write, because for a series
+// no provider carries, that marker is unrecoverable rather than merely
+// sticky. These tests pin both halves - the label changes nothing about
+// how a cache is served, and it does change whether a unanimous 404
+// gets remembered.
+
+test "shouldNegativeCache refuses an external-provider symbol" {
+ // Looped over every variant so a fifth one has to come here and
+ // declare its intent rather than silently inheriting `true`.
+ for (std.enums.values(cache.Store.CandleProvider)) |provider| {
+ const meta = cache.Store.CandleMeta{
+ .last_close = 10.0,
+ .last_date = Date.fromYmd(2026, 8, 14),
+ .provider = provider,
+ };
+ const expected = provider != .external;
+ try std.testing.expectEqual(expected, DataService.shouldNegativeCache(meta));
+ }
+
+ // A true cold start has no label to consult and nothing local to
+ // destroy, so the entry is still written. This is the documented
+ // residual hole: an external symbol cold-started while ZFIN_SERVER
+ // is unreachable stays poisoned until `--refresh-data=force`.
+ try std.testing.expect(DataService.shouldNegativeCache(null));
+}
+
+test "getCandles serves an external-provider cache without touching the network" {
+ // `provider::external` must be inert on the serve path. In
+ // particular it must not fall into the `.twelvedata` carve-out,
+ // which treats a cache as unusable and forces a full re-fetch - for
+ // a symbol no provider carries, that re-fetch 404s and the
+ // cold-start path writes a negative marker over the only copy of
+ // the series. `panic_on_network_attempt` fires if any of that
+ // regresses.
+ const allocator = std.testing.allocator;
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
+ defer allocator.free(dir_path);
+
+ const config = Config{ .cache_dir = dir_path };
+ var svc = DataService.init(io, allocator, config);
+ defer svc.deinit();
+
+ var store = svc.store();
+ var bars = [_]Candle{
+ .{ .date = Date.fromYmd(2026, 8, 13), .open = 24, .high = 24, .low = 24, .close = 24, .adj_close = 24, .volume = 0 },
+ .{ .date = Date.fromYmd(2026, 8, 14), .open = 25, .high = 25, .low = 25, .close = 25, .adj_close = 25, .volume = 0 },
+ };
+ store.cacheCandles("XTRN", bars[0..], .{ .provider = .external }, 9_999_999_999);
+
+ svc.panic_on_network_attempt = true;
+ const result = try svc.getCandles("XTRN", .{});
+ defer result.deinit();
+
+ try std.testing.expectEqual(Source.cached, result.source);
+ try std.testing.expectEqual(@as(usize, 2), result.data.len);
+ try std.testing.expect(result.data[1].date.eql(Date.fromYmd(2026, 8, 14)));
+
+ // Label survived the read, and no marker was written over the file.
+ const after = (store.readCandleMeta("XTRN") orelse return error.NoCache).meta;
+ try std.testing.expectEqual(cache.Store.CandleProvider.external, after.provider);
+ try std.testing.expect(!store.isNegative("XTRN", .candles_daily));
+}
+
+test "getCandles offline serves an external-provider cache" {
+ // The skip_network path has its own `.twelvedata` check, distinct
+ // from the one on the online path. `.external` must miss that one
+ // too, otherwise offline mode reports an externally-managed holding
+ // as unavailable despite a perfectly good local series.
+ const allocator = std.testing.allocator;
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
+ defer allocator.free(dir_path);
+
+ const config = Config{ .cache_dir = dir_path };
+ var svc = DataService.init(io, allocator, config);
+ defer svc.deinit();
+
+ var store = svc.store();
+ var bars = [_]Candle{
+ .{ .date = Date.fromYmd(2026, 8, 14), .open = 25, .high = 25, .low = 25, .close = 25, .adj_close = 25, .volume = 0 },
+ };
+ // Expiry in the past: stale on purpose, since offline mode is meant
+ // to serve a stale externally-managed series rather than fail.
+ store.cacheCandles("XTRN", bars[0..], .{ .provider = .external }, 1);
+
+ svc.panic_on_network_attempt = true;
+ const result = try svc.getCandles("XTRN", .{ .skip_network = true });
+ defer result.deinit();
+
+ try std.testing.expectEqual(Source.cached, result.source);
+ try std.testing.expectEqual(@as(usize, 1), result.data.len);
+ try std.testing.expect(!store.isNegative("XTRN", .candles_daily));
+}
+
// ── Tiingo coverage bookkeeping ──────────────────────────────//
// Regression suite for the one-way drift to Yahoo. The old code
// routed on `CandleMeta.provider == .yahoo`, so any non-transient