store.zig refactored and reviewed
This commit is contained in:
parent
189d09720b
commit
e0129003e6
10 changed files with 600 additions and 681 deletions
|
|
@ -13,8 +13,8 @@
|
||||||
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
|
.hash = "z2d-0.10.0-j5P_Hu-6FgBsZNgwphIqh17jDnj8_yPtD8yzjO6PpHRQ",
|
||||||
},
|
},
|
||||||
.srf = .{
|
.srf = .{
|
||||||
.url = "git+https://git.lerch.org/lobo/srf.git#8e12b7396afc1bcbc4e2a3f19d8725a82b71b27e",
|
.url = "git+https://git.lerch.org/lobo/srf.git#353f8bca359d35872c1869dca906f34f9579d073",
|
||||||
.hash = "srf-0.0.0-qZj573V9AQBJTR8ehcnA6KW_wb6cdkJZtFZGq87b8dAJ",
|
.hash = "srf-0.0.0-qZj577GyAQBpIS3e1hiOb6Gi-4KUmFxaNsk3jzZMszoO",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
.paths = .{
|
.paths = .{
|
||||||
|
|
|
||||||
|
|
@ -75,11 +75,11 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
|
||||||
}
|
}
|
||||||
|
|
||||||
var reader = std.Io.Reader.fixed(data);
|
var reader = std.Io.Reader.fixed(data);
|
||||||
const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData;
|
var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData;
|
||||||
defer parsed.deinit();
|
defer it.deinit();
|
||||||
|
|
||||||
for (parsed.records) |record| {
|
while (try it.next()) |fields| {
|
||||||
const entry = record.to(AccountTaxEntry) catch continue;
|
const entry = fields.to(AccountTaxEntry) catch continue;
|
||||||
try entries.append(allocator, .{
|
try entries.append(allocator, .{
|
||||||
.account = try allocator.dupe(u8, entry.account),
|
.account = try allocator.dupe(u8, entry.account),
|
||||||
.tax_type = entry.tax_type,
|
.tax_type = entry.tax_type,
|
||||||
|
|
|
||||||
932
src/cache/store.zig
vendored
932
src/cache/store.zig
vendored
File diff suppressed because it is too large
Load diff
|
|
@ -57,11 +57,11 @@ pub fn parseClassificationFile(allocator: std.mem.Allocator, data: []const u8) !
|
||||||
}
|
}
|
||||||
|
|
||||||
var reader = std.Io.Reader.fixed(data);
|
var reader = std.Io.Reader.fixed(data);
|
||||||
const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData;
|
var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData;
|
||||||
defer parsed.deinit();
|
defer it.deinit();
|
||||||
|
|
||||||
for (parsed.records) |record| {
|
while (try it.next()) |fields| {
|
||||||
const entry = record.to(ClassificationEntry) catch continue;
|
const entry = fields.to(ClassificationEntry) catch continue;
|
||||||
try entries.append(allocator, .{
|
try entries.append(allocator, .{
|
||||||
.symbol = try allocator.dupe(u8, entry.symbol),
|
.symbol = try allocator.dupe(u8, entry.symbol),
|
||||||
.sector = if (entry.sector) |s| try allocator.dupe(u8, s) else null,
|
.sector = if (entry.sector) |s| try allocator.dupe(u8, s) else null,
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ pub const LotType = enum {
|
||||||
/// Open lots have no close_date/close_price.
|
/// Open lots have no close_date/close_price.
|
||||||
/// Closed lots have both.
|
/// Closed lots have both.
|
||||||
pub const Lot = struct {
|
pub const Lot = struct {
|
||||||
symbol: []const u8,
|
symbol: []const u8 = "",
|
||||||
shares: f64,
|
shares: f64,
|
||||||
open_date: Date,
|
open_date: Date,
|
||||||
open_price: f64,
|
open_price: f64,
|
||||||
|
|
|
||||||
|
|
@ -174,26 +174,26 @@ pub fn buildUrl(
|
||||||
base: []const u8,
|
base: []const u8,
|
||||||
params: []const [2][]const u8,
|
params: []const [2][]const u8,
|
||||||
) ![]const u8 {
|
) ![]const u8 {
|
||||||
var buf: std.ArrayList(u8) = .empty;
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||||
errdefer buf.deinit(allocator);
|
errdefer aw.deinit();
|
||||||
|
|
||||||
try buf.appendSlice(allocator, base);
|
try aw.writer.writeAll(base);
|
||||||
for (params, 0..) |param, i| {
|
for (params, 0..) |param, i| {
|
||||||
try buf.append(allocator, if (i == 0) '?' else '&');
|
try aw.writer.writeByte(if (i == 0) '?' else '&');
|
||||||
try buf.appendSlice(allocator, param[0]);
|
try aw.writer.writeAll(param[0]);
|
||||||
try buf.append(allocator, '=');
|
try aw.writer.writeByte('=');
|
||||||
for (param[1]) |c| {
|
for (param[1]) |c| {
|
||||||
switch (c) {
|
switch (c) {
|
||||||
' ' => try buf.appendSlice(allocator, "%20"),
|
' ' => try aw.writer.writeAll("%20"),
|
||||||
'&' => try buf.appendSlice(allocator, "%26"),
|
'&' => try aw.writer.writeAll("%26"),
|
||||||
'=' => try buf.appendSlice(allocator, "%3D"),
|
'=' => try aw.writer.writeAll("%3D"),
|
||||||
'+' => try buf.appendSlice(allocator, "%2B"),
|
'+' => try aw.writer.writeAll("%2B"),
|
||||||
else => try buf.append(allocator, c),
|
else => try aw.writer.writeByte(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return buf.toOwnedSlice(allocator);
|
return aw.toOwnedSlice();
|
||||||
}
|
}
|
||||||
|
|
||||||
test "buildUrl" {
|
test "buildUrl" {
|
||||||
|
|
|
||||||
|
|
@ -55,15 +55,15 @@ pub const Cboe = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
fn buildCboeUrl(allocator: std.mem.Allocator, symbol: []const u8) ![]const u8 {
|
fn buildCboeUrl(allocator: std.mem.Allocator, symbol: []const u8) ![]const u8 {
|
||||||
var buf: std.ArrayList(u8) = .empty;
|
var aw: std.Io.Writer.Allocating = .init(allocator);
|
||||||
errdefer buf.deinit(allocator);
|
errdefer aw.deinit();
|
||||||
|
|
||||||
try buf.appendSlice(allocator, base_url);
|
try aw.writer.writeAll(base_url);
|
||||||
try buf.append(allocator, '/');
|
try aw.writer.writeByte('/');
|
||||||
try buf.appendSlice(allocator, symbol);
|
try aw.writer.writeAll(symbol);
|
||||||
try buf.appendSlice(allocator, ".json");
|
try aw.writer.writeAll(".json");
|
||||||
|
|
||||||
return buf.toOwnedSlice(allocator);
|
return aw.toOwnedSlice();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a CBOE options response into grouped OptionsChain slices.
|
/// Parse a CBOE options response into grouped OptionsChain slices.
|
||||||
|
|
|
||||||
253
src/service.zig
253
src/service.zig
|
|
@ -47,6 +47,28 @@ pub const Source = enum {
|
||||||
fetched,
|
fetched,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── PostProcess callbacks ────────────────────────────────────
|
||||||
|
// These are passed to Store.read to handle type-specific
|
||||||
|
// concerns: string duping (serialization plumbing) and domain transforms.
|
||||||
|
|
||||||
|
/// Dupe the currency string so it outlives the SRF iterator's backing buffer.
|
||||||
|
fn dividendPostProcess(div: *Dividend, allocator: std.mem.Allocator) anyerror!void {
|
||||||
|
if (div.currency) |c| {
|
||||||
|
div.currency = try allocator.dupe(u8, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute surprise/surprise_percent from actual and estimate fields.
|
||||||
|
/// SRF only stores actual and estimate; surprise is derived.
|
||||||
|
fn earningsPostProcess(ev: *EarningsEvent, _: std.mem.Allocator) anyerror!void {
|
||||||
|
if (ev.actual != null and ev.estimate != null) {
|
||||||
|
ev.surprise = ev.actual.? - ev.estimate.?;
|
||||||
|
if (ev.estimate.? != 0) {
|
||||||
|
ev.surprise_percent = (ev.surprise.? / @abs(ev.estimate.?)) * 100.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const DataService = struct {
|
pub const DataService = struct {
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
|
@ -137,12 +159,13 @@ pub const DataService = struct {
|
||||||
const today = todayDate();
|
const today = todayDate();
|
||||||
|
|
||||||
// Check candle metadata for freshness (tiny file, no candle deserialization)
|
// Check candle metadata for freshness (tiny file, no candle deserialization)
|
||||||
const meta = s.readCandleMeta(symbol);
|
const meta_result = s.readCandleMeta(symbol);
|
||||||
if (meta) |m| {
|
if (meta_result) |mr| {
|
||||||
|
const m = mr.meta;
|
||||||
if (s.isCandleMetaFresh(symbol)) {
|
if (s.isCandleMetaFresh(symbol)) {
|
||||||
// Fresh — deserialize candles and return
|
// Fresh — deserialize candles and return
|
||||||
const candles = self.loadCandleFile(&s, symbol);
|
if (s.read(Candle, symbol, null, .any)) |r|
|
||||||
if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = m.fetched_at };
|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stale — try incremental update using last_date from meta
|
// Stale — try incremental update using last_date from meta
|
||||||
|
|
@ -150,38 +173,37 @@ pub const DataService = struct {
|
||||||
|
|
||||||
// If last cached date is today or later, just refresh the TTL (meta only)
|
// If last cached date is today or later, just refresh the TTL (meta only)
|
||||||
if (!fetch_from.lessThan(today)) {
|
if (!fetch_from.lessThan(today)) {
|
||||||
self.updateCandleMeta(&s, symbol, m.last_close, m.last_date);
|
s.updateCandleMeta(symbol, m.last_close, m.last_date);
|
||||||
const candles = self.loadCandleFile(&s, symbol);
|
if (s.read(Candle, symbol, null, .any)) |r|
|
||||||
if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = std.time.timestamp() };
|
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
|
||||||
} else {
|
} else {
|
||||||
// Incremental fetch from day after last cached candle
|
// Incremental fetch from day after last cached candle
|
||||||
var td = self.getTwelveData() catch {
|
var td = self.getTwelveData() catch {
|
||||||
// No API key — return stale data
|
// No API key — return stale data
|
||||||
const candles = self.loadCandleFile(&s, symbol);
|
if (s.read(Candle, symbol, null, .any)) |r|
|
||||||
if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = m.fetched_at };
|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
|
||||||
return DataError.NoApiKey;
|
return DataError.NoApiKey;
|
||||||
};
|
};
|
||||||
const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch {
|
const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch {
|
||||||
// Fetch failed — return stale data rather than erroring
|
// Fetch failed — return stale data rather than erroring
|
||||||
const candles = self.loadCandleFile(&s, symbol);
|
if (s.read(Candle, symbol, null, .any)) |r|
|
||||||
if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = m.fetched_at };
|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
|
||||||
return DataError.FetchFailed;
|
return DataError.FetchFailed;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (new_candles.len == 0) {
|
if (new_candles.len == 0) {
|
||||||
// No new candles (weekend/holiday) — refresh TTL only (meta rewrite)
|
// No new candles (weekend/holiday) — refresh TTL only (meta rewrite)
|
||||||
self.allocator.free(new_candles);
|
self.allocator.free(new_candles);
|
||||||
self.updateCandleMeta(&s, symbol, m.last_close, m.last_date);
|
s.updateCandleMeta(symbol, m.last_close, m.last_date);
|
||||||
const candles = self.loadCandleFile(&s, symbol);
|
if (s.read(Candle, symbol, null, .any)) |r|
|
||||||
if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = std.time.timestamp() };
|
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
|
||||||
} else {
|
} else {
|
||||||
// Append new candles to existing file + update meta
|
// Append new candles to existing file + update meta
|
||||||
self.appendCandles(&s, symbol, new_candles);
|
s.appendCandles(symbol, new_candles);
|
||||||
// Load the full (now-updated) file for the caller
|
// Load the full (now-updated) file for the caller
|
||||||
const candles = self.loadCandleFile(&s, symbol);
|
if (s.read(Candle, symbol, null, .any)) |r| {
|
||||||
if (candles) |c| {
|
|
||||||
self.allocator.free(new_candles);
|
self.allocator.free(new_candles);
|
||||||
return .{ .data = c, .source = .fetched, .timestamp = std.time.timestamp() };
|
return .{ .data = r.data, .source = .fetched, .timestamp = std.time.timestamp() };
|
||||||
}
|
}
|
||||||
// Append failed or file unreadable — just return new candles
|
// Append failed or file unreadable — just return new candles
|
||||||
return .{ .data = new_candles, .source = .fetched, .timestamp = std.time.timestamp() };
|
return .{ .data = new_candles, .source = .fetched, .timestamp = std.time.timestamp() };
|
||||||
|
|
@ -198,91 +220,19 @@ pub const DataService = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fetched.len > 0) {
|
if (fetched.len > 0) {
|
||||||
self.cacheCandles(&s, symbol, fetched);
|
s.cacheCandles(symbol, fetched);
|
||||||
}
|
}
|
||||||
|
|
||||||
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load candle data from the cache file. Returns null if unavailable.
|
|
||||||
fn loadCandleFile(self: *DataService, s: *cache.Store, symbol: []const u8) ?[]Candle {
|
|
||||||
const raw = s.readRaw(symbol, .candles_daily) catch return null;
|
|
||||||
const data = raw orelse return null;
|
|
||||||
defer self.allocator.free(data);
|
|
||||||
return cache.Store.deserializeCandles(self.allocator, data) catch null;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cacheCandles(self: *DataService, s: *cache.Store, symbol: []const u8, candles: []const Candle) void {
|
|
||||||
// Write candle data (no expiry -- historical facts don't expire)
|
|
||||||
if (cache.Store.serializeCandles(self.allocator, candles, .{})) |srf_data| {
|
|
||||||
defer self.allocator.free(srf_data);
|
|
||||||
s.writeRaw(symbol, .candles_daily, srf_data) catch {};
|
|
||||||
} else |_| {}
|
|
||||||
|
|
||||||
// Write candle metadata (with expiry for freshness checks)
|
|
||||||
if (candles.len > 0) {
|
|
||||||
const last = candles[candles.len - 1];
|
|
||||||
self.updateCandleMeta(s, symbol, last.close, last.date);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append new candle records to the existing candle file and update metadata.
|
|
||||||
/// Falls back to a full rewrite if append fails (e.g. file doesn't exist).
|
|
||||||
fn appendCandles(self: *DataService, s: *cache.Store, symbol: []const u8, new_candles: []const Candle) void {
|
|
||||||
if (new_candles.len == 0) return;
|
|
||||||
|
|
||||||
// Serialize just the new records with no SRF header
|
|
||||||
if (cache.Store.serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| {
|
|
||||||
defer self.allocator.free(srf_data);
|
|
||||||
s.appendRaw(symbol, .candles_daily, srf_data) catch {
|
|
||||||
// Append failed (file missing?) — fall back to full load + rewrite
|
|
||||||
if (self.loadCandleFile(s, symbol)) |existing| {
|
|
||||||
defer self.allocator.free(existing);
|
|
||||||
// Merge and do full write
|
|
||||||
const merged = self.allocator.alloc(Candle, existing.len + new_candles.len) catch return;
|
|
||||||
defer self.allocator.free(merged);
|
|
||||||
@memcpy(merged[0..existing.len], existing);
|
|
||||||
@memcpy(merged[existing.len..], new_candles);
|
|
||||||
if (cache.Store.serializeCandles(self.allocator, merged, .{})) |full_data| {
|
|
||||||
defer self.allocator.free(full_data);
|
|
||||||
s.writeRaw(symbol, .candles_daily, full_data) catch {};
|
|
||||||
} else |_| {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else |_| {}
|
|
||||||
|
|
||||||
// Update metadata to reflect the new last candle
|
|
||||||
const last = new_candles[new_candles.len - 1];
|
|
||||||
self.updateCandleMeta(s, symbol, last.close, last.date);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write (or refresh) candle metadata without touching the candle data file.
|
|
||||||
fn updateCandleMeta(self: *DataService, s: *cache.Store, symbol: []const u8, last_close: f64, last_date: Date) void {
|
|
||||||
const expires = std.time.timestamp() + cache.Ttl.candles_latest;
|
|
||||||
const meta = cache.Store.CandleMeta{
|
|
||||||
.last_close = last_close,
|
|
||||||
.last_date = last_date,
|
|
||||||
.fetched_at = std.time.timestamp(),
|
|
||||||
};
|
|
||||||
if (cache.Store.serializeCandleMeta(self.allocator, meta, .{ .expires = expires })) |meta_data| {
|
|
||||||
defer self.allocator.free(meta_data);
|
|
||||||
s.writeRaw(symbol, .candles_meta, meta_data) catch {};
|
|
||||||
} else |_| {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch dividend history for a symbol.
|
/// Fetch dividend history for a symbol.
|
||||||
/// Checks cache first; fetches from Polygon if stale/missing.
|
/// Checks cache first; fetches from Polygon if stale/missing.
|
||||||
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!struct { data: []Dividend, source: Source, timestamp: i64 } {
|
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!struct { data: []Dividend, source: Source, timestamp: i64 } {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
|
|
||||||
const cached_raw = s.readRaw(symbol, .dividends) catch return DataError.CacheError;
|
if (s.read(Dividend, symbol, dividendPostProcess, .fresh_only)) |cached|
|
||||||
if (cached_raw) |data| {
|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
|
||||||
defer self.allocator.free(data);
|
|
||||||
if (cache.Store.isFreshData(data, self.allocator)) {
|
|
||||||
const divs = cache.Store.deserializeDividends(self.allocator, data) catch null;
|
|
||||||
if (divs) |d| return .{ .data = d, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var pg = try self.getPolygon();
|
var pg = try self.getPolygon();
|
||||||
const fetched = pg.fetchDividends(self.allocator, symbol, null, null) catch {
|
const fetched = pg.fetchDividends(self.allocator, symbol, null, null) catch {
|
||||||
|
|
@ -290,11 +240,7 @@ pub const DataService = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fetched.len > 0) {
|
if (fetched.len > 0) {
|
||||||
const expires = std.time.timestamp() + cache.Ttl.dividends;
|
s.write(Dividend, symbol, fetched, cache.Ttl.dividends);
|
||||||
if (cache.Store.serializeDividends(self.allocator, fetched, .{ .expires = expires })) |srf_data| {
|
|
||||||
defer self.allocator.free(srf_data);
|
|
||||||
s.writeRaw(symbol, .dividends, srf_data) catch {};
|
|
||||||
} else |_| {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
||||||
|
|
@ -305,24 +251,15 @@ pub const DataService = struct {
|
||||||
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!struct { data: []Split, source: Source, timestamp: i64 } {
|
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!struct { data: []Split, source: Source, timestamp: i64 } {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
|
|
||||||
const cached_raw = s.readRaw(symbol, .splits) catch return DataError.CacheError;
|
if (s.read(Split, symbol, null, .fresh_only)) |cached|
|
||||||
if (cached_raw) |data| {
|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
|
||||||
defer self.allocator.free(data);
|
|
||||||
if (cache.Store.isFreshData(data, self.allocator)) {
|
|
||||||
const splits = cache.Store.deserializeSplits(self.allocator, data) catch null;
|
|
||||||
if (splits) |sp| return .{ .data = sp, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var pg = try self.getPolygon();
|
var pg = try self.getPolygon();
|
||||||
const fetched = pg.fetchSplits(self.allocator, symbol) catch {
|
const fetched = pg.fetchSplits(self.allocator, symbol) catch {
|
||||||
return DataError.FetchFailed;
|
return DataError.FetchFailed;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cache.Store.serializeSplits(self.allocator, fetched, .{ .expires = std.time.timestamp() + cache.Ttl.splits })) |srf_data| {
|
s.write(Split, symbol, fetched, cache.Ttl.splits);
|
||||||
defer self.allocator.free(srf_data);
|
|
||||||
s.writeRaw(symbol, .splits, srf_data) catch {};
|
|
||||||
} else |_| {}
|
|
||||||
|
|
||||||
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
||||||
}
|
}
|
||||||
|
|
@ -332,14 +269,8 @@ pub const DataService = struct {
|
||||||
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!struct { data: []OptionsChain, source: Source, timestamp: i64 } {
|
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!struct { data: []OptionsChain, source: Source, timestamp: i64 } {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
|
|
||||||
const cached_raw = s.readRaw(symbol, .options) catch return DataError.CacheError;
|
if (s.read(OptionsChain, symbol, null, .fresh_only)) |cached|
|
||||||
if (cached_raw) |data| {
|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
|
||||||
defer self.allocator.free(data);
|
|
||||||
if (cache.Store.isFreshData(data, self.allocator)) {
|
|
||||||
const chains = cache.Store.deserializeOptions(self.allocator, data) catch null;
|
|
||||||
if (chains) |c| return .{ .data = c, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var cboe = self.getCboe();
|
var cboe = self.getCboe();
|
||||||
const fetched = cboe.fetchOptionsChain(self.allocator, symbol) catch {
|
const fetched = cboe.fetchOptionsChain(self.allocator, symbol) catch {
|
||||||
|
|
@ -347,11 +278,7 @@ pub const DataService = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fetched.len > 0) {
|
if (fetched.len > 0) {
|
||||||
const expires = std.time.timestamp() + cache.Ttl.options;
|
s.write(OptionsChain, symbol, fetched, cache.Ttl.options);
|
||||||
if (cache.Store.serializeOptions(self.allocator, fetched, .{ .expires = expires })) |srf_data| {
|
|
||||||
defer self.allocator.free(srf_data);
|
|
||||||
s.writeRaw(symbol, .options, srf_data) catch {};
|
|
||||||
} else |_| {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
||||||
|
|
@ -365,25 +292,18 @@ pub const DataService = struct {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
const today = todayDate();
|
const today = todayDate();
|
||||||
|
|
||||||
const cached_raw = s.readRaw(symbol, .earnings) catch return DataError.CacheError;
|
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
|
||||||
if (cached_raw) |data| {
|
// Check if any past/today earnings event is still missing actual results.
|
||||||
defer self.allocator.free(data);
|
// If so, the announcement likely just happened — force a refresh.
|
||||||
if (cache.Store.isFreshData(data, self.allocator)) {
|
const needs_refresh = for (cached.data) |ev| {
|
||||||
const events = cache.Store.deserializeEarnings(self.allocator, data) catch null;
|
if (ev.actual == null and !today.lessThan(ev.date)) break true;
|
||||||
if (events) |e| {
|
} else false;
|
||||||
// Check if any past/today earnings event is still missing actual results.
|
|
||||||
// If so, the announcement likely just happened — force a refresh.
|
|
||||||
const needs_refresh = for (e) |ev| {
|
|
||||||
if (ev.actual == null and !today.lessThan(ev.date)) break true;
|
|
||||||
} else false;
|
|
||||||
|
|
||||||
if (!needs_refresh) {
|
if (!needs_refresh) {
|
||||||
return .{ .data = e, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() };
|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
|
||||||
}
|
|
||||||
// Stale: free cached events and re-fetch below
|
|
||||||
self.allocator.free(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Stale: free cached events and re-fetch below
|
||||||
|
self.allocator.free(cached.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fh = try self.getFinnhub();
|
var fh = try self.getFinnhub();
|
||||||
|
|
@ -395,11 +315,7 @@ pub const DataService = struct {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fetched.len > 0) {
|
if (fetched.len > 0) {
|
||||||
const expires = std.time.timestamp() + cache.Ttl.earnings;
|
s.write(EarningsEvent, symbol, fetched, cache.Ttl.earnings);
|
||||||
if (cache.Store.serializeEarnings(self.allocator, fetched, .{ .expires = expires })) |srf_data| {
|
|
||||||
defer self.allocator.free(srf_data);
|
|
||||||
s.writeRaw(symbol, .earnings, srf_data) catch {};
|
|
||||||
} else |_| {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
||||||
|
|
@ -410,24 +326,15 @@ pub const DataService = struct {
|
||||||
pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!struct { data: EtfProfile, source: Source, timestamp: i64 } {
|
pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!struct { data: EtfProfile, source: Source, timestamp: i64 } {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
|
|
||||||
const cached_raw = s.readRaw(symbol, .etf_profile) catch return DataError.CacheError;
|
if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached|
|
||||||
if (cached_raw) |data| {
|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
|
||||||
defer self.allocator.free(data);
|
|
||||||
if (cache.Store.isFreshData(data, self.allocator)) {
|
|
||||||
const profile = cache.Store.deserializeEtfProfile(self.allocator, data) catch null;
|
|
||||||
if (profile) |p| return .{ .data = p, .source = .cached, .timestamp = cache.Store.readFetchedAt(self.allocator, data) orelse std.time.timestamp() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var av = try self.getAlphaVantage();
|
var av = try self.getAlphaVantage();
|
||||||
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch {
|
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch {
|
||||||
return DataError.FetchFailed;
|
return DataError.FetchFailed;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (cache.Store.serializeEtfProfile(self.allocator, fetched, .{ .expires = std.time.timestamp() + cache.Ttl.etf_profile })) |srf_data| {
|
s.write(EtfProfile, symbol, fetched, cache.Ttl.etf_profile);
|
||||||
defer self.allocator.free(srf_data);
|
|
||||||
s.writeRaw(symbol, .etf_profile, srf_data) catch {};
|
|
||||||
} else |_| {}
|
|
||||||
|
|
||||||
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
|
||||||
}
|
}
|
||||||
|
|
@ -524,45 +431,29 @@ pub const DataService = struct {
|
||||||
pub fn getCachedCandles(self: *DataService, symbol: []const u8) ?[]Candle {
|
pub fn getCachedCandles(self: *DataService, symbol: []const u8) ?[]Candle {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
if (s.isNegative(symbol, .candles_daily)) return null;
|
if (s.isNegative(symbol, .candles_daily)) return null;
|
||||||
const data = s.readRaw(symbol, .candles_daily) catch return null;
|
const result = s.read(Candle, symbol, null, .any) orelse return null;
|
||||||
if (data) |d| {
|
return result.data;
|
||||||
defer self.allocator.free(d);
|
|
||||||
return cache.Store.deserializeCandles(self.allocator, d) catch null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read dividends from cache only (no network fetch).
|
/// Read dividends from cache only (no network fetch).
|
||||||
pub fn getCachedDividends(self: *DataService, symbol: []const u8) ?[]Dividend {
|
pub fn getCachedDividends(self: *DataService, symbol: []const u8) ?[]Dividend {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
const data = s.readRaw(symbol, .dividends) catch return null;
|
const result = s.read(Dividend, symbol, dividendPostProcess, .any) orelse return null;
|
||||||
if (data) |d| {
|
return result.data;
|
||||||
defer self.allocator.free(d);
|
|
||||||
return cache.Store.deserializeDividends(self.allocator, d) catch null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read earnings from cache only (no network fetch).
|
/// Read earnings from cache only (no network fetch).
|
||||||
pub fn getCachedEarnings(self: *DataService, symbol: []const u8) ?[]EarningsEvent {
|
pub fn getCachedEarnings(self: *DataService, symbol: []const u8) ?[]EarningsEvent {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
const data = s.readRaw(symbol, .earnings) catch return null;
|
const result = s.read(EarningsEvent, symbol, earningsPostProcess, .any) orelse return null;
|
||||||
if (data) |d| {
|
return result.data;
|
||||||
defer self.allocator.free(d);
|
|
||||||
return cache.Store.deserializeEarnings(self.allocator, d) catch null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read options from cache only (no network fetch).
|
/// Read options from cache only (no network fetch).
|
||||||
pub fn getCachedOptions(self: *DataService, symbol: []const u8) ?[]OptionsChain {
|
pub fn getCachedOptions(self: *DataService, symbol: []const u8) ?[]OptionsChain {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
const data = s.readRaw(symbol, .options) catch return null;
|
const result = s.read(OptionsChain, symbol, null, .any) orelse return null;
|
||||||
if (data) |d| {
|
return result.data;
|
||||||
defer self.allocator.free(d);
|
|
||||||
return cache.Store.deserializeOptions(self.allocator, d) catch null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Portfolio price loading ──────────────────────────────────
|
// ── Portfolio price loading ──────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -316,30 +316,26 @@ pub fn loadFromData(allocator: std.mem.Allocator, data: []const u8) ?KeyMap {
|
||||||
const aa = arena.allocator();
|
const aa = arena.allocator();
|
||||||
|
|
||||||
var reader = std.Io.Reader.fixed(data);
|
var reader = std.Io.Reader.fixed(data);
|
||||||
const parsed = srf.parse(&reader, aa, .{}) catch return null;
|
var it = srf.iterator(&reader, aa, .{}) catch return null;
|
||||||
// Don't defer parsed.deinit() -- arena owns everything
|
// Don't defer it.deinit() -- arena owns everything
|
||||||
|
|
||||||
var bindings = std.ArrayList(Binding).empty;
|
var bindings = std.ArrayList(Binding).empty;
|
||||||
|
|
||||||
for (parsed.records) |record| {
|
while (it.next() catch return null) |fields| {
|
||||||
var action: ?Action = null;
|
var action: ?Action = null;
|
||||||
var key: ?KeyCombo = null;
|
var key: ?KeyCombo = null;
|
||||||
|
|
||||||
for (record.fields) |field| {
|
while (fields.next() catch return null) |field| {
|
||||||
if (std.mem.eql(u8, field.key, "action")) {
|
if (std.mem.eql(u8, field.key, "action")) {
|
||||||
if (field.value) |v| {
|
if (field.value) |v| switch (v) {
|
||||||
switch (v) {
|
.string => |s| action = parseAction(s),
|
||||||
.string => |s| action = parseAction(s),
|
else => {},
|
||||||
else => {},
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (std.mem.eql(u8, field.key, "key")) {
|
} else if (std.mem.eql(u8, field.key, "key")) {
|
||||||
if (field.value) |v| {
|
if (field.value) |v| switch (v) {
|
||||||
switch (v) {
|
.string => |s| key = parseKeyCombo(s),
|
||||||
.string => |s| key = parseKeyCombo(s),
|
else => {},
|
||||||
else => {},
|
};
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -251,13 +251,13 @@ pub fn loadFromData(data: []const u8) ?Theme {
|
||||||
const alloc = fba.allocator();
|
const alloc = fba.allocator();
|
||||||
|
|
||||||
var reader = std.Io.Reader.fixed(data);
|
var reader = std.Io.Reader.fixed(data);
|
||||||
const parsed = srf.parse(&reader, alloc, .{ .alloc_strings = false }) catch return null;
|
var it = srf.iterator(&reader, alloc, .{ .alloc_strings = false }) catch return null;
|
||||||
_ = &parsed; // don't deinit, fba owns everything
|
// Don't deinit -- fba owns everything
|
||||||
|
|
||||||
var theme = default_theme;
|
var theme = default_theme;
|
||||||
|
|
||||||
for (parsed.records) |record| {
|
while (it.next() catch return null) |fields| {
|
||||||
for (record.fields) |field| {
|
while (fields.next() catch return null) |field| {
|
||||||
if (field.value) |v| {
|
if (field.value) |v| {
|
||||||
const str = switch (v) {
|
const str = switch (v) {
|
||||||
.string => |s| s,
|
.string => |s| s,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue