Compare commits
No commits in common. "057bca14a13039071a39cf48748094538644c8e6" and "7144f60d10842d75bd877a6edd070d6346fff733" have entirely different histories.
057bca14a1
...
7144f60d10
6 changed files with 39 additions and 373 deletions
161
src/cache/store.zig
vendored
161
src/cache/store.zig
vendored
|
|
@ -1,5 +1,4 @@
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
const log = std.log.scoped(.cache);
|
|
||||||
const srf = @import("srf");
|
const srf = @import("srf");
|
||||||
const Date = @import("../models/date.zig").Date;
|
const Date = @import("../models/date.zig").Date;
|
||||||
const Candle = @import("../models/candle.zig").Candle;
|
const Candle = @import("../models/candle.zig").Candle;
|
||||||
|
|
@ -180,35 +179,20 @@ pub const Store = struct {
|
||||||
const expires = std.time.timestamp() + ttl;
|
const expires = std.time.timestamp() + ttl;
|
||||||
const data_type = dataTypeFor(T);
|
const data_type = dataTypeFor(T);
|
||||||
if (T == EtfProfile) {
|
if (T == EtfProfile) {
|
||||||
const srf_data = serializeEtfProfile(self.allocator, items, .{ .expires = expires }) catch |err| {
|
const srf_data = serializeEtfProfile(self.allocator, items, .{ .expires = expires }) catch return;
|
||||||
log.warn("{s}: failed to serialize ETF profile: {s}", .{ symbol, @errorName(err) });
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
defer self.allocator.free(srf_data);
|
defer self.allocator.free(srf_data);
|
||||||
self.writeRaw(symbol, data_type, srf_data) catch |err| {
|
self.writeRaw(symbol, data_type, srf_data) catch {};
|
||||||
log.warn("{s}: failed to write ETF profile to cache: {s}", .{ symbol, @errorName(err) });
|
|
||||||
};
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (T == OptionsChain) {
|
if (T == OptionsChain) {
|
||||||
const srf_data = serializeOptions(self.allocator, items, .{ .expires = expires }) catch |err| {
|
const srf_data = serializeOptions(self.allocator, items, .{ .expires = expires }) catch return;
|
||||||
log.warn("{s}: failed to serialize options: {s}", .{ symbol, @errorName(err) });
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
defer self.allocator.free(srf_data);
|
defer self.allocator.free(srf_data);
|
||||||
self.writeRaw(symbol, data_type, srf_data) catch |err| {
|
self.writeRaw(symbol, data_type, srf_data) catch {};
|
||||||
log.warn("{s}: failed to write options to cache: {s}", .{ symbol, @errorName(err) });
|
|
||||||
};
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const srf_data = serializeWithMeta(T, self.allocator, items, .{ .expires = expires }) catch |err| {
|
const srf_data = serializeWithMeta(T, self.allocator, items, .{ .expires = expires }) catch return;
|
||||||
log.warn("{s}: failed to serialize {s}: {s}", .{ symbol, @tagName(data_type), @errorName(err) });
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
defer self.allocator.free(srf_data);
|
defer self.allocator.free(srf_data);
|
||||||
self.writeRaw(symbol, data_type, srf_data) catch |err| {
|
self.writeRaw(symbol, data_type, srf_data) catch {};
|
||||||
log.warn("{s}: failed to write {s} to cache: {s}", .{ symbol, @tagName(data_type), @errorName(err) });
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Candle-specific API ──────────────────────────────────────
|
// ── Candle-specific API ──────────────────────────────────────
|
||||||
|
|
@ -218,12 +202,8 @@ pub const Store = struct {
|
||||||
pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle) void {
|
pub fn cacheCandles(self: *Store, symbol: []const u8, candles: []const Candle) void {
|
||||||
if (serializeCandles(self.allocator, candles, .{})) |srf_data| {
|
if (serializeCandles(self.allocator, candles, .{})) |srf_data| {
|
||||||
defer self.allocator.free(srf_data);
|
defer self.allocator.free(srf_data);
|
||||||
self.writeRaw(symbol, .candles_daily, srf_data) catch |err| {
|
self.writeRaw(symbol, .candles_daily, srf_data) catch {};
|
||||||
log.warn("{s}: failed to write candles to cache: {s}", .{ symbol, @errorName(err) });
|
} else |_| {}
|
||||||
};
|
|
||||||
} else |err| {
|
|
||||||
log.warn("{s}: failed to serialize candles: {s}", .{ symbol, @errorName(err) });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candles.len > 0) {
|
if (candles.len > 0) {
|
||||||
const last = candles[candles.len - 1];
|
const last = candles[candles.len - 1];
|
||||||
|
|
@ -239,9 +219,8 @@ pub const Store = struct {
|
||||||
|
|
||||||
if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| {
|
if (serializeCandles(self.allocator, new_candles, .{ .emit_directives = false })) |srf_data| {
|
||||||
defer self.allocator.free(srf_data);
|
defer self.allocator.free(srf_data);
|
||||||
self.appendRaw(symbol, .candles_daily, srf_data) catch |append_err| {
|
self.appendRaw(symbol, .candles_daily, srf_data) catch {
|
||||||
// Append failed (file missing?) — fall back to full load + rewrite
|
// Append failed (file missing?) — fall back to full load + rewrite
|
||||||
log.debug("{s}: append failed ({s}), falling back to full rewrite", .{ symbol, @errorName(append_err) });
|
|
||||||
if (self.read(Candle, symbol, null, .any)) |existing| {
|
if (self.read(Candle, symbol, null, .any)) |existing| {
|
||||||
defer self.allocator.free(existing.data);
|
defer self.allocator.free(existing.data);
|
||||||
const merged = self.allocator.alloc(Candle, existing.data.len + new_candles.len) catch return;
|
const merged = self.allocator.alloc(Candle, existing.data.len + new_candles.len) catch return;
|
||||||
|
|
@ -250,17 +229,11 @@ pub const Store = struct {
|
||||||
@memcpy(merged[existing.data.len..], new_candles);
|
@memcpy(merged[existing.data.len..], new_candles);
|
||||||
if (serializeCandles(self.allocator, merged, .{})) |full_data| {
|
if (serializeCandles(self.allocator, merged, .{})) |full_data| {
|
||||||
defer self.allocator.free(full_data);
|
defer self.allocator.free(full_data);
|
||||||
self.writeRaw(symbol, .candles_daily, full_data) catch |err| {
|
self.writeRaw(symbol, .candles_daily, full_data) catch {};
|
||||||
log.warn("{s}: failed to write merged candles to cache: {s}", .{ symbol, @errorName(err) });
|
} else |_| {}
|
||||||
};
|
|
||||||
} else |err| {
|
|
||||||
log.warn("{s}: failed to serialize merged candles: {s}", .{ symbol, @errorName(err) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else |err| {
|
} else |_| {}
|
||||||
log.warn("{s}: failed to serialize new candles for append: {s}", .{ symbol, @errorName(err) });
|
|
||||||
}
|
|
||||||
|
|
||||||
const last = new_candles[new_candles.len - 1];
|
const last = new_candles[new_candles.len - 1];
|
||||||
self.updateCandleMeta(symbol, last.close, last.date);
|
self.updateCandleMeta(symbol, last.close, last.date);
|
||||||
|
|
@ -281,12 +254,8 @@ pub const Store = struct {
|
||||||
};
|
};
|
||||||
if (serializeCandleMeta(self.allocator, meta, .{ .expires = expires })) |meta_data| {
|
if (serializeCandleMeta(self.allocator, meta, .{ .expires = expires })) |meta_data| {
|
||||||
defer self.allocator.free(meta_data);
|
defer self.allocator.free(meta_data);
|
||||||
self.writeRaw(symbol, .candles_meta, meta_data) catch |err| {
|
self.writeRaw(symbol, .candles_meta, meta_data) catch {};
|
||||||
log.warn("{s}: failed to write candle metadata: {s}", .{ symbol, @errorName(err) });
|
} else |_| {}
|
||||||
};
|
|
||||||
} else |err| {
|
|
||||||
log.warn("{s}: failed to serialize candle metadata: {s}", .{ symbol, @errorName(err) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cache management ─────────────────────────────────────────
|
// ── Cache management ─────────────────────────────────────────
|
||||||
|
|
@ -595,7 +564,7 @@ pub const Store = struct {
|
||||||
return aw.toOwnedSlice();
|
return aw.toOwnedSlice();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deserializeOptions(allocator: std.mem.Allocator, it: *srf.RecordIterator) ![]OptionsChain {
|
fn deserializeOptions(allocator: std.mem.Allocator, it: anytype) ![]OptionsChain {
|
||||||
var chains: std.ArrayList(OptionsChain) = .empty;
|
var chains: std.ArrayList(OptionsChain) = .empty;
|
||||||
errdefer {
|
errdefer {
|
||||||
for (chains.items) |*ch| {
|
for (chains.items) |*ch| {
|
||||||
|
|
@ -694,7 +663,7 @@ pub const Store = struct {
|
||||||
return aw.toOwnedSlice();
|
return aw.toOwnedSlice();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deserializeEtfProfile(allocator: std.mem.Allocator, it: *srf.RecordIterator) !EtfProfile {
|
fn deserializeEtfProfile(allocator: std.mem.Allocator, it: anytype) !EtfProfile {
|
||||||
var profile = EtfProfile{ .symbol = "" };
|
var profile = EtfProfile{ .symbol = "" };
|
||||||
var sectors: std.ArrayList(SectorWeight) = .empty;
|
var sectors: std.ArrayList(SectorWeight) = .empty;
|
||||||
errdefer {
|
errdefer {
|
||||||
|
|
@ -962,101 +931,3 @@ test "portfolio: price_ratio round-trip" {
|
||||||
try std.testing.expectEqualStrings("VTTHX", portfolio2.lots[0].ticker.?);
|
try std.testing.expectEqualStrings("VTTHX", portfolio2.lots[0].ticker.?);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio2.lots[1].price_ratio, 0.001);
|
try std.testing.expectApproxEqAbs(@as(f64, 1.0), portfolio2.lots[1].price_ratio, 0.001);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── TTL and Negative Cache Tests ─────────────────────────────────
|
|
||||||
|
|
||||||
test "TTL constants are reasonable" {
|
|
||||||
// Historical candles never expire
|
|
||||||
try std.testing.expectEqual(@as(i64, -1), Ttl.candles_historical);
|
|
||||||
|
|
||||||
// Latest candles expire just under 24 hours (allowing for cron jitter)
|
|
||||||
try std.testing.expect(Ttl.candles_latest > 23 * std.time.s_per_hour);
|
|
||||||
try std.testing.expect(Ttl.candles_latest < 24 * std.time.s_per_hour);
|
|
||||||
|
|
||||||
// Dividends and splits refresh biweekly
|
|
||||||
try std.testing.expectEqual(@as(i64, 14 * std.time.s_per_day), Ttl.dividends);
|
|
||||||
try std.testing.expectEqual(@as(i64, 14 * std.time.s_per_day), Ttl.splits);
|
|
||||||
|
|
||||||
// Options refresh hourly
|
|
||||||
try std.testing.expectEqual(@as(i64, std.time.s_per_hour), Ttl.options);
|
|
||||||
|
|
||||||
// Earnings and ETF profiles 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "DataType.ttl returns correct values" {
|
|
||||||
try std.testing.expectEqual(Ttl.dividends, DataType.dividends.ttl());
|
|
||||||
try std.testing.expectEqual(Ttl.splits, DataType.splits.ttl());
|
|
||||||
try std.testing.expectEqual(Ttl.options, DataType.options.ttl());
|
|
||||||
try std.testing.expectEqual(Ttl.earnings, DataType.earnings.ttl());
|
|
||||||
try std.testing.expectEqual(Ttl.etf_profile, DataType.etf_profile.ttl());
|
|
||||||
|
|
||||||
// These types have no TTL (0 = managed elsewhere)
|
|
||||||
try std.testing.expectEqual(@as(i64, 0), DataType.candles_daily.ttl());
|
|
||||||
try std.testing.expectEqual(@as(i64, 0), DataType.candles_meta.ttl());
|
|
||||||
try std.testing.expectEqual(@as(i64, 0), DataType.meta.ttl());
|
|
||||||
}
|
|
||||||
|
|
||||||
test "DataType.fileName returns correct file names" {
|
|
||||||
try std.testing.expectEqualStrings("candles_daily.srf", DataType.candles_daily.fileName());
|
|
||||||
try std.testing.expectEqualStrings("candles_meta.srf", DataType.candles_meta.fileName());
|
|
||||||
try std.testing.expectEqualStrings("dividends.srf", DataType.dividends.fileName());
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
test "negative_cache_content format" {
|
|
||||||
// Negative cache marker should be valid SRF with a comment
|
|
||||||
try std.testing.expect(std.mem.startsWith(u8, Store.negative_cache_content, "#!srfv1"));
|
|
||||||
try std.testing.expect(std.mem.indexOf(u8, Store.negative_cache_content, "fetch_failed") != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "Store.dataTypeFor maps model types correctly" {
|
|
||||||
try std.testing.expectEqual(DataType.candles_daily, Store.dataTypeFor(Candle));
|
|
||||||
try std.testing.expectEqual(DataType.dividends, Store.dataTypeFor(Dividend));
|
|
||||||
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);
|
|
||||||
try std.testing.expect(Store.DataFor(Candle) == []Candle);
|
|
||||||
try std.testing.expect(Store.DataFor(Dividend) == []Dividend);
|
|
||||||
try std.testing.expect(Store.DataFor(Split) == []Split);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "Store.Freshness enum values" {
|
|
||||||
// Ensure enum has expected 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 "Store init creates valid store" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const store = Store.init(allocator, "/tmp/zfin-test");
|
|
||||||
try std.testing.expectEqualStrings("/tmp/zfin-test", store.cache_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "CandleMeta default provider is tiingo" {
|
|
||||||
const meta = Store.CandleMeta{
|
|
||||||
.last_close = 100.0,
|
|
||||||
.last_date = Date.fromYmd(2024, 1, 1),
|
|
||||||
};
|
|
||||||
try std.testing.expectEqual(Store.CandleProvider.tiingo, meta.provider);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -807,7 +807,7 @@ pub fn computeBrailleChart(
|
||||||
/// Write a braille chart to a writer with ANSI color escapes.
|
/// Write a braille chart to a writer with ANSI color escapes.
|
||||||
/// Used by the CLI for terminal output.
|
/// Used by the CLI for terminal output.
|
||||||
pub fn writeBrailleAnsi(
|
pub fn writeBrailleAnsi(
|
||||||
out: *std.Io.Writer,
|
out: anytype,
|
||||||
chart: *const BrailleChart,
|
chart: *const BrailleChart,
|
||||||
use_color: bool,
|
use_color: bool,
|
||||||
muted_color: [3]u8,
|
muted_color: [3]u8,
|
||||||
|
|
@ -888,17 +888,22 @@ pub fn shouldUseColor(no_color_flag: bool) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write an ANSI 24-bit foreground color escape.
|
/// Write an ANSI 24-bit foreground color escape.
|
||||||
pub fn ansiSetFg(out: *std.Io.Writer, r: u8, g: u8, b: u8) !void {
|
pub fn ansiSetFg(out: anytype, r: u8, g: u8, b: u8) !void {
|
||||||
try out.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b });
|
try out.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write ANSI bold.
|
/// Write ANSI bold.
|
||||||
pub fn ansiBold(out: *std.Io.Writer) !void {
|
pub fn ansiBold(out: anytype) !void {
|
||||||
try out.writeAll("\x1b[1m");
|
try out.writeAll("\x1b[1m");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write ANSI dim.
|
||||||
|
pub fn ansiDim(out: anytype) !void {
|
||||||
|
try out.writeAll("\x1b[2m");
|
||||||
|
}
|
||||||
|
|
||||||
/// Reset all ANSI attributes.
|
/// Reset all ANSI attributes.
|
||||||
pub fn ansiReset(out: *std.Io.Writer) !void {
|
pub fn ansiReset(out: anytype) !void {
|
||||||
try out.writeAll("\x1b[0m");
|
try out.writeAll("\x1b[0m");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
168
src/service.zig
168
src/service.zig
|
|
@ -98,40 +98,10 @@ pub const DataService = struct {
|
||||||
tg: ?Tiingo = null,
|
tg: ?Tiingo = null,
|
||||||
|
|
||||||
pub fn init(allocator: std.mem.Allocator, config: Config) DataService {
|
pub fn init(allocator: std.mem.Allocator, config: Config) DataService {
|
||||||
const self = DataService{
|
return .{
|
||||||
.allocator = allocator,
|
.allocator = allocator,
|
||||||
.config = config,
|
.config = config,
|
||||||
};
|
};
|
||||||
self.logMissingKeys();
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log warnings for missing API keys so users know which features are unavailable.
|
|
||||||
fn logMissingKeys(self: DataService) void {
|
|
||||||
// Primary candle provider
|
|
||||||
if (self.config.tiingo_key == null) {
|
|
||||||
log.warn("TIINGO_API_KEY not set — candle data will fall back to TwelveData/Yahoo", .{});
|
|
||||||
}
|
|
||||||
// Dividend/split data
|
|
||||||
if (self.config.polygon_key == null) {
|
|
||||||
log.warn("POLYGON_API_KEY not set — dividend and split data unavailable", .{});
|
|
||||||
}
|
|
||||||
// Earnings data
|
|
||||||
if (self.config.finnhub_key == null) {
|
|
||||||
log.warn("FINNHUB_API_KEY not set — earnings data unavailable", .{});
|
|
||||||
}
|
|
||||||
// ETF profiles
|
|
||||||
if (self.config.alphavantage_key == null) {
|
|
||||||
log.warn("ALPHAVANTAGE_API_KEY not set — ETF profiles unavailable", .{});
|
|
||||||
}
|
|
||||||
// Candle fallback
|
|
||||||
if (self.config.twelvedata_key == null and self.config.tiingo_key == null) {
|
|
||||||
log.warn("TWELVEDATA_API_KEY not set — no candle fallback if Yahoo fails", .{});
|
|
||||||
}
|
|
||||||
// CUSIP lookups
|
|
||||||
if (self.config.openfigi_key == null) {
|
|
||||||
log.info("OPENFIGI_API_KEY not set — CUSIP lookups will use anonymous rate limits", .{});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deinit(self: *DataService) void {
|
pub fn deinit(self: *DataService) void {
|
||||||
|
|
@ -1282,139 +1252,3 @@ pub const DataService = struct {
|
||||||
return symbol.len == 5 and symbol[4] == 'X';
|
return symbol.len == 5 and symbol[4] == 'X';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test "isMutualFund identifies mutual funds" {
|
|
||||||
// Standard mutual fund tickers (5 letters ending in X)
|
|
||||||
try std.testing.expect(DataService.isMutualFund("FDSCX"));
|
|
||||||
try std.testing.expect(DataService.isMutualFund("VSTCX"));
|
|
||||||
try std.testing.expect(DataService.isMutualFund("FAGIX"));
|
|
||||||
try std.testing.expect(DataService.isMutualFund("VFINX"));
|
|
||||||
|
|
||||||
// Not mutual funds
|
|
||||||
try std.testing.expect(!DataService.isMutualFund("AAPL"));
|
|
||||||
try std.testing.expect(!DataService.isMutualFund("VTI"));
|
|
||||||
try std.testing.expect(!DataService.isMutualFund("SPY"));
|
|
||||||
try std.testing.expect(!DataService.isMutualFund("GOOGL"));
|
|
||||||
try std.testing.expect(!DataService.isMutualFund("")); // empty
|
|
||||||
try std.testing.expect(!DataService.isMutualFund("X")); // too short
|
|
||||||
try std.testing.expect(!DataService.isMutualFund("FDSCA")); // 5 letters but not ending in X
|
|
||||||
try std.testing.expect(!DataService.isMutualFund("FDSCXA")); // 6 letters ending in A
|
|
||||||
}
|
|
||||||
|
|
||||||
test "DataService init/deinit lifecycle" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const config = Config{
|
|
||||||
.cache_dir = "/tmp/zfin-test-cache",
|
|
||||||
};
|
|
||||||
var svc = DataService.init(allocator, config);
|
|
||||||
defer svc.deinit();
|
|
||||||
|
|
||||||
// Should be able to access config
|
|
||||||
try std.testing.expectEqualStrings("/tmp/zfin-test-cache", svc.config.cache_dir);
|
|
||||||
// Providers should be null (lazy init)
|
|
||||||
try std.testing.expect(svc.td == null);
|
|
||||||
try std.testing.expect(svc.pg == null);
|
|
||||||
try std.testing.expect(svc.fh == null);
|
|
||||||
try std.testing.expect(svc.yh == null);
|
|
||||||
try std.testing.expect(svc.tg == null);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "DataService store helper creates valid store" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const config = Config{
|
|
||||||
.cache_dir = "/tmp/zfin-test-cache",
|
|
||||||
};
|
|
||||||
var svc = DataService.init(allocator, config);
|
|
||||||
defer svc.deinit();
|
|
||||||
|
|
||||||
const s = svc.store();
|
|
||||||
try std.testing.expectEqualStrings("/tmp/zfin-test-cache", s.cache_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "DataService getProvider returns NoApiKey without key" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const config = Config{
|
|
||||||
.cache_dir = "/tmp/zfin-test-cache",
|
|
||||||
// No API keys set
|
|
||||||
};
|
|
||||||
var svc = DataService.init(allocator, config);
|
|
||||||
defer svc.deinit();
|
|
||||||
|
|
||||||
// TwelveData requires API key
|
|
||||||
const td_result = svc.getProvider(TwelveData);
|
|
||||||
try std.testing.expectError(DataError.NoApiKey, td_result);
|
|
||||||
|
|
||||||
// Polygon requires API key
|
|
||||||
const pg_result = svc.getProvider(Polygon);
|
|
||||||
try std.testing.expectError(DataError.NoApiKey, pg_result);
|
|
||||||
|
|
||||||
// Yahoo doesn't require API key
|
|
||||||
const yh_result = svc.getProvider(Yahoo);
|
|
||||||
try std.testing.expect(yh_result != error.NoApiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "DataService getProvider initializes provider with key" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
const config = Config{
|
|
||||||
.cache_dir = "/tmp/zfin-test-cache",
|
|
||||||
.tiingo_key = "test-tiingo-key",
|
|
||||||
};
|
|
||||||
var svc = DataService.init(allocator, config);
|
|
||||||
defer svc.deinit();
|
|
||||||
|
|
||||||
// First call initializes
|
|
||||||
const tg1 = try svc.getProvider(Tiingo);
|
|
||||||
try std.testing.expect(svc.tg != null);
|
|
||||||
|
|
||||||
// Second call returns same instance
|
|
||||||
const tg2 = try svc.getProvider(Tiingo);
|
|
||||||
try std.testing.expect(tg1 == tg2);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "DataService PriceLoadResult default values" {
|
|
||||||
const result = DataService.PriceLoadResult{
|
|
||||||
.cached_count = 0,
|
|
||||||
.fetched_count = 0,
|
|
||||||
.fail_count = 0,
|
|
||||||
.stale_count = 0,
|
|
||||||
.latest_date = null,
|
|
||||||
};
|
|
||||||
try std.testing.expectEqual(@as(usize, 0), result.cached_count);
|
|
||||||
try std.testing.expect(result.latest_date == null);
|
|
||||||
}
|
|
||||||
|
|
||||||
test "DataService LoadAllResult default values" {
|
|
||||||
const allocator = std.testing.allocator;
|
|
||||||
var result = DataService.LoadAllResult{
|
|
||||||
.prices = std.StringHashMap(f64).init(allocator),
|
|
||||||
.cached_count = 0,
|
|
||||||
.server_synced_count = 0,
|
|
||||||
.provider_fetched_count = 0,
|
|
||||||
.stale_count = 0,
|
|
||||||
.failed_count = 0,
|
|
||||||
.latest_date = null,
|
|
||||||
};
|
|
||||||
defer result.deinit();
|
|
||||||
|
|
||||||
try std.testing.expectEqual(@as(usize, 0), result.prices.count());
|
|
||||||
}
|
|
||||||
|
|
||||||
test "FetchResult type construction" {
|
|
||||||
// Verify FetchResult works for different types
|
|
||||||
const candle_result = FetchResult(Candle){
|
|
||||||
.data = &.{},
|
|
||||||
.source = .cached,
|
|
||||||
.timestamp = 0,
|
|
||||||
};
|
|
||||||
try std.testing.expect(candle_result.source == .cached);
|
|
||||||
|
|
||||||
const div_result = FetchResult(Dividend){
|
|
||||||
.data = &.{},
|
|
||||||
.source = .fetched,
|
|
||||||
.timestamp = 12345,
|
|
||||||
};
|
|
||||||
try std.testing.expect(div_result.source == .fetched);
|
|
||||||
try std.testing.expectEqual(@as(i64, 12345), div_result.timestamp);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
30
src/tui.zig
30
src/tui.zig
|
|
@ -334,14 +334,12 @@ pub const App = struct {
|
||||||
quote_timestamp: i64 = 0,
|
quote_timestamp: i64 = 0,
|
||||||
// Track whether earnings tab should be disabled (ETF, no data)
|
// Track whether earnings tab should be disabled (ETF, no data)
|
||||||
earnings_disabled: bool = false,
|
earnings_disabled: bool = false,
|
||||||
earnings_error: ?[]const u8 = null, // error message to show in content area
|
|
||||||
// ETF profile (loaded lazily on quote tab)
|
// ETF profile (loaded lazily on quote tab)
|
||||||
etf_profile: ?zfin.EtfProfile = null,
|
etf_profile: ?zfin.EtfProfile = null,
|
||||||
etf_loaded: bool = false,
|
etf_loaded: bool = false,
|
||||||
// Analysis tab state
|
// Analysis tab state
|
||||||
analysis_result: ?zfin.analysis.AnalysisResult = null,
|
analysis_result: ?zfin.analysis.AnalysisResult = null,
|
||||||
analysis_loaded: bool = false,
|
analysis_loaded: bool = false,
|
||||||
analysis_disabled: bool = false, // true when no portfolio loaded (analysis requires portfolio)
|
|
||||||
classification_map: ?zfin.classification.ClassificationMap = null,
|
classification_map: ?zfin.classification.ClassificationMap = null,
|
||||||
account_map: ?zfin.analysis.AccountMap = null,
|
account_map: ?zfin.analysis.AccountMap = null,
|
||||||
|
|
||||||
|
|
@ -402,7 +400,7 @@ pub const App = struct {
|
||||||
for (tabs) |t| {
|
for (tabs) |t| {
|
||||||
const lbl_len: i16 = @intCast(t.label().len);
|
const lbl_len: i16 = @intCast(t.label().len);
|
||||||
if (mouse.col >= col and mouse.col < col + lbl_len) {
|
if (mouse.col >= col and mouse.col < col + lbl_len) {
|
||||||
if (self.isTabDisabled(t)) return;
|
if (t == .earnings and self.earnings_disabled) return;
|
||||||
self.active_tab = t;
|
self.active_tab = t;
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
self.loadTabData();
|
self.loadTabData();
|
||||||
|
|
@ -616,7 +614,7 @@ pub const App = struct {
|
||||||
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
|
const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1);
|
||||||
if (idx < tabs.len) {
|
if (idx < tabs.len) {
|
||||||
const target = tabs[idx];
|
const target = tabs[idx];
|
||||||
if (self.isTabDisabled(target)) return;
|
if (target == .earnings and self.earnings_disabled) return;
|
||||||
self.active_tab = target;
|
self.active_tab = target;
|
||||||
self.scroll_offset = 0;
|
self.scroll_offset = 0;
|
||||||
self.loadTabData();
|
self.loadTabData();
|
||||||
|
|
@ -975,7 +973,6 @@ pub const App = struct {
|
||||||
self.perf_loaded = false;
|
self.perf_loaded = false;
|
||||||
self.earnings_loaded = false;
|
self.earnings_loaded = false;
|
||||||
self.earnings_disabled = false;
|
self.earnings_disabled = false;
|
||||||
self.earnings_error = null;
|
|
||||||
self.options_loaded = false;
|
self.options_loaded = false;
|
||||||
self.etf_loaded = false;
|
self.etf_loaded = false;
|
||||||
self.options_cursor = 0;
|
self.options_cursor = 0;
|
||||||
|
|
@ -1034,8 +1031,6 @@ pub const App = struct {
|
||||||
},
|
},
|
||||||
.earnings => {
|
.earnings => {
|
||||||
self.earnings_loaded = false;
|
self.earnings_loaded = false;
|
||||||
self.earnings_disabled = false;
|
|
||||||
self.earnings_error = null;
|
|
||||||
self.freeEarnings();
|
self.freeEarnings();
|
||||||
},
|
},
|
||||||
.options => {
|
.options => {
|
||||||
|
|
@ -1086,7 +1081,6 @@ pub const App = struct {
|
||||||
if (!self.options_loaded) options_tab.loadData(self);
|
if (!self.options_loaded) options_tab.loadData(self);
|
||||||
},
|
},
|
||||||
.analysis => {
|
.analysis => {
|
||||||
if (self.analysis_disabled) return;
|
|
||||||
if (!self.analysis_loaded) self.loadAnalysisData();
|
if (!self.analysis_loaded) self.loadAnalysisData();
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -1222,7 +1216,7 @@ pub const App = struct {
|
||||||
for (tabs) |t| {
|
for (tabs) |t| {
|
||||||
const lbl = t.label();
|
const lbl = t.label();
|
||||||
const is_active = t == self.active_tab;
|
const is_active = t == self.active_tab;
|
||||||
const is_disabled = self.isTabDisabled(t);
|
const is_disabled = t == .earnings and self.earnings_disabled;
|
||||||
const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style;
|
const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style;
|
||||||
|
|
||||||
for (lbl) |ch| {
|
for (lbl) |ch| {
|
||||||
|
|
@ -1253,11 +1247,6 @@ pub const App = struct {
|
||||||
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} };
|
||||||
}
|
}
|
||||||
|
|
||||||
fn isTabDisabled(self: *App, t: Tab) bool {
|
|
||||||
return (t == .earnings and self.earnings_disabled) or
|
|
||||||
(t == .analysis and self.analysis_disabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn isSymbolSelected(self: *App) bool {
|
fn isSymbolSelected(self: *App) bool {
|
||||||
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
|
// Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's'
|
||||||
if (self.active_tab != .portfolio) return false;
|
if (self.active_tab != .portfolio) return false;
|
||||||
|
|
@ -1487,9 +1476,7 @@ pub const App = struct {
|
||||||
fn nextTab(self: *App) void {
|
fn nextTab(self: *App) void {
|
||||||
const idx = @intFromEnum(self.active_tab);
|
const idx = @intFromEnum(self.active_tab);
|
||||||
var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
|
var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0;
|
||||||
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
|
if (tabs[next_idx] == .earnings and self.earnings_disabled)
|
||||||
var tries: usize = 0;
|
|
||||||
while (self.isTabDisabled(tabs[next_idx]) and tries < tabs.len) : (tries += 1)
|
|
||||||
next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0;
|
next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0;
|
||||||
self.active_tab = tabs[next_idx];
|
self.active_tab = tabs[next_idx];
|
||||||
}
|
}
|
||||||
|
|
@ -1497,9 +1484,7 @@ pub const App = struct {
|
||||||
fn prevTab(self: *App) void {
|
fn prevTab(self: *App) void {
|
||||||
const idx = @intFromEnum(self.active_tab);
|
const idx = @intFromEnum(self.active_tab);
|
||||||
var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1;
|
var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1;
|
||||||
// Skip disabled tabs (earnings for ETFs, analysis without portfolio)
|
if (tabs[prev_idx] == .earnings and self.earnings_disabled)
|
||||||
var tries: usize = 0;
|
|
||||||
while (self.isTabDisabled(tabs[prev_idx]) and tries < tabs.len) : (tries += 1)
|
|
||||||
prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1;
|
prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1;
|
||||||
self.active_tab = tabs[prev_idx];
|
self.active_tab = tabs[prev_idx];
|
||||||
}
|
}
|
||||||
|
|
@ -1763,11 +1748,6 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
|
||||||
app_inst.active_tab = .quote;
|
app_inst.active_tab = .quote;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable analysis tab when no portfolio is loaded (analysis requires portfolio)
|
|
||||||
if (app_inst.portfolio == null) {
|
|
||||||
app_inst.analysis_disabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
|
// Pre-fetch portfolio prices before TUI starts, with stderr progress.
|
||||||
// This runs while the terminal is still in normal mode so output is visible.
|
// This runs while the terminal is still in normal mode so output is visible.
|
||||||
if (app_inst.portfolio) |pf| {
|
if (app_inst.portfolio) |pf| {
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,16 @@ const StyledLine = tui.StyledLine;
|
||||||
|
|
||||||
pub fn loadData(app: *App) void {
|
pub fn loadData(app: *App) void {
|
||||||
app.earnings_loaded = true;
|
app.earnings_loaded = true;
|
||||||
app.earnings_error = null;
|
|
||||||
app.freeEarnings();
|
app.freeEarnings();
|
||||||
|
|
||||||
const result = app.svc.getEarnings(app.symbol) catch |err| {
|
const result = app.svc.getEarnings(app.symbol) catch |err| {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
zfin.DataError.NoApiKey => {
|
zfin.DataError.NoApiKey => app.setStatus("No API key. Set FINNHUB_API_KEY"),
|
||||||
app.earnings_error = "No API key. Set FINNHUB_API_KEY (free at finnhub.io)";
|
|
||||||
app.setStatus("No API key. Set FINNHUB_API_KEY");
|
|
||||||
},
|
|
||||||
zfin.DataError.FetchFailed => {
|
zfin.DataError.FetchFailed => {
|
||||||
app.earnings_disabled = true;
|
app.earnings_disabled = true;
|
||||||
app.setStatus("No earnings data (ETF/index?)");
|
app.setStatus("No earnings data (ETF/index?)");
|
||||||
},
|
},
|
||||||
else => {
|
else => app.setStatus("Error loading earnings"),
|
||||||
app.earnings_error = "Error loading earnings data. Press r to retry.";
|
|
||||||
app.setStatus("Error loading earnings");
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -54,7 +47,7 @@ pub fn loadData(app: *App) void {
|
||||||
// ── Rendering ─────────────────────────────────────────────────
|
// ── Rendering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||||
return renderEarningsLines(arena, app.theme, app.symbol, app.earnings_disabled, app.earnings_data, app.earnings_timestamp, app.earnings_error);
|
return renderEarningsLines(arena, app.theme, app.symbol, app.earnings_disabled, app.earnings_data, app.earnings_timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render earnings tab content. Pure function — no App dependency.
|
/// Render earnings tab content. Pure function — no App dependency.
|
||||||
|
|
@ -65,7 +58,6 @@ pub fn renderEarningsLines(
|
||||||
earnings_disabled: bool,
|
earnings_disabled: bool,
|
||||||
earnings_data: ?[]const zfin.EarningsEvent,
|
earnings_data: ?[]const zfin.EarningsEvent,
|
||||||
earnings_timestamp: i64,
|
earnings_timestamp: i64,
|
||||||
earnings_error: ?[]const u8,
|
|
||||||
) ![]const StyledLine {
|
) ![]const StyledLine {
|
||||||
var lines: std.ArrayList(StyledLine) = .empty;
|
var lines: std.ArrayList(StyledLine) = .empty;
|
||||||
|
|
||||||
|
|
@ -90,11 +82,7 @@ pub fn renderEarningsLines(
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
||||||
const ev = earnings_data orelse {
|
const ev = earnings_data orelse {
|
||||||
if (earnings_error) |err_msg| {
|
|
||||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{err_msg}), .style = th.warningStyle() });
|
|
||||||
} else {
|
|
||||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() });
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() });
|
||||||
}
|
|
||||||
return lines.toOwnedSlice(arena);
|
return lines.toOwnedSlice(arena);
|
||||||
};
|
};
|
||||||
if (ev.len == 0) {
|
if (ev.len == 0) {
|
||||||
|
|
@ -139,7 +127,7 @@ test "renderEarningsLines with earnings data" {
|
||||||
.estimate = 1.50,
|
.estimate = 1.50,
|
||||||
.actual = 1.65,
|
.actual = 1.65,
|
||||||
}};
|
}};
|
||||||
const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0, null);
|
const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0);
|
||||||
// blank + header + blank + col_header + data_row + blank + count = 7
|
// blank + header + blank + col_header + data_row + blank + count = 7
|
||||||
try testing.expectEqual(@as(usize, 7), lines.len);
|
try testing.expectEqual(@as(usize, 7), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null);
|
try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null);
|
||||||
|
|
@ -154,7 +142,7 @@ test "renderEarningsLines no symbol" {
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
const th = theme_mod.default_theme;
|
const th = theme_mod.default_theme;
|
||||||
|
|
||||||
const lines = try renderEarningsLines(arena, th, "", false, null, 0, null);
|
const lines = try renderEarningsLines(arena, th, "", false, null, 0);
|
||||||
try testing.expectEqual(@as(usize, 2), lines.len);
|
try testing.expectEqual(@as(usize, 2), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null);
|
try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null);
|
||||||
}
|
}
|
||||||
|
|
@ -165,7 +153,7 @@ test "renderEarningsLines disabled" {
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
const th = theme_mod.default_theme;
|
const th = theme_mod.default_theme;
|
||||||
|
|
||||||
const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0, null);
|
const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0);
|
||||||
try testing.expectEqual(@as(usize, 2), lines.len);
|
try testing.expectEqual(@as(usize, 2), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null);
|
try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null);
|
||||||
}
|
}
|
||||||
|
|
@ -176,18 +164,7 @@ test "renderEarningsLines no data" {
|
||||||
const arena = arena_state.allocator();
|
const arena = arena_state.allocator();
|
||||||
const th = theme_mod.default_theme;
|
const th = theme_mod.default_theme;
|
||||||
|
|
||||||
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, null);
|
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0);
|
||||||
try testing.expectEqual(@as(usize, 4), lines.len);
|
try testing.expectEqual(@as(usize, 4), lines.len);
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null);
|
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "renderEarningsLines with error message" {
|
|
||||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
|
||||||
defer arena_state.deinit();
|
|
||||||
const arena = arena_state.allocator();
|
|
||||||
const th = theme_mod.default_theme;
|
|
||||||
|
|
||||||
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0, "No API key. Set FINNHUB_API_KEY");
|
|
||||||
try testing.expectEqual(@as(usize, 4), lines.len);
|
|
||||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "FINNHUB_API_KEY") != null);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1025,7 +1025,6 @@ pub fn reloadPortfolioFile(app: *App) void {
|
||||||
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
|
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
|
||||||
app.analysis_result = null;
|
app.analysis_result = null;
|
||||||
app.analysis_loaded = false;
|
app.analysis_loaded = false;
|
||||||
app.analysis_disabled = false; // Portfolio loaded; analysis is now possible
|
|
||||||
|
|
||||||
// If currently on the analysis tab, eagerly recompute so the user
|
// If currently on the analysis tab, eagerly recompute so the user
|
||||||
// doesn't see an error message before switching away and back.
|
// doesn't see an error message before switching away and back.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue