diff --git a/src/cache/store.zig b/src/cache/store.zig index 8ca60fb..1b0814f 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -9,9 +9,6 @@ const Dividend = @import("../models/dividend.zig").Dividend; const DividendType = @import("../models/dividend.zig").DividendType; const Split = @import("../models/split.zig").Split; const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; -const EtfProfile = @import("../models/etf_profile.zig").EtfProfile; -const Holding = @import("../models/etf_profile.zig").Holding; -const SectorWeight = @import("../models/etf_profile.zig").SectorWeight; const Wikidata = @import("../providers/Wikidata.zig"); const Edgar = @import("../providers/Edgar.zig"); @@ -45,8 +42,6 @@ pub const Ttl = struct { pub const options: i64 = std.time.s_per_hour; /// Earnings refresh monthly, with smart refresh after announcements pub const earnings: i64 = 30 * s_per_day; - /// ETF profiles refresh monthly - pub const etf_profile: i64 = 30 * s_per_day; /// Per-symbol classification record (sector / industry / country / /// inception_date / CIK) sourced from Wikidata. The data changes @@ -171,7 +166,6 @@ pub const DataType = enum { splits, options, earnings, - etf_profile, meta, /// Per-symbol classification record sourced from Wikidata. /// Stored at `//classification.srf`. @@ -204,7 +198,6 @@ pub const DataType = enum { .splits => "splits.srf", .options => "options.srf", .earnings => "earnings.srf", - .etf_profile => "etf_profile.srf", .meta => "meta.srf", .classification => "classification.srf", .etf_metrics => "etf_metrics.srf", @@ -231,7 +224,7 @@ pub const DataType = enum { /// because the absolute spread on a 30d/90d base is /// already large in days. /// - /// - 0% on the rest. options/earnings/etf_profile either + /// - 0% on the rest. options/earnings either /// have natural cadence spread or are short-TTL enough /// that jitter would exceed meaningful drift. pub fn ttl(self: DataType) TtlSpec { @@ -240,7 +233,6 @@ pub const DataType = enum { .splits => .{ .seconds = Ttl.splits, .jitter_pct = 11 }, .options => .{ .seconds = Ttl.options }, .earnings => .{ .seconds = Ttl.earnings }, - .etf_profile => .{ .seconds = Ttl.etf_profile }, .classification => .{ .seconds = Ttl.classification, .jitter_pct = 8 }, .etf_metrics => .{ .seconds = Ttl.etf_metrics, .jitter_pct = 8 }, .entity_facts => .{ .seconds = Ttl.entity_facts, .jitter_pct = 8 }, @@ -292,7 +284,6 @@ pub const Store = struct { Split => .splits, EarningsEvent => .earnings, OptionsChain => .options, - EtfProfile => .etf_profile, Wikidata.ClassificationRecord => .classification, Edgar.EtfMetricRecord => .etf_metrics, Edgar.EntityFactRecord => .entity_facts, @@ -302,9 +293,10 @@ pub const Store = struct { }; } - /// The data payload for a given type: single struct for EtfProfile, slice for everything else. + /// The data payload for a given type. Every supported type is + /// cached as a slice of records. pub fn DataFor(comptime T: type) type { - return if (T == EtfProfile) EtfProfile else []T; + return []T; } pub fn CacheResult(comptime T: type) type { @@ -345,15 +337,12 @@ pub const Store = struct { return null; } - if (T == EtfProfile or T == OptionsChain) { + if (T == OptionsChain) { const is_negative = std.mem.eql(u8, data, negative_cache_content); if (is_negative) { if (freshness == .fresh_only) { // Negative entries are always fresh — return empty data - if (T == EtfProfile) - return .{ .data = EtfProfile{ .symbol = "" }, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() }; - if (T == OptionsChain) - return .{ .data = &.{}, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() }; + return .{ .data = &.{}, .timestamp = std.Io.Timestamp.now(self.io, .real).toSeconds() }; } return null; } @@ -368,21 +357,14 @@ pub const Store = struct { } const timestamp = it.created orelse std.Io.Timestamp.now(self.io, .real).toSeconds(); - if (T == EtfProfile) { - const profile = deserializeEtfProfile(allocator, &it) catch return null; - return .{ .data = profile, .timestamp = timestamp }; - } - if (T == OptionsChain) { - const items = deserializeOptions(allocator, &it) catch return null; - return .{ .data = items, .timestamp = timestamp }; - } + const items = deserializeOptions(allocator, &it) catch return null; + return .{ .data = items, .timestamp = timestamp }; } return readSlice(T, self.io, allocator, data, postProcess, freshness); } /// Serialize data and write to cache with the given TTL. - /// Accepts a slice for most types, or a single struct for EtfProfile. /// /// For `Dividend` and `Split`, this dispatches to `writeMerged`, /// which performs sorted-union-with-existing semantics rather than @@ -419,17 +401,6 @@ pub const Store = struct { } const expires = computeExpires(std.Io.Timestamp.now(self.io, .real).toSeconds(), ttl, symbol); const data_type = dataTypeFor(T); - if (T == EtfProfile) { - const srf_data = serializeEtfProfile(self.io, self.allocator, items, .{ .expires = expires }) catch |err| { - log.warn("{s}: failed to serialize ETF profile: {s}", .{ symbol, @errorName(err) }); - return; - }; - defer self.allocator.free(srf_data); - self.writeRaw(symbol, data_type, srf_data) catch |err| { - log.warn("{s}: failed to write ETF profile to cache: {s}", .{ symbol, @errorName(err) }); - }; - return; - } if (T == OptionsChain) { const srf_data = serializeOptions(self.io, self.allocator, items, .{ .expires = expires }) catch |err| { log.warn("{s}: failed to serialize options: {s}", .{ symbol, @errorName(err) }); @@ -1370,10 +1341,6 @@ pub const Store = struct { try std.testing.expect(!hasNoStringFields(EarningsEvent)); } - test "hasNoStringFields: EtfProfile has string fields -> false" { - try std.testing.expect(!hasNoStringFields(EtfProfile)); - } - test "hasNoStringFields: synthetic shapes" { // Pure ints/floats/bools/enums + Date — should pass. const Pure = struct { @@ -1759,88 +1726,6 @@ pub const Store = struct { return chains.toOwnedSlice(allocator); } - - // ── Private serialization: ETF profile (bespoke) ───────────── - - const EtfRecord = union(enum) { - pub const srf_tag_field = "type"; - meta: EtfProfile, - sector: SectorWeight, - holding: Holding, - }; - - fn serializeEtfProfile(io: std.Io, allocator: std.mem.Allocator, profile: EtfProfile, options: srf.FormatOptions) ![]const u8 { - var records: std.ArrayList(EtfRecord) = .empty; - defer records.deinit(allocator); - - try records.append(allocator, .{ .meta = profile }); - if (profile.sectors) |sectors| { - for (sectors) |s| try records.append(allocator, .{ .sector = s }); - } - if (profile.holdings) |holdings| { - for (holdings) |h| try records.append(allocator, .{ .holding = h }); - } - - var aw: std.Io.Writer.Allocating = .init(allocator); - errdefer aw.deinit(); - var opts = options; - opts.created = std.Io.Timestamp.now(io, .real).toSeconds(); - try aw.writer.print("{f}", .{srf.fmt(EtfRecord, records.items, opts)}); - return aw.toOwnedSlice(); - } - - fn deserializeEtfProfile(allocator: std.mem.Allocator, it: *srf.RecordIterator) !EtfProfile { - var profile = EtfProfile{ .symbol = "" }; - var sectors: std.ArrayList(SectorWeight) = .empty; - errdefer { - for (sectors.items) |s| allocator.free(s.name); - sectors.deinit(allocator); - } - var holdings: std.ArrayList(Holding) = .empty; - errdefer { - for (holdings.items) |h| { - if (h.symbol) |s| allocator.free(s); - if (h.cusip) |c| allocator.free(c); - allocator.free(h.name); - } - holdings.deinit(allocator); - } - - while (try it.next()) |fields| { - const etf_rec = fields.to(EtfRecord, .{}) catch continue; - switch (etf_rec) { - .meta => |m| { - profile = m; - }, - .sector => |s| { - const duped = try allocator.dupe(u8, s.name); - try sectors.append(allocator, .{ .name = duped, .weight = s.weight }); - }, - .holding => |h| { - const duped_sym = if (h.symbol) |s| try allocator.dupe(u8, s) else null; - errdefer if (duped_sym) |s| allocator.free(s); - const duped_cusip = if (h.cusip) |c| try allocator.dupe(u8, c) else null; - errdefer if (duped_cusip) |c| allocator.free(c); - const duped_name = try allocator.dupe(u8, h.name); - errdefer allocator.free(duped_name); - try holdings.append(allocator, .{ .symbol = duped_sym, .name = duped_name, .weight = h.weight, .cusip = duped_cusip }); - }, - } - } - - if (sectors.items.len > 0) { - profile.sectors = try sectors.toOwnedSlice(allocator); - } else { - sectors.deinit(allocator); - } - if (holdings.items.len > 0) { - profile.holdings = try holdings.toOwnedSlice(allocator); - } else { - holdings.deinit(allocator); - } - - return profile; - } }; /// Serialize a portfolio (list of lots) to SRF format. @@ -2741,9 +2626,8 @@ test "TTL constants are reasonable" { // Options refresh hourly try std.testing.expectEqual(@as(i64, std.time.s_per_hour), Ttl.options); - // Earnings and ETF profiles refresh monthly + // Earnings refresh monthly try std.testing.expectEqual(@as(i64, 30 * std.time.s_per_day), Ttl.earnings); - try std.testing.expectEqual(@as(i64, 30 * std.time.s_per_day), Ttl.etf_profile); // New types: classification (90d) and etf_metrics (90d) refresh // quarterly; entity_facts (30d) refreshes monthly. @@ -2786,13 +2670,11 @@ test "DataType.ttl returns correct seconds and jitter policy" { try std.testing.expectEqual(Ttl.tickers_companies, tc.seconds); try std.testing.expectEqual(@as(u8, 8), tc.jitter_pct); - // No jitter: short-TTL types and etf_profile. + // No jitter: short-TTL types. try std.testing.expectEqual(Ttl.options, DataType.options.ttl().seconds); try std.testing.expectEqual(@as(u8, 0), DataType.options.ttl().jitter_pct); try std.testing.expectEqual(Ttl.earnings, DataType.earnings.ttl().seconds); try std.testing.expectEqual(@as(u8, 0), DataType.earnings.ttl().jitter_pct); - try std.testing.expectEqual(Ttl.etf_profile, DataType.etf_profile.ttl().seconds); - try std.testing.expectEqual(@as(u8, 0), DataType.etf_profile.ttl().jitter_pct); // candles_daily, candles_meta, and meta have their own writers // (`cacheCandles`, `writeNegative`); calling .ttl() on them is @@ -2806,7 +2688,6 @@ test "DataType.fileName returns correct file names" { try std.testing.expectEqualStrings("splits.srf", DataType.splits.fileName()); try std.testing.expectEqualStrings("options.srf", DataType.options.fileName()); try std.testing.expectEqualStrings("earnings.srf", DataType.earnings.fileName()); - try std.testing.expectEqualStrings("etf_profile.srf", DataType.etf_profile.fileName()); try std.testing.expectEqualStrings("meta.srf", DataType.meta.fileName()); try std.testing.expectEqualStrings("classification.srf", DataType.classification.fileName()); try std.testing.expectEqualStrings("etf_metrics.srf", DataType.etf_metrics.fileName()); @@ -3100,13 +2981,10 @@ test "Store.dataTypeFor maps model types correctly" { try std.testing.expectEqual(DataType.splits, Store.dataTypeFor(Split)); try std.testing.expectEqual(DataType.earnings, Store.dataTypeFor(EarningsEvent)); try std.testing.expectEqual(DataType.options, Store.dataTypeFor(OptionsChain)); - try std.testing.expectEqual(DataType.etf_profile, Store.dataTypeFor(EtfProfile)); } test "Store.DataFor returns correct types" { - // EtfProfile returns single struct, others return slices - try std.testing.expect(@TypeOf(Store.DataFor(EtfProfile)) == type); - try std.testing.expect(Store.DataFor(EtfProfile) == EtfProfile); + // Every supported type is cached as a slice of records. try std.testing.expect(Store.DataFor(Candle) == []Candle); try std.testing.expect(Store.DataFor(Dividend) == []Dividend); try std.testing.expect(Store.DataFor(Split) == []Split); diff --git a/src/commands/cache.zig b/src/commands/cache.zig index 063f1ca..f1657cc 100644 --- a/src/commands/cache.zig +++ b/src/commands/cache.zig @@ -43,7 +43,6 @@ const display_types = [_]DataType{ .splits, .options, .earnings, - .etf_profile, }; const display_labels = [_][]const u8{ @@ -52,7 +51,6 @@ const display_labels = [_][]const u8{ "splits", "options", "earnings", - "etf_profile", }; pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { diff --git a/src/service.zig b/src/service.zig index 1a367fe..8c44f77 100644 --- a/src/service.zig +++ b/src/service.zig @@ -208,6 +208,19 @@ pub const Source = enum { fetched, }; +/// In-memory payload shape for a fetched type `T`. +/// +/// Almost everything is a slice of records (`[]Candle`, `[]Dividend`, +/// …) — the same shape the cache stores. `EtfProfile` is the lone +/// exception: `getEtfProfile` assembles a single struct from the +/// `etf_metrics` cache rather than returning a slice, so its payload +/// is the struct itself. The cache layer never stores `EtfProfile` +/// directly, which is why this single-struct knowledge lives here in +/// the fetch layer rather than in `Store.DataFor`. +fn PayloadFor(comptime T: type) type { + return if (T == EtfProfile) EtfProfile else []T; +} + /// Generic result type for all fetch operations: data payload + provenance metadata. /// /// `data` is owned by `allocator` — call `result.deinit()` to release @@ -217,7 +230,7 @@ pub const Source = enum { /// allocator (e.g. an arena) differed from the service's allocator. pub fn FetchResult(comptime T: type) type { return struct { - data: cache.Store.DataFor(T), + data: PayloadFor(T), source: Source, timestamp: i64, /// Allocator that owns `data`. Populated by the service on @@ -1874,10 +1887,10 @@ pub const DataService = struct { // No proactive token-bucket limiter for these. Tiingo // (candles) has a 1000/day quota enforced reactively // via 429-then-backoff in `getCandles`; Wikidata - // (classification) has no published quota; the legacy - // `etf_profile` and `meta` types aren't fetched. Nothing - // useful to wait for at the call site, so report 0. - .candles_daily, .candles_meta, .classification, .etf_profile, .meta => 0, + // (classification) has no published quota; the `meta` + // type isn't fetched. Nothing useful to wait for at the + // call site, so report 0. + .candles_daily, .candles_meta, .classification, .meta => 0, }; return if (ns == 0) 0 else @max(1, ns / std.time.ns_per_s); } @@ -2759,7 +2772,6 @@ pub const DataService = struct { .earnings => "/earnings", .options => "/options", .splits => "/splits", - .etf_profile => return false, // not served (replaced by etf_metrics) .meta => return false, .classification => "/classification", .etf_metrics => "/etf_metrics", @@ -3876,7 +3888,6 @@ test "estimateWaitSeconds returns 0 for types without rate limiters" { try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.candles_daily)); try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.candles_meta)); try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.classification)); - try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.etf_profile)); try std.testing.expectEqual(@as(?u64, 0), svc.estimateWaitSeconds(.meta)); }