Compare commits

...

30 commits

Author SHA1 Message Date
7144f60d10
rework the data load
All checks were successful
Generic zig build / build (push) Successful in 29s
2026-03-20 09:48:03 -07:00
6d99349b62
clean up chart.zig 2026-03-20 09:36:23 -07:00
04882a4ff8
cache chart and use sliding windows on bollinger bands 2026-03-20 09:23:41 -07:00
be42f9e15a
clean up history/enrich 2026-03-20 09:07:47 -07:00
1cdf228fc1
update todo 2026-03-20 08:58:48 -07:00
e9a757cece
clean up options 2026-03-20 08:58:30 -07:00
35fc9101fd
clean up quote 2026-03-20 08:51:49 -07:00
ce72985054
clean up analysis 2026-03-20 08:40:04 -07:00
b718c1ae39
remove stupid one-liners that are not called/rename self in tabs 2026-03-20 08:29:45 -07:00
621a8db0df
move loads into tabs 2026-03-20 08:18:21 -07:00
2bcb84dafa
clean up earnings 2026-03-20 08:12:53 -07:00
8ae9089975
full screen repaint when switching tabs 2026-03-19 14:43:27 -07:00
31d49d4432
clean up perf 2026-03-19 14:32:03 -07:00
88241b2c7b
portfolio cli/tui cleanup 2026-03-19 14:25:13 -07:00
b4f3857cef
consolidate shared portfolio summary calculations 2026-03-19 13:28:18 -07:00
b162708055
consolidate drip summary 2026-03-19 13:04:20 -07:00
e29fb5b743
consolidate watchlist loading/use srf 2026-03-19 13:00:57 -07:00
46bf34fd1c
remove old comment/reuse symbols 2026-03-19 12:50:51 -07:00
38d9065f4f
windows support (theoretically) 2026-03-19 12:29:37 -07:00
8124ca0e88
ai: remove edit feature 2026-03-19 12:12:48 -07:00
4a3df7a05b
more clearly communicate intent for scroll_bottom 2026-03-19 12:08:39 -07:00
ff87505771
clean up handleInputKey 2026-03-19 11:46:29 -07:00
b66b9391a5
remove magic numbers 2026-03-19 11:30:12 -07:00
d442119d70
unconditional debounce 2026-03-19 11:12:43 -07:00
43ab8d1957
centralize movement logic/debounce mouse wheel on cursor tabs 2026-03-19 11:10:26 -07:00
863111d801
introduce chart state to hold the 13 chart state fields 2026-03-19 09:57:54 -07:00
21a45d5309
add comment on App 2026-03-19 09:44:20 -07:00
c5c7f13400
rearrange tui types 2026-03-19 09:42:19 -07:00
ea7da4ad5d
add concept of "quick switch to last selected security" to todo 2026-03-19 09:41:29 -07:00
71f328b329
ai: refactor tui/fix options panic 2026-03-19 09:29:59 -07:00
26 changed files with 3794 additions and 2997 deletions

16
TODO.md
View file

@ -1,9 +1,11 @@
# Future Work # Future Work
## TUI issues ## CLI options command UX
Display artifacts that don't go away when switching tabs (need specific steps The `options` command auto-expands only the nearest monthly expiration and
to reproduce). Lower priority now that ^L has been introduced to re-paint lists others collapsed. Reconsider the interaction model — e.g. allow
specifying an expiration date, showing all monthlies expanded by default,
or filtering by strategy (covered calls, spreads).
## Risk-free rate maintenance ## Risk-free rate maintenance
@ -42,6 +44,14 @@ Commands:
- `src/commands/quote.zig` - `src/commands/quote.zig`
- `src/commands/splits.zig` - `src/commands/splits.zig`
## TUI: toggle to last symbol keybind
Add a single-key toggle that flips between the current symbol and the
previously selected one (like `cd -` in bash or `Ctrl+^` in vim). Store
`last_symbol` on `App`; on symbol change, stash the previous. The toggle
key swaps current and last. Works on any tab — particularly useful for
eyeball-comparing performance/risk data between two symbols.
## Market-aware cache TTL for daily candles ## Market-aware cache TTL for daily candles
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful Daily candle TTL is currently 23h45m, but candle data only becomes meaningful

View file

@ -23,6 +23,10 @@ pub const BollingerBand = struct {
/// Compute Bollinger Bands (SMA ± k * stddev) for the full series. /// Compute Bollinger Bands (SMA ± k * stddev) for the full series.
/// Returns a slice of optional BollingerBand null where period hasn't been reached. /// Returns a slice of optional BollingerBand null where period hasn't been reached.
///
/// Uses O(n) sliding window algorithm instead of O(n * period):
/// - Maintains running sum for SMA
/// - Maintains running sum of squares for variance: Var(X) = E[X²] - E[X]²
pub fn bollingerBands( pub fn bollingerBands(
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
closes: []const f64, closes: []const f64,
@ -30,25 +34,61 @@ pub fn bollingerBands(
k: f64, k: f64,
) ![]?BollingerBand { ) ![]?BollingerBand {
const result = try alloc.alloc(?BollingerBand, closes.len); const result = try alloc.alloc(?BollingerBand, closes.len);
for (result, 0..) |*r, i| {
const mean = sma(closes, i, period) orelse { if (closes.len < period or period == 0) {
r.* = null; @memset(result, null);
continue; return result;
};
// Standard deviation
const start = i + 1 - period;
var sq_sum: f64 = 0;
for (closes[start .. i + 1]) |v| {
const diff = v - mean;
sq_sum += diff * diff;
} }
const stddev = @sqrt(sq_sum / @as(f64, @floatFromInt(period)));
r.* = .{ const p_f: f64 = @floatFromInt(period);
// Initialize running sums for the first window [0..period)
var sum: f64 = 0;
var sum_sq: f64 = 0;
for (0..period) |i| {
sum += closes[i];
sum_sq += closes[i] * closes[i];
}
// First period-1 values are null (not enough data points)
for (0..period - 1) |i| {
result[i] = null;
}
// Compute for index period-1 (first valid point)
{
const mean = sum / p_f;
// Variance via E[X²] - E[X]² formula
const variance = (sum_sq / p_f) - (mean * mean);
// Use @max to guard against tiny negative values from floating point error
const stddev = @sqrt(@max(variance, 0.0));
result[period - 1] = .{
.upper = mean + k * stddev, .upper = mean + k * stddev,
.middle = mean, .middle = mean,
.lower = mean - k * stddev, .lower = mean - k * stddev,
}; };
} }
// Slide the window for remaining points: O(n - period) iterations, O(1) each
for (period..closes.len) |i| {
const old_val = closes[i - period];
const new_val = closes[i];
// Update running sums in O(1)
sum = sum - old_val + new_val;
sum_sq = sum_sq - (old_val * old_val) + (new_val * new_val);
const mean = sum / p_f;
const variance = (sum_sq / p_f) - (mean * mean);
const stddev = @sqrt(@max(variance, 0.0));
result[i] = .{
.upper = mean + k * stddev,
.middle = mean,
.lower = mean - k * stddev,
};
}
return result; return result;
} }
@ -208,3 +248,89 @@ test "rsi insufficient data" {
// All should be null since len < period + 1 // All should be null since len < period + 1
for (result) |r| try std.testing.expect(r == null); for (result) |r| try std.testing.expect(r == null);
} }
test "bollingerBands sliding window correctness" {
const alloc = std.testing.allocator;
// Test with realistic price data
const closes = [_]f64{
100.0, 101.5, 99.8, 102.3, 103.1, 101.9, 104.2, 105.0,
103.8, 106.2, 107.1, 105.5, 108.0, 109.2, 107.8, 110.5,
111.3, 109.8, 112.4, 113.0,
};
const bands = try bollingerBands(alloc, &closes, 5, 2.0);
defer alloc.free(bands);
// First 4 should be null
for (0..4) |i| {
try std.testing.expect(bands[i] == null);
}
// Verify a few points manually
// Index 4: window is [100.0, 101.5, 99.8, 102.3, 103.1]
// Mean = 101.34, manually computed
const b4 = bands[4].?;
try std.testing.expectApproxEqAbs(@as(f64, 101.34), b4.middle, 0.01);
try std.testing.expect(b4.upper > b4.middle);
try std.testing.expect(b4.lower < b4.middle);
// Index 19: window is [109.8, 112.4, 113.0, 111.3, 110.5] wait, let me recalculate
// Window at i=19 is closes[15..20] = [110.5, 111.3, 109.8, 112.4, 113.0]
// Mean = (110.5 + 111.3 + 109.8 + 112.4 + 113.0) / 5 = 557.0 / 5 = 111.4
const b19 = bands[19].?;
try std.testing.expectApproxEqAbs(@as(f64, 111.4), b19.middle, 0.01);
// Verify bands are properly ordered
for (bands) |b_opt| {
if (b_opt) |b| {
try std.testing.expect(b.upper >= b.middle);
try std.testing.expect(b.middle >= b.lower);
}
}
}
test "bollingerBands edge cases" {
const alloc = std.testing.allocator;
// Empty data
{
const empty: []const f64 = &.{};
const bands = try bollingerBands(alloc, empty, 5, 2.0);
defer alloc.free(bands);
try std.testing.expectEqual(@as(usize, 0), bands.len);
}
// Data shorter than period
{
const short = [_]f64{ 1, 2, 3 };
const bands = try bollingerBands(alloc, &short, 5, 2.0);
defer alloc.free(bands);
for (bands) |b| try std.testing.expect(b == null);
}
// Period = 1 (each point is its own window, stddev = 0)
{
const data = [_]f64{ 10, 20, 30 };
const bands = try bollingerBands(alloc, &data, 1, 2.0);
defer alloc.free(bands);
// With period=1, stddev=0, so upper=middle=lower
for (bands, 0..) |b_opt, i| {
const b = b_opt.?;
try std.testing.expectApproxEqAbs(data[i], b.middle, 0.001);
try std.testing.expectApproxEqAbs(data[i], b.upper, 0.001);
try std.testing.expectApproxEqAbs(data[i], b.lower, 0.001);
}
}
// Constant data (stddev = 0)
{
const constant = [_]f64{ 50, 50, 50, 50, 50 };
const bands = try bollingerBands(alloc, &constant, 3, 2.0);
defer alloc.free(bands);
for (bands[2..]) |b_opt| {
const b = b_opt.?;
try std.testing.expectApproxEqAbs(@as(f64, 50), b.middle, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 50), b.upper, 0.001);
try std.testing.expectApproxEqAbs(@as(f64, 50), b.lower, 0.001);
}
}
}

View file

@ -4,9 +4,7 @@ const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
/// CLI `analysis` command: show portfolio analysis breakdowns. /// CLI `analysis` command: show portfolio analysis breakdowns.
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {
_ = config;
// Load portfolio // Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch { const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
try cli.stderrPrint("Error: Cannot read portfolio file\n"); try cli.stderrPrint("Error: Cannot read portfolio file\n");
@ -23,11 +21,12 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
const positions = try portfolio.positions(allocator); const positions = try portfolio.positions(allocator);
defer allocator.free(positions); defer allocator.free(positions);
// Build prices map from cache const syms = try portfolio.stockSymbols(allocator);
defer allocator.free(syms);
// Build prices from cache
var prices = std.StringHashMap(f64).init(allocator); var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit(); defer prices.deinit();
// First pass: try cached candle prices
for (positions) |pos| { for (positions) |pos| {
if (pos.shares <= 0) continue; if (pos.shares <= 0) continue;
if (svc.getCachedCandles(pos.symbol)) |cs| { if (svc.getCachedCandles(pos.symbol)) |cs| {
@ -37,15 +36,16 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
} }
} }
} }
// Build fallback prices for symbols without cached candle data
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, &prices);
defer manual_price_set.deinit();
var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices, manual_price_set) catch { // Build summary via shared pipeline
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
try cli.stderrPrint("Error computing portfolio summary.\n"); try cli.stderrPrint("Error computing portfolio summary.\n");
return; return;
},
else => return err,
}; };
defer summary.deinit(allocator); defer pf_data.deinit(allocator);
// Load classification metadata // Load classification metadata
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0; const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0;
@ -78,10 +78,10 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
var result = zfin.analysis.analyzePortfolio( var result = zfin.analysis.analyzePortfolio(
allocator, allocator,
summary.allocations, pf_data.summary.allocations,
cm, cm,
portfolio, portfolio,
summary.total_value, pf_data.summary.total_value,
acct_map_opt, acct_map_opt,
) catch { ) catch {
try cli.stderrPrint("Error computing analysis.\n"); try cli.stderrPrint("Error computing analysis.\n");
@ -101,51 +101,22 @@ pub fn display(result: zfin.analysis.AnalysisResult, file_path: []const u8, colo
try cli.reset(out, color); try cli.reset(out, color);
try out.print("========================================\n\n", .{}); try out.print("========================================\n\n", .{});
// Asset Class const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{
try cli.setBold(out, color); .{ .items = result.asset_class, .title = " Asset Class" },
try cli.setFg(out, color, cli.CLR_HEADER); .{ .items = result.sector, .title = " Sector (Equities)" },
try out.print(" Asset Class\n", .{}); .{ .items = result.geo, .title = " Geographic" },
try cli.reset(out, color); .{ .items = result.account, .title = " By Account" },
try printBreakdownSection(out, result.asset_class, label_width, bar_width, color); .{ .items = result.tax_type, .title = " By Tax Type" },
};
// Sector for (sections, 0..) |sec, si| {
if (result.sector.len > 0) { if (si > 0 and sec.items.len == 0) continue;
try out.print("\n", .{}); if (si > 0) try out.print("\n", .{});
try cli.setBold(out, color); try cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER); try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" Sector (Equities)\n", .{}); try out.print("{s}\n", .{sec.title});
try cli.reset(out, color); try cli.reset(out, color);
try printBreakdownSection(out, result.sector, label_width, bar_width, color); try printBreakdownSection(out, sec.items, label_width, bar_width, color);
}
// Geographic
if (result.geo.len > 0) {
try out.print("\n", .{});
try cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" Geographic\n", .{});
try cli.reset(out, color);
try printBreakdownSection(out, result.geo, label_width, bar_width, color);
}
// By Account
if (result.account.len > 0) {
try out.print("\n", .{});
try cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" By Account\n", .{});
try cli.reset(out, color);
try printBreakdownSection(out, result.account, label_width, bar_width, color);
}
// Tax Type
if (result.tax_type.len > 0) {
try out.print("\n", .{});
try cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" By Tax Type\n", .{});
try cli.reset(out, color);
try printBreakdownSection(out, result.tax_type, label_width, bar_width, color);
} }
// Unclassified // Unclassified

View file

@ -123,6 +123,235 @@ pub const LoadProgress = struct {
} }
}; };
/// Aggregate progress callback for parallel loading operations.
/// Displays a single updating line with progress bar.
pub const AggregateProgress = struct {
color: bool,
last_phase: ?zfin.DataService.AggregateProgressCallback.Phase = null,
fn onProgress(ctx: *anyopaque, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase) void {
const self: *AggregateProgress = @ptrCast(@alignCast(ctx));
// Track phase transitions for newlines
const phase_changed = self.last_phase == null or self.last_phase.? != phase;
self.last_phase = phase;
var buf: [256]u8 = undefined;
var writer = std.fs.File.stderr().writer(&buf);
const out = &writer.interface;
switch (phase) {
.cache_check => {
// Brief phase, no output needed
},
.server_sync => {
// Single updating line with carriage return
if (self.color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
out.print("\r Syncing from server... [{d}/{d}]", .{ completed, total }) catch {};
if (self.color) fmt.ansiReset(out) catch {};
out.flush() catch {};
},
.provider_fetch => {
if (phase_changed) {
// Clear the server sync line and print newline
out.print("\r\x1b[K", .{}) catch {}; // clear line
if (self.color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
out.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed}) catch {};
if (self.color) fmt.ansiReset(out) catch {};
out.flush() catch {};
}
},
.complete => {
// Final newline if we were on server_sync line
if (self.last_phase != null and
(self.last_phase.? == .server_sync or self.last_phase.? == .cache_check))
{
out.print("\r\x1b[K", .{}) catch {}; // clear line
}
},
}
}
pub fn callback(self: *AggregateProgress) zfin.DataService.AggregateProgressCallback {
return .{
.context = @ptrCast(self),
.on_progress = onProgress,
};
}
};
/// Unified price loading for both CLI and TUI.
/// Handles parallel server sync when ZFIN_SERVER is configured,
/// with sequential provider fallback for failures.
pub fn loadPortfolioPrices(
svc: *zfin.DataService,
portfolio_syms: ?[]const []const u8,
watch_syms: []const []const u8,
force_refresh: bool,
color: bool,
) zfin.DataService.LoadAllResult {
var aggregate = AggregateProgress{ .color = color };
var symbol_progress = LoadProgress{
.svc = svc,
.color = color,
.index_offset = 0,
.grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
};
const result = svc.loadAllPrices(
portfolio_syms,
watch_syms,
.{ .force_refresh = force_refresh, .color = color },
aggregate.callback(),
symbol_progress.callback(),
);
// Print summary
const total = symbol_progress.grand_total;
const from_cache = result.cached_count;
const from_server = result.server_synced_count;
const from_provider = result.provider_fetched_count;
const failed = result.failed_count;
const stale = result.stale_count;
var buf: [256]u8 = undefined;
var writer = std.fs.File.stderr().writer(&buf);
const out = &writer.interface;
if (from_cache == total) {
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
out.print(" Loaded {d} symbols from cache\n", .{total}) catch {};
if (color) fmt.ansiReset(out) catch {};
} else if (failed > 0) {
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
if (stale > 0) {
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ total, from_cache, from_server, from_provider, failed, stale }) catch {};
} else {
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ total, from_cache, from_server, from_provider, failed }) catch {};
}
if (color) fmt.ansiReset(out) catch {};
} else {
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
if (from_server > 0 and from_provider > 0) {
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ total, from_cache, from_server, from_provider }) catch {};
} else if (from_server > 0) {
out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ total, from_cache, from_server }) catch {};
} else {
out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ total, from_cache, from_provider }) catch {};
}
if (color) fmt.ansiReset(out) catch {};
}
out.flush() catch {};
return result;
}
// Portfolio data pipeline
/// Result of the shared portfolio data pipeline. Caller must call deinit().
pub const PortfolioData = struct {
summary: zfin.valuation.PortfolioSummary,
candle_map: std.StringHashMap([]const zfin.Candle),
snapshots: ?[6]zfin.valuation.HistoricalSnapshot,
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
self.summary.deinit(allocator);
var it = self.candle_map.valueIterator();
while (it.next()) |v| allocator.free(v.*);
self.candle_map.deinit();
}
};
/// Build portfolio summary, candle map, and historical snapshots from
/// pre-populated prices. Shared between CLI `portfolio` command, TUI
/// `loadPortfolioData`, and TUI `reloadPortfolioFile`.
///
/// Callers are responsible for populating `prices` (via network fetch,
/// cache read, or pre-fetched map) before calling this.
///
/// Returns error.NoAllocations if the summary produces no positions
/// (e.g. no cached prices available).
pub fn buildPortfolioData(
allocator: std.mem.Allocator,
portfolio: zfin.Portfolio,
positions: []const zfin.Position,
syms: []const []const u8,
prices: *std.StringHashMap(f64),
svc: *zfin.DataService,
) !PortfolioData {
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices);
defer manual_price_set.deinit();
var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices.*, manual_price_set) catch
return error.SummaryFailed;
errdefer summary.deinit(allocator);
if (summary.allocations.len == 0) {
summary.deinit(allocator);
return error.NoAllocations;
}
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
errdefer {
var it = candle_map.valueIterator();
while (it.next()) |v| allocator.free(v.*);
candle_map.deinit();
}
for (syms) |sym| {
if (svc.getCachedCandles(sym)) |cs| {
candle_map.put(sym, cs) catch {};
}
}
const snapshots = zfin.valuation.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices.*,
candle_map,
);
return .{
.summary = summary,
.candle_map = candle_map,
.snapshots = snapshots,
};
}
// Watchlist loading
/// Load a watchlist file using the library's SRF deserializer.
/// Returns owned symbol strings. Returns null if file missing or empty.
pub fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
const file_data = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch return null;
defer allocator.free(file_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch return null;
defer portfolio.deinit();
if (portfolio.lots.len == 0) return null;
var syms: std.ArrayList([]const u8) = .empty;
for (portfolio.lots) |lot| {
const duped = allocator.dupe(u8, lot.symbol) catch continue;
syms.append(allocator, duped) catch {
allocator.free(duped);
continue;
};
}
if (syms.items.len == 0) {
syms.deinit(allocator);
return null;
}
return syms.toOwnedSlice(allocator) catch null;
}
pub fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void {
if (watchlist) |wl| {
for (wl) |sym| allocator.free(sym);
allocator.free(wl);
}
}
// Tests // Tests
test "setFg emits ANSI when color enabled" { test "setFg emits ANSI when color enabled" {

View file

@ -16,6 +16,15 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
}; };
defer allocator.free(result.data); defer allocator.free(result.data);
// Sort chronologically (oldest first) providers may return in any order
if (result.data.len > 1) {
std.mem.sort(zfin.EarningsEvent, result.data, {}, struct {
fn f(_: void, a: zfin.EarningsEvent, b: zfin.EarningsEvent) bool {
return a.date.days < b.date.days;
}
}.f);
}
if (result.source == .cached) try cli.stderrPrint("(using cached earnings data)\n"); if (result.source == .cached) try cli.stderrPrint("(using cached earnings data)\n");
try display(result.data, symbol, color, out); try display(result.data, symbol, color, out);
@ -64,32 +73,8 @@ pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bo
try out.print("\n{d} earnings event(s)\n\n", .{events.len}); try out.print("\n{d} earnings event(s)\n\n", .{events.len});
} }
pub fn fmtEps(val: f64) [12]u8 {
var buf: [12]u8 = .{' '} ** 12;
_ = std.fmt.bufPrint(&buf, "${d:.2}", .{val}) catch {};
return buf;
}
// Tests // Tests
test "fmtEps formats positive value" {
const result = fmtEps(1.25);
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
try std.testing.expectEqualStrings("$1.25", trimmed);
}
test "fmtEps formats negative value" {
const result = fmtEps(-0.50);
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
try std.testing.expect(std.mem.indexOf(u8, trimmed, "0.5") != null);
}
test "fmtEps formats zero" {
const result = fmtEps(0.0);
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
try std.testing.expectEqualStrings("$0.00", trimmed);
}
test "display shows earnings with beat" { test "display shows earnings with beat" {
var buf: [4096]u8 = undefined; var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);

View file

@ -3,6 +3,36 @@ const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const isCusipLike = @import("../models/portfolio.zig").isCusipLike; const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
const OverviewMeta = struct {
sector: []const u8,
geo: []const u8,
asset_class: []const u8,
};
/// Derive sector, geo, and asset_class from an Alpha Vantage company overview.
fn deriveMetadata(overview: zfin.CompanyOverview, sector_buf: []u8) OverviewMeta {
const sector_raw = overview.sector orelse "Unknown";
const sector_str = cli.fmt.toTitleCase(sector_buf, sector_raw);
const country_str = overview.country orelse "US";
const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str;
const asset_class_str = blk: {
if (overview.asset_type) |at| {
if (std.mem.eql(u8, at, "ETF")) break :blk "ETF";
if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund";
}
if (overview.market_cap) |mc_str| {
const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0;
if (mc >= 10_000_000_000) break :blk "US Large Cap";
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
break :blk "US Small Cap";
}
break :blk "US Large Cap";
};
return .{ .sector = sector_str, .geo = geo_str, .asset_class = asset_class_str };
}
/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data. /// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data.
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each, /// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,
/// and outputs a metadata SRF file to stdout. /// and outputs a metadata SRF file to stdout.
@ -50,31 +80,14 @@ fn enrichSymbol(allocator: std.mem.Allocator, svc: *zfin.DataService, sym: []con
if (overview.asset_type) |at| allocator.free(at); if (overview.asset_type) |at| allocator.free(at);
} }
const sector_raw = overview.sector orelse "Unknown";
var sector_buf: [64]u8 = undefined; var sector_buf: [64]u8 = undefined;
const sector_str = cli.fmt.toTitleCase(&sector_buf, sector_raw); const meta = deriveMetadata(overview, &sector_buf);
const country_str = overview.country orelse "US";
const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str;
const asset_class_str = blk: {
if (overview.asset_type) |at| {
if (std.mem.eql(u8, at, "ETF")) break :blk "ETF";
if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund";
}
if (overview.market_cap) |mc_str| {
const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0;
if (mc >= 10_000_000_000) break :blk "US Large Cap";
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
break :blk "US Small Cap";
}
break :blk "US Large Cap";
};
if (overview.name) |name| { if (overview.name) |name| {
try out.print("# {s}\n", .{name}); try out.print("# {s}\n", .{name});
} }
try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n", .{ try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n", .{
sym, sector_str, geo_str, asset_class_str, sym, meta.sector, meta.geo, meta.asset_class,
}); });
} }
@ -159,34 +172,15 @@ fn enrichPortfolio(allocator: std.mem.Allocator, svc: *zfin.DataService, file_pa
if (overview.asset_type) |at| allocator.free(at); if (overview.asset_type) |at| allocator.free(at);
} }
const sector_raw = overview.sector orelse "Unknown";
var sector_buf: [64]u8 = undefined; var sector_buf: [64]u8 = undefined;
const sector_str = cli.fmt.toTitleCase(&sector_buf, sector_raw); const meta = deriveMetadata(overview, &sector_buf);
const country_str = overview.country orelse "US";
const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str;
// Determine asset_class from asset type + market cap
const asset_class_str = blk: {
if (overview.asset_type) |at| {
if (std.mem.eql(u8, at, "ETF")) break :blk "ETF";
if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund";
}
// For common stocks, infer from market cap
if (overview.market_cap) |mc_str| {
const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0;
if (mc >= 10_000_000_000) break :blk "US Large Cap";
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
break :blk "US Small Cap";
}
break :blk "US Large Cap";
};
// Comment with the name for readability // Comment with the name for readability
if (overview.name) |name| { if (overview.name) |name| {
try out.print("# {s}\n", .{name}); try out.print("# {s}\n", .{name});
} }
try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n\n", .{ try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n\n", .{
sym, sector_str, geo_str, asset_class_str, sym, meta.sector, meta.geo, meta.asset_class,
}); });
success += 1; success += 1;
} }

View file

@ -6,7 +6,7 @@ const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getCandles(symbol) catch |err| switch (err) { const result = svc.getCandles(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set.\n"); try cli.stderrPrint("Error: No API key configured for candle data.\n");
return; return;
}, },
else => { else => {
@ -46,7 +46,7 @@ pub fn display(candles: []const zfin.Candle, symbol: []const u8, color: bool, ou
for (candles) |candle| { for (candles) |candle| {
var db: [10]u8 = undefined; var db: [10]u8 = undefined;
var vb: [32]u8 = undefined; var vb: [32]u8 = undefined;
try cli.setGainLoss(out, color, if (candle.close >= candle.open) @as(f64, 1) else @as(f64, -1)); try cli.setGainLoss(out, color, if (candle.close >= candle.open) 1.0 else -1.0);
try out.print("{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{ try out.print("{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume), candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
}); });

View file

@ -132,8 +132,6 @@ pub fn printSection(
test "printSection shows header and contracts" { test "printSection shows header and contracts" {
var buf: [8192]u8 = undefined; var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const calls = [_]zfin.OptionContract{ const calls = [_]zfin.OptionContract{
.{ .contract_type = .call, .strike = 150.0, .expiration = .{ .days = 20100 }, .bid = 5.0, .ask = 5.50, .last_price = 5.25 }, .{ .contract_type = .call, .strike = 150.0, .expiration = .{ .days = 20100 }, .bid = 5.0, .ask = 5.50, .last_price = 5.25 },
.{ .contract_type = .call, .strike = 155.0, .expiration = .{ .days = 20100 }, .bid = 2.0, .ask = 2.50, .last_price = 2.25 }, .{ .contract_type = .call, .strike = 155.0, .expiration = .{ .days = 20100 }, .bid = 2.0, .ask = 2.50, .last_price = 2.25 },
@ -148,8 +146,6 @@ test "printSection shows header and contracts" {
test "display shows chain header no color" { test "display shows chain header no color" {
var buf: [8192]u8 = undefined; var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const calls = [_]zfin.OptionContract{}; const calls = [_]zfin.OptionContract{};
const puts = [_]zfin.OptionContract{}; const puts = [_]zfin.OptionContract{};
const chains = [_]zfin.OptionsChain{ const chains = [_]zfin.OptionsChain{

View file

@ -124,7 +124,7 @@ pub fn printReturnsTable(
); );
if (price_arr[i] != null) { if (price_arr[i] != null) {
try cli.setGainLoss(out, color, if (row.price_positive) @as(f64, 1) else @as(f64, -1)); try cli.setGainLoss(out, color, if (row.price_positive) 1.0 else -1.0);
} else { } else {
try cli.setFg(out, color, cli.CLR_MUTED); try cli.setFg(out, color, cli.CLR_MUTED);
} }
@ -133,7 +133,7 @@ pub fn printReturnsTable(
if (has_total) { if (has_total) {
if (row.total_str) |ts| { if (row.total_str) |ts| {
try cli.setGainLoss(out, color, if (row.price_positive) @as(f64, 1) else @as(f64, -1)); try cli.setGainLoss(out, color, if (row.price_positive) 1.0 else -1.0);
try out.print(" {s:>13}", .{ts}); try out.print(" {s:>13}", .{ts});
try cli.reset(out, color); try cli.reset(out, color);
} else { } else {

View file

@ -3,7 +3,7 @@ const zfin = @import("../root.zig");
const cli = @import("common.zig"); const cli = @import("common.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void { pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {
// Load portfolio from SRF file // Load portfolio from SRF file
const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| { const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| {
try cli.stderrPrint("Error reading portfolio file: "); try cli.stderrPrint("Error reading portfolio file: ");
@ -56,86 +56,41 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
const all_syms_count = syms.len + watch_syms.items.len; const all_syms_count = syms.len + watch_syms.items.len;
if (all_syms_count > 0) { if (all_syms_count > 0) {
if (config.twelvedata_key == null) { // Use consolidated parallel loader
try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n"); var load_result = cli.loadPortfolioPrices(
svc,
syms,
watch_syms.items,
force_refresh,
color,
);
defer load_result.deinit(); // Free the prices hashmap after we copy
// Transfer prices to our local map
var it = load_result.prices.iterator();
while (it.next()) |entry| {
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
}
fail_count = load_result.failed_count;
} }
// Progress callback for per-symbol output // Build portfolio summary, candle map, and historical snapshots
var progress_ctx = cli.LoadProgress{ var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc) catch |err| switch (err) {
.svc = svc, error.NoAllocations, error.SummaryFailed => {
.color = color,
.index_offset = 0,
.grand_total = all_syms_count,
};
// Load prices for stock/ETF positions
const load_result = svc.loadPrices(syms, &prices, force_refresh, progress_ctx.callback());
fail_count = load_result.fail_count;
// Fetch watch symbol candles (for watchlist display, not portfolio value)
progress_ctx.index_offset = syms.len;
_ = svc.loadPrices(watch_syms.items, &prices, force_refresh, progress_ctx.callback());
// Summary line
{
const cached_count = load_result.cached_count;
const fetched_count = load_result.fetched_count;
var msg_buf: [256]u8 = undefined;
if (cached_count == all_syms_count) {
const msg = std.fmt.bufPrint(&msg_buf, "All {d} symbols loaded from cache\n", .{all_syms_count}) catch "Loaded from cache\n";
try cli.stderrPrint(msg);
} else if (fail_count > 0) {
const stale = load_result.stale_count;
if (stale > 0) {
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed — {d} using stale cache)\n", .{ all_syms_count, cached_count, fetched_count, fail_count, stale }) catch "Done loading\n";
try cli.stderrPrint(msg);
} else {
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n";
try cli.stderrPrint(msg);
}
} else {
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ all_syms_count, cached_count, fetched_count }) catch "Done loading\n";
try cli.stderrPrint(msg);
}
}
}
// Compute summary
// Build fallback prices for symbols that failed API fetch
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, &prices);
defer manual_price_set.deinit();
var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices, manual_price_set) catch {
try cli.stderrPrint("Error computing portfolio summary.\n"); try cli.stderrPrint("Error computing portfolio summary.\n");
return; return;
},
else => return err,
}; };
defer summary.deinit(allocator); defer pf_data.deinit(allocator);
// Sort allocations alphabetically by symbol // Sort allocations alphabetically by symbol
std.mem.sort(zfin.valuation.Allocation, summary.allocations, {}, struct { std.mem.sort(zfin.valuation.Allocation, pf_data.summary.allocations, {}, struct {
fn f(_: void, a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool { fn f(_: void, a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool {
return std.mem.lessThan(u8, a.display_symbol, b.display_symbol); return std.mem.lessThan(u8, a.display_symbol, b.display_symbol);
} }
}.f); }.f);
// Build candle map once for historical snapshots and risk metrics.
// This avoids parsing the full candle history multiple times.
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
defer {
var it = candle_map.valueIterator();
while (it.next()) |v| allocator.free(v.*);
candle_map.deinit();
}
{
const stock_syms = try portfolio.stockSymbols(allocator);
defer allocator.free(stock_syms);
for (stock_syms) |sym| {
if (svc.getCachedCandles(sym)) |cs| {
try candle_map.put(sym, cs);
}
}
}
// Collect watch symbols and their prices for display. // Collect watch symbols and their prices for display.
// Includes watch lots from portfolio + symbols from separate watchlist file. // Includes watch lots from portfolio + symbols from separate watchlist file.
var watch_list: std.ArrayList([]const u8) = .empty; var watch_list: std.ArrayList([]const u8) = .empty;
@ -146,7 +101,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
var watch_seen = std.StringHashMap(void).init(allocator); var watch_seen = std.StringHashMap(void).init(allocator);
defer watch_seen.deinit(); defer watch_seen.deinit();
// Exclude portfolio position symbols from watchlist // Exclude portfolio position symbols from watchlist
for (summary.allocations) |a| { for (pf_data.summary.allocations) |a| {
try watch_seen.put(a.symbol, {}); try watch_seen.put(a.symbol, {});
} }
@ -165,18 +120,10 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
// Separate watchlist file (backward compat) // Separate watchlist file (backward compat)
if (watchlist_path) |wl_path| { if (watchlist_path) |wl_path| {
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null; const wl_syms = cli.loadWatchlist(allocator, wl_path);
if (wl_data) |wd| { defer cli.freeWatchlist(allocator, wl_syms);
defer allocator.free(wd); if (wl_syms) |syms_list| {
var wl_lines = std.mem.splitScalar(u8, wd, '\n'); for (syms_list) |sym| {
while (wl_lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0 or trimmed[0] == '#') continue;
if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| {
const rest = trimmed[idx + "symbol::".len ..];
const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len;
const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace);
if (sym.len > 0 and sym.len <= 10) {
if (watch_seen.contains(sym)) continue; if (watch_seen.contains(sym)) continue;
try watch_seen.put(sym, {}); try watch_seen.put(sym, {});
try watch_list.append(allocator, sym); try watch_list.append(allocator, sym);
@ -187,8 +134,6 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
} }
} }
} }
}
}
try display( try display(
allocator, allocator,
@ -197,9 +142,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
file_path, file_path,
&portfolio, &portfolio,
positions, positions,
&summary, &pf_data,
prices,
candle_map,
watch_list.items, watch_list.items,
watch_prices, watch_prices,
); );
@ -213,12 +156,11 @@ pub fn display(
file_path: []const u8, file_path: []const u8,
portfolio: *const zfin.Portfolio, portfolio: *const zfin.Portfolio,
positions: []const zfin.Position, positions: []const zfin.Position,
summary: *const zfin.valuation.PortfolioSummary, pf_data: *const cli.PortfolioData,
prices: std.StringHashMap(f64),
candle_map: std.StringHashMap([]const zfin.Candle),
watch_symbols: []const []const u8, watch_symbols: []const []const u8,
watch_prices: std.StringHashMap(f64), watch_prices: std.StringHashMap(f64),
) !void { ) !void {
const summary = &pf_data.summary;
// Header with summary // Header with summary
try cli.setBold(out, color); try cli.setBold(out, color);
try out.print("\nPortfolio Summary ({s})\n", .{file_path}); try out.print("\nPortfolio Summary ({s})\n", .{file_path});
@ -233,11 +175,11 @@ pub fn display(
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss; const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoneyAbs(&val_buf, summary.total_value), fmt.fmtMoneyAbs(&cost_buf, summary.total_cost) }); try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoneyAbs(&val_buf, summary.total_value), fmt.fmtMoneyAbs(&cost_buf, summary.total_cost) });
try cli.setGainLoss(out, color, summary.unrealized_gain_loss); try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
if (summary.unrealized_gain_loss >= 0) { try out.print("Gain/Loss: {c}{s} ({d:.1}%)", .{
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 }); @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
} else { fmt.fmtMoneyAbs(&gl_buf, gl_abs),
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 }); summary.unrealized_return * 100.0,
} });
try cli.reset(out, color); try cli.reset(out, color);
try out.print("\n", .{}); try out.print("\n", .{});
} }
@ -255,13 +197,7 @@ pub fn display(
// Historical portfolio value snapshots // Historical portfolio value snapshots
{ {
if (candle_map.count() > 0) { if (pf_data.snapshots) |snapshots| {
const snapshots = zfin.valuation.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices,
candle_map,
);
try out.print(" Historical: ", .{}); try out.print(" Historical: ", .{});
try cli.setFg(out, color, cli.CLR_MUTED); try cli.setFg(out, color, cli.CLR_MUTED);
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| { for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
@ -378,31 +314,15 @@ pub fn display(
const drip = fmt.aggregateDripLots(lots_for_sym.items); const drip = fmt.aggregateDripLots(lots_for_sym.items);
if (!drip.st.isEmpty()) { if (!drip.st.isEmpty()) {
var avg_buf: [24]u8 = undefined; var drip_buf: [128]u8 = undefined;
var d1_buf: [10]u8 = undefined;
var d2_buf: [10]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED); try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{ try out.print(" {s}\n", .{fmt.fmtDripSummary(&drip_buf, "ST", drip.st)});
drip.st.lot_count,
drip.st.shares,
fmt.fmtMoneyAbs(&avg_buf, drip.st.avgCost()),
if (drip.st.first_date) |d| d.format(&d1_buf)[0..7] else "?",
if (drip.st.last_date) |d| d.format(&d2_buf)[0..7] else "?",
});
try cli.reset(out, color); try cli.reset(out, color);
} }
if (!drip.lt.isEmpty()) { if (!drip.lt.isEmpty()) {
var avg_buf2: [24]u8 = undefined; var drip_buf2: [128]u8 = undefined;
var d1_buf2: [10]u8 = undefined;
var d2_buf2: [10]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED); try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{ try out.print(" {s}\n", .{fmt.fmtDripSummary(&drip_buf2, "LT", drip.lt)});
drip.lt.lot_count,
drip.lt.shares,
fmt.fmtMoneyAbs(&avg_buf2, drip.lt.avgCost()),
if (drip.lt.first_date) |d| d.format(&d1_buf2)[0..7] else "?",
if (drip.lt.last_date) |d| d.format(&d2_buf2)[0..7] else "?",
});
try cli.reset(out, color); try cli.reset(out, color);
} }
} }
@ -423,11 +343,10 @@ pub fn display(
"", "", "", "TOTAL", fmt.fmtMoneyAbs(&total_mv_buf, summary.total_value), "", "", "", "TOTAL", fmt.fmtMoneyAbs(&total_mv_buf, summary.total_value),
}); });
try cli.setGainLoss(out, color, summary.unrealized_gain_loss); try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
if (summary.unrealized_gain_loss >= 0) { try out.print("{c}{s:>13}", .{
try out.print("+{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)}); @as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
} else { fmt.fmtMoneyAbs(&total_gl_buf, gl_abs),
try out.print("-{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)}); });
}
try cli.reset(out, color); try cli.reset(out, color);
try out.print(" {s:>7}\n", .{"100.0%"}); try out.print(" {s:>7}\n", .{"100.0%"});
} }
@ -436,11 +355,10 @@ pub fn display(
var rpl_buf: [24]u8 = undefined; var rpl_buf: [24]u8 = undefined;
const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss; const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss;
try cli.setGainLoss(out, color, summary.realized_gain_loss); try cli.setGainLoss(out, color, summary.realized_gain_loss);
if (summary.realized_gain_loss >= 0) { try out.print("\n Realized P&L: {c}{s}\n", .{
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)}); @as(u8, if (summary.realized_gain_loss >= 0) '+' else '-'),
} else { fmt.fmtMoneyAbs(&rpl_buf, rpl_abs),
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)}); });
}
try cli.reset(out, color); try cli.reset(out, color);
} }
@ -640,7 +558,7 @@ pub fn display(
var any_risk = false; var any_risk = false;
for (summary.allocations) |a| { for (summary.allocations) |a| {
if (candle_map.get(a.symbol)) |candles| { if (pf_data.candle_map.get(a.symbol)) |candles| {
const tr = zfin.risk.trailingRisk(candles); const tr = zfin.risk.trailingRisk(candles);
if (tr.three_year) |metrics| { if (tr.three_year) |metrics| {
if (!any_risk) { if (!any_risk) {
@ -737,6 +655,14 @@ fn testSummary(allocations: []zfin.valuation.Allocation) zfin.valuation.Portfoli
}; };
} }
fn testPortfolioData(summary: zfin.valuation.PortfolioSummary, candle_map: std.StringHashMap([]const zfin.Candle)) cli.PortfolioData {
return .{
.summary = summary,
.candle_map = candle_map,
.snapshots = null,
};
}
test "display shows header and summary" { test "display shows header and summary" {
var buf: [8192]u8 = undefined; var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
@ -756,7 +682,7 @@ test "display shows header and summary" {
.{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .current_price = 175.0, .market_value = 1750.0, .cost_basis = 1500.0, .weight = 0.745, .unrealized_gain_loss = 250.0, .unrealized_return = 0.167 }, .{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .current_price = 175.0, .market_value = 1750.0, .cost_basis = 1500.0, .weight = 0.745, .unrealized_gain_loss = 250.0, .unrealized_return = 0.167 },
.{ .symbol = "GOOG", .display_symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .current_price = 140.0, .market_value = 700.0, .cost_basis = 600.0, .weight = 0.255, .unrealized_gain_loss = 100.0, .unrealized_return = 0.167 }, .{ .symbol = "GOOG", .display_symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .current_price = 140.0, .market_value = 700.0, .cost_basis = 600.0, .weight = 0.255, .unrealized_gain_loss = 100.0, .unrealized_return = 0.167 },
}; };
var summary = testSummary(&allocs); const summary = testSummary(&allocs);
var prices = std.StringHashMap(f64).init(testing.allocator); var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit(); defer prices.deinit();
@ -765,13 +691,14 @@ test "display shows header and summary" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit(); defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator); var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit(); defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{}; const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices); try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
const out = w.buffered(); const out = w.buffered();
// Header present // Header present
@ -806,7 +733,7 @@ test "display with watchlist" {
var allocs = [_]zfin.valuation.Allocation{ var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 20, .avg_cost = 200.0, .current_price = 220.0, .market_value = 4400.0, .cost_basis = 4000.0, .weight = 1.0, .unrealized_gain_loss = 400.0, .unrealized_return = 0.1 }, .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 20, .avg_cost = 200.0, .current_price = 220.0, .market_value = 4400.0, .cost_basis = 4000.0, .weight = 1.0, .unrealized_gain_loss = 400.0, .unrealized_return = 0.1 },
}; };
var summary = testSummary(&allocs); const summary = testSummary(&allocs);
var prices = std.StringHashMap(f64).init(testing.allocator); var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit(); defer prices.deinit();
@ -814,6 +741,7 @@ test "display with watchlist" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit(); defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
// Watchlist with prices // Watchlist with prices
const watch_syms: []const []const u8 = &.{ "TSLA", "NVDA" }; const watch_syms: []const []const u8 = &.{ "TSLA", "NVDA" };
@ -822,7 +750,7 @@ test "display with watchlist" {
try watch_prices.put("TSLA", 250.50); try watch_prices.put("TSLA", 250.50);
try watch_prices.put("NVDA", 800.25); try watch_prices.put("NVDA", 800.25);
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices); try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
const out = w.buffered(); const out = w.buffered();
// Watchlist header and symbols // Watchlist header and symbols
@ -859,11 +787,12 @@ test "display with options section" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit(); defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator); var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit(); defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{}; const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices); try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
const out = w.buffered(); const out = w.buffered();
// Options section present // Options section present
@ -900,11 +829,12 @@ test "display with CDs and cash" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit(); defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator); var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit(); defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{}; const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices); try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
const out = w.buffered(); const out = w.buffered();
// CDs section present // CDs section present
@ -943,11 +873,12 @@ test "display realized PnL shown when nonzero" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit(); defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator); var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit(); defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{}; const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices); try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
const out = w.buffered(); const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Realized P&L") != null); try testing.expect(std.mem.indexOf(u8, out, "Realized P&L") != null);
@ -969,7 +900,7 @@ test "display empty watchlist not shown" {
var allocs = [_]zfin.valuation.Allocation{ var allocs = [_]zfin.valuation.Allocation{
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 }, .{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
}; };
var summary = testSummary(&allocs); const summary = testSummary(&allocs);
var prices = std.StringHashMap(f64).init(testing.allocator); var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit(); defer prices.deinit();
@ -977,11 +908,12 @@ test "display empty watchlist not shown" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit(); defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator); var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit(); defer watch_prices.deinit();
const watch_syms: []const []const u8 = &.{}; const watch_syms: []const []const u8 = &.{};
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices); try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
const out = w.buffered(); const out = w.buffered();
// Watchlist header should NOT appear when there are no watch symbols // Watchlist header should NOT appear when there are no watch symbols

View file

@ -18,7 +18,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
// Fetch candle data for chart and history // Fetch candle data for chart and history
const candle_result = svc.getCandles(symbol) catch |err| switch (err) { const candle_result = svc.getCandles(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => { zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set.\n"); try cli.stderrPrint("Error: No API key configured for candle data.\n");
return; return;
}, },
else => { else => {
@ -120,7 +120,7 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
for (candles[start_idx..]) |candle| { for (candles[start_idx..]) |candle| {
var row_buf: [128]u8 = undefined; var row_buf: [128]u8 = undefined;
const day_gain = candle.close >= candle.open; const day_gain = candle.close >= candle.open;
try cli.setGainLoss(out, color, if (day_gain) @as(f64, 1) else @as(f64, -1)); try cli.setGainLoss(out, color, if (day_gain) 1.0 else -1.0);
try out.print("{s}\n", .{fmt.fmtCandleRow(&row_buf, candle)}); try out.print("{s}\n", .{fmt.fmtCandleRow(&row_buf, candle)});
try cli.reset(out, color); try cli.reset(out, color);
} }

View file

@ -12,11 +12,14 @@ pub const Config = struct {
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org") /// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
server_url: ?[]const u8 = null, server_url: ?[]const u8 = null,
cache_dir: []const u8, cache_dir: []const u8,
cache_dir_owned: bool = false, // true when cache_dir was allocated via path.join
allocator: ?std.mem.Allocator = null, allocator: ?std.mem.Allocator = null,
/// Raw .env file contents (keys/values in env_map point into this). /// Raw .env file contents (keys/values in env_map point into this).
env_buf: ?[]const u8 = null, env_buf: ?[]const u8 = null,
/// Parsed KEY=VALUE pairs from .env file. /// Parsed KEY=VALUE pairs from .env file.
env_map: ?EnvMap = null, env_map: ?EnvMap = null,
/// Strings allocated by resolve() from process environment variables.
env_owned: std.ArrayList([]const u8) = .empty,
pub fn fromEnv(allocator: std.mem.Allocator) Config { pub fn fromEnv(allocator: std.mem.Allocator) Config {
var self = Config{ var self = Config{
@ -40,12 +43,14 @@ pub const Config = struct {
const env_cache = self.resolve("ZFIN_CACHE_DIR"); const env_cache = self.resolve("ZFIN_CACHE_DIR");
self.cache_dir = env_cache orelse blk: { self.cache_dir = env_cache orelse blk: {
self.cache_dir_owned = true;
// XDG Base Directory: $XDG_CACHE_HOME/zfin, falling back to $HOME/.cache/zfin // XDG Base Directory: $XDG_CACHE_HOME/zfin, falling back to $HOME/.cache/zfin
const base = std.posix.getenv("XDG_CACHE_HOME") orelse fallback: { const xdg = self.resolve("XDG_CACHE_HOME");
const home = std.posix.getenv("HOME") orelse "/tmp"; const base = xdg orelse fallback: {
const home = self.resolve("HOME") orelse "/tmp";
break :fallback std.fs.path.join(allocator, &.{ home, ".cache" }) catch @panic("OOM"); break :fallback std.fs.path.join(allocator, &.{ home, ".cache" }) catch @panic("OOM");
}; };
const base_allocated = std.posix.getenv("XDG_CACHE_HOME") == null; const base_allocated = xdg == null;
defer if (base_allocated) allocator.free(base); defer if (base_allocated) allocator.free(base);
break :blk std.fs.path.join(allocator, &.{ base, "zfin" }) catch @panic("OOM"); break :blk std.fs.path.join(allocator, &.{ base, "zfin" }) catch @panic("OOM");
}; };
@ -55,15 +60,14 @@ pub const Config = struct {
pub fn deinit(self: *Config) void { pub fn deinit(self: *Config) void {
if (self.allocator) |a| { if (self.allocator) |a| {
// cache_dir is allocated (via path.join) unless ZFIN_CACHE_DIR was set directly.
// Check BEFORE freeing env_map/env_buf, since resolve() reads from them.
const cache_dir_from_env = self.resolve("ZFIN_CACHE_DIR") != null;
if (self.env_map) |*m| { if (self.env_map) |*m| {
var map = m.*; var map = m.*;
map.deinit(); map.deinit();
} }
if (self.env_buf) |buf| a.free(buf); if (self.env_buf) |buf| a.free(buf);
if (!cache_dir_from_env) { for (self.env_owned.items) |s| a.free(s);
self.env_owned.deinit(a);
if (self.cache_dir_owned) {
a.free(self.cache_dir); a.free(self.cache_dir);
} }
} }
@ -77,9 +81,14 @@ pub const Config = struct {
self.tiingo_key != null; self.tiingo_key != null;
} }
/// Look up a key: environment variable first, then .env file fallback. /// Look up a key: process environment first, then .env file fallback.
fn resolve(self: Config, key: []const u8) ?[]const u8 { fn resolve(self: *Config, key: []const u8) ?[]const u8 {
if (std.posix.getenv(key)) |v| return v; if (self.allocator) |a| {
if (std.process.getEnvVarOwned(a, key)) |v| {
self.env_owned.append(a, v) catch {};
return v;
} else |_| {}
}
if (self.env_map) |m| return m.get(key); if (self.env_map) |m| return m.get(key);
return null; return null;
} }

View file

@ -427,6 +427,23 @@ pub fn aggregateDripLots(lots: []const Lot) DripAggregation {
return result; return result;
} }
/// Format a DRIP summary line: "ST: 12 DRIP lots, 3.5 shares, avg $45.67 (2023-01 to 2024-06)"
pub fn fmtDripSummary(buf: []u8, label: []const u8, summary: DripSummary) []const u8 {
var avg_buf: [24]u8 = undefined;
var d1_buf: [10]u8 = undefined;
var d2_buf: [10]u8 = undefined;
const d1: []const u8 = if (summary.first_date) |d| d.format(&d1_buf)[0..7] else "?";
const d2: []const u8 = if (summary.last_date) |d| d.format(&d2_buf)[0..7] else "?";
return std.fmt.bufPrint(buf, "{s}: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})", .{
label,
summary.lot_count,
summary.shares,
fmtMoneyAbs(&avg_buf, summary.avgCost()),
d1,
d2,
}) catch "?";
}
// Shared rendering helpers (CLI + TUI) // Shared rendering helpers (CLI + TUI)
/// Layout constants for analysis breakdown views. /// Layout constants for analysis breakdown views.
@ -863,11 +880,11 @@ pub fn writeBrailleAnsi(
// ANSI color helpers (for CLI) // ANSI color helpers (for CLI)
/// Determine whether to use ANSI color output. /// Determine whether to use ANSI color output.
/// Uses std.Io.tty.Config.detect which handles TTY detection, NO_COLOR,
/// CLICOLOR_FORCE, and Windows console API cross-platform.
pub fn shouldUseColor(no_color_flag: bool) bool { pub fn shouldUseColor(no_color_flag: bool) bool {
if (no_color_flag) return false; if (no_color_flag) return false;
if (std.posix.getenv("NO_COLOR")) |_| return false; return std.Io.tty.Config.detect(std.fs.File.stdout()) != .no_color;
// Check if stdout is a TTY
return std.posix.isatty(std.fs.File.stdout().handle);
} }
/// Write an ANSI 24-bit foreground color escape. /// Write an ANSI 24-bit foreground color escape.

View file

@ -183,7 +183,7 @@ pub fn main() !u8 {
file_path = args[pi]; file_path = args[pi];
} }
} }
try commands.portfolio.run(allocator, config, &svc, file_path, watchlist_path, force_refresh, color, out); try commands.portfolio.run(allocator, &svc, file_path, watchlist_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) { } else if (std.mem.eql(u8, command, "lookup")) {
if (args.len < 3) { if (args.len < 3) {
try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n"); try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
@ -211,7 +211,7 @@ pub fn main() !u8 {
break; break;
} }
} }
try commands.analysis.run(allocator, config, &svc, analysis_file, color, out); try commands.analysis.run(allocator, &svc, analysis_file, color, out);
} else { } else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n"); try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
return 1; return 1;

View file

@ -48,7 +48,7 @@ pub const Client = struct {
return self.request(.POST, url, body, extra_headers); return self.request(.POST, url, body, extra_headers);
} }
fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response { pub fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
var attempt: u8 = 0; var attempt: u8 = 0;
while (true) : (attempt += 1) { while (true) : (attempt += 1) {
const response = self.doRequest(method, url, body, extra_headers) catch { const response = self.doRequest(method, url, body, extra_headers) catch {

View file

@ -47,7 +47,9 @@ pub const Cboe = struct {
const url = try buildCboeUrl(allocator, symbol); const url = try buildCboeUrl(allocator, symbol);
defer allocator.free(url); defer allocator.free(url);
var response = try self.client.get(url); // Request with Accept-Encoding: identity to avoid Zig 0.15 stdlib deflate panic
// on malformed compressed responses from CBOE's CDN.
var response = try self.client.request(.GET, url, null, &.{.{ .name = "Accept-Encoding", .value = "identity" }});
defer response.deinit(); defer response.deinit();
return parseResponse(allocator, response.body, symbol); return parseResponse(allocator, response.body, symbol);

View file

@ -748,6 +748,356 @@ pub const DataService = struct {
return result; return result;
} }
// Consolidated Price Loading (Parallel Server + Sequential Provider)
/// Configuration for loadAllPrices.
pub const LoadAllConfig = struct {
force_refresh: bool = false,
color: bool = true,
/// Maximum concurrent server sync requests. 0 = auto (8).
max_concurrent: usize = 0,
};
/// Result of loadAllPrices operation.
pub const LoadAllResult = struct {
prices: std.StringHashMap(f64),
/// Number of symbols resolved from fresh local cache.
cached_count: usize,
/// Number of symbols synced from server.
server_synced_count: usize,
/// Number of symbols fetched from providers (rate-limited APIs).
provider_fetched_count: usize,
/// Number of symbols that failed all sources but used stale cache.
stale_count: usize,
/// Number of symbols that failed completely (no data).
failed_count: usize,
/// Latest candle date seen.
latest_date: ?Date,
/// Free the prices hashmap. Call this if you don't transfer ownership.
pub fn deinit(self: *LoadAllResult) void {
self.prices.deinit();
}
};
/// Progress callback for aggregate (parallel) progress reporting.
/// Called periodically during parallel operations with current counts.
pub const AggregateProgressCallback = struct {
context: *anyopaque,
on_progress: *const fn (ctx: *anyopaque, completed: usize, total: usize, phase: Phase) void,
pub const Phase = enum {
/// Checking local cache
cache_check,
/// Syncing from ZFIN_SERVER
server_sync,
/// Fetching from rate-limited providers
provider_fetch,
/// Done
complete,
};
fn emit(self: AggregateProgressCallback, completed: usize, total: usize, phase: Phase) void {
self.on_progress(self.context, completed, total, phase);
}
};
/// Thread-safe counter for parallel progress tracking.
const AtomicCounter = struct {
value: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
fn increment(self: *AtomicCounter) usize {
return self.value.fetchAdd(1, .monotonic);
}
fn load(self: *const AtomicCounter) usize {
return self.value.load(.monotonic);
}
};
/// Per-symbol result from parallel server sync.
const ServerSyncResult = struct {
symbol: []const u8,
success: bool,
};
/// Load prices for portfolio and watchlist symbols with automatic parallelization.
///
/// When ZFIN_SERVER is configured:
/// 1. Check local cache (fast, parallel-safe)
/// 2. Parallel sync from server for cache misses
/// 3. Sequential provider fallback for server failures
///
/// When ZFIN_SERVER is not configured:
/// Falls back to sequential loading with per-symbol progress.
///
/// Progress is reported via `aggregate_progress` for parallel phases
/// and `symbol_progress` for sequential provider fallback.
pub fn loadAllPrices(
self: *DataService,
portfolio_syms: ?[]const []const u8,
watch_syms: []const []const u8,
config: LoadAllConfig,
aggregate_progress: ?AggregateProgressCallback,
symbol_progress: ?ProgressCallback,
) LoadAllResult {
var result = LoadAllResult{
.prices = std.StringHashMap(f64).init(self.allocator),
.cached_count = 0,
.server_synced_count = 0,
.provider_fetched_count = 0,
.stale_count = 0,
.failed_count = 0,
.latest_date = null,
};
// Combine all symbols
const portfolio_count = if (portfolio_syms) |ps| ps.len else 0;
const watch_count = watch_syms.len;
const total_count = portfolio_count + watch_count;
if (total_count == 0) return result;
// Build combined symbol list
var all_symbols = std.ArrayList([]const u8).initCapacity(self.allocator, total_count) catch return result;
defer all_symbols.deinit(self.allocator);
if (portfolio_syms) |ps| {
for (ps) |sym| all_symbols.append(self.allocator, sym) catch {};
}
for (watch_syms) |sym| all_symbols.append(self.allocator, sym) catch {};
// Invalidate cache if force refresh
if (config.force_refresh) {
for (all_symbols.items) |sym| {
self.invalidate(sym, .candles_daily);
}
}
// Phase 1: Check local cache (fast path)
var needs_fetch: std.ArrayList([]const u8) = .empty;
defer needs_fetch.deinit(self.allocator);
if (aggregate_progress) |p| p.emit(0, total_count, .cache_check);
for (all_symbols.items) |sym| {
if (!config.force_refresh and self.isCandleCacheFresh(sym)) {
if (self.getCachedLastClose(sym)) |close| {
result.prices.put(sym, close) catch {};
self.updateLatestDate(&result, sym);
}
result.cached_count += 1;
} else {
needs_fetch.append(self.allocator, sym) catch {};
}
}
if (aggregate_progress) |p| p.emit(result.cached_count, total_count, .cache_check);
if (needs_fetch.items.len == 0) {
if (aggregate_progress) |p| p.emit(total_count, total_count, .complete);
return result;
}
// Phase 2: Server sync (parallel if server configured)
var server_failures: std.ArrayList([]const u8) = .empty;
defer server_failures.deinit(self.allocator);
if (self.config.server_url != null) {
self.parallelServerSync(
needs_fetch.items,
&result,
&server_failures,
config,
aggregate_progress,
total_count,
);
} else {
// No server all need provider fetch
for (needs_fetch.items) |sym| {
server_failures.append(self.allocator, sym) catch {};
}
}
// Phase 3: Sequential provider fallback for server failures
if (server_failures.items.len > 0) {
if (aggregate_progress) |p| p.emit(
result.cached_count + result.server_synced_count,
total_count,
.provider_fetch,
);
self.sequentialProviderFetch(
server_failures.items,
&result,
symbol_progress,
total_count - server_failures.items.len, // offset for progress display
);
}
if (aggregate_progress) |p| p.emit(total_count, total_count, .complete);
return result;
}
/// Parallel server sync using thread pool.
fn parallelServerSync(
self: *DataService,
symbols: []const []const u8,
result: *LoadAllResult,
failures: *std.ArrayList([]const u8),
config: LoadAllConfig,
aggregate_progress: ?AggregateProgressCallback,
total_count: usize,
) void {
const max_threads = if (config.max_concurrent > 0) config.max_concurrent else 8;
const thread_count = @min(symbols.len, max_threads);
if (aggregate_progress) |p| p.emit(result.cached_count, total_count, .server_sync);
// Shared state for worker threads
var completed = AtomicCounter{};
var next_index = AtomicCounter{};
const sync_results = self.allocator.alloc(ServerSyncResult, symbols.len) catch {
// Allocation failed fall back to marking all as failures
for (symbols) |sym| failures.append(self.allocator, sym) catch {};
return;
};
defer self.allocator.free(sync_results);
// Initialize results
for (sync_results, 0..) |*sr, i| {
sr.* = .{ .symbol = symbols[i], .success = false };
}
// Spawn worker threads
var threads = self.allocator.alloc(std.Thread, thread_count) catch {
for (symbols) |sym| failures.append(self.allocator, sym) catch {};
return;
};
defer self.allocator.free(threads);
const WorkerContext = struct {
svc: *DataService,
symbols: []const []const u8,
results: []ServerSyncResult,
next_index: *AtomicCounter,
completed: *AtomicCounter,
};
var ctx = WorkerContext{
.svc = self,
.symbols = symbols,
.results = sync_results,
.next_index = &next_index,
.completed = &completed,
};
const worker = struct {
fn run(wctx: *WorkerContext) void {
while (true) {
const idx = wctx.next_index.increment();
if (idx >= wctx.symbols.len) break;
const sym = wctx.symbols[idx];
const success = wctx.svc.syncCandlesFromServer(sym);
wctx.results[idx].success = success;
_ = wctx.completed.increment();
}
}
};
// Start threads
var spawned: usize = 0;
for (threads) |*t| {
t.* = std.Thread.spawn(.{}, worker.run, .{&ctx}) catch continue;
spawned += 1;
}
// Progress reporting while waiting
if (aggregate_progress) |p| {
while (completed.load() < symbols.len) {
std.Thread.sleep(50 * std.time.ns_per_ms);
p.emit(result.cached_count + completed.load(), total_count, .server_sync);
}
}
// Wait for all threads
for (threads[0..spawned]) |t| {
t.join();
}
// Process results
for (sync_results) |sr| {
if (sr.success) {
// Server sync succeeded read from cache
if (self.getCachedLastClose(sr.symbol)) |close| {
result.prices.put(sr.symbol, close) catch {};
self.updateLatestDate(result, sr.symbol);
result.server_synced_count += 1;
} else {
// Sync said success but can't read cache treat as failure
failures.append(self.allocator, sr.symbol) catch {};
}
} else {
failures.append(self.allocator, sr.symbol) catch {};
}
}
}
/// Sequential provider fetch for symbols that failed server sync.
fn sequentialProviderFetch(
self: *DataService,
symbols: []const []const u8,
result: *LoadAllResult,
progress: ?ProgressCallback,
index_offset: usize,
) void {
const total = index_offset + symbols.len;
for (symbols, 0..) |sym, i| {
const display_idx = index_offset + i;
// Notify: about to fetch
if (progress) |p| p.emit(display_idx, total, sym, .fetching);
// Try provider fetch
if (self.getCandles(sym)) |candle_result| {
defer self.allocator.free(candle_result.data);
if (candle_result.data.len > 0) {
const last = candle_result.data[candle_result.data.len - 1];
result.prices.put(sym, last.close) catch {};
if (result.latest_date == null or last.date.days > result.latest_date.?.days) {
result.latest_date = last.date;
}
}
result.provider_fetched_count += 1;
if (progress) |p| p.emit(display_idx, total, sym, .fetched);
continue;
} else |_| {}
// Provider failed try stale cache
result.failed_count += 1;
if (self.getCachedLastClose(sym)) |close| {
result.prices.put(sym, close) catch {};
result.stale_count += 1;
if (progress) |p| p.emit(display_idx, total, sym, .failed_used_stale);
} else {
if (progress) |p| p.emit(display_idx, total, sym, .failed);
}
}
}
/// Update latest_date in result from cached candle metadata.
fn updateLatestDate(self: *DataService, result: *LoadAllResult, symbol: []const u8) void {
var s = self.store();
if (s.readCandleMeta(symbol)) |cm| {
const d = cm.meta.last_date;
if (result.latest_date == null or d.days > result.latest_date.?.days) {
result.latest_date = d;
}
}
}
// CUSIP Resolution // CUSIP Resolution
/// Look up multiple CUSIPs in a single batch request via OpenFIGI. /// Look up multiple CUSIPs in a single batch request via OpenFIGI.

File diff suppressed because it is too large Load diff

258
src/tui/analysis_tab.zig Normal file
View file

@ -0,0 +1,258 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Data loading
pub fn loadData(app: *App) void {
app.analysis_loaded = true;
// Ensure portfolio is loaded first
if (!app.portfolio_loaded) app.loadPortfolioData();
const pf = app.portfolio orelse return;
const summary = app.portfolio_summary orelse return;
// Load classification metadata file
if (app.classification_map == null) {
// Look for metadata.srf next to the portfolio file
if (app.portfolio_path) |ppath| {
// Derive metadata path: same directory as portfolio, named "metadata.srf"
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
defer app.allocator.free(meta_path);
const file_data = std.fs.cwd().readFileAlloc(app.allocator, meta_path, 1024 * 1024) catch {
app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
defer app.allocator.free(file_data);
app.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
app.setStatus("Error parsing metadata.srf");
return;
};
}
}
// Load account tax type metadata file (optional)
if (app.account_map == null) {
if (app.portfolio_path) |ppath| {
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
loadDataFinish(app, pf, summary);
return;
};
defer app.allocator.free(acct_path);
if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| {
defer app.allocator.free(acct_data);
app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null;
} else |_| {
// accounts.srf is optional -- analysis works without it
}
}
}
loadDataFinish(app, pf, summary);
}
fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
const cm = app.classification_map orelse {
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
return;
};
// Free previous result
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
app.analysis_result = zfin.analysis.analyzePortfolio(
app.allocator,
summary.allocations,
cm,
pf,
summary.total_value,
app.account_map,
) catch {
app.setStatus("Error computing analysis");
return;
};
}
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
return renderAnalysisLines(arena, app.theme, app.analysis_result);
}
/// Render analysis tab content. Pure function no App dependency.
pub fn renderAnalysisLines(
arena: std.mem.Allocator,
th: theme_mod.Theme,
analysis_result: ?zfin.analysis.AnalysisResult,
) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const result = analysis_result orelse {
try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() });
try lines.append(arena, .{ .text = " Run: zfin enrich <portfolio.srf> > metadata.srf", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
const bar_width: usize = 30;
const label_width: usize = 24;
const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{
.{ .items = result.asset_class, .title = " Asset Class" },
.{ .items = result.sector, .title = " Sector (Equities)" },
.{ .items = result.geo, .title = " Geographic" },
.{ .items = result.account, .title = " By Account" },
.{ .items = result.tax_type, .title = " By Tax Type" },
};
for (sections, 0..) |sec, si| {
if (si > 0 and sec.items.len == 0) continue;
if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = sec.title, .style = th.headerStyle() });
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
for (sec.items) |item| {
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width });
}
}
if (result.unclassified.len > 0) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() });
for (result.unclassified) |sym| {
const text = try std.fmt.allocPrint(arena, " {s}", .{sym});
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
}
}
return lines.toOwnedSlice(arena);
}
pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 {
var val_buf: [24]u8 = undefined;
const pct = item.weight * 100.0;
const bar = try buildBlockBar(arena, item.weight, bar_width);
// Build label padded to label_width
const lbl = item.label;
const lbl_len = @min(lbl.len, label_width);
const padded_label = try arena.alloc(u8, label_width);
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{
padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value),
});
}
/// Build a bar using Unicode block elements for sub-character precision.
/// Wraps fmt.buildBlockBar into arena-allocated memory.
pub fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
var buf: [256]u8 = undefined;
const result = fmt.buildBlockBar(&buf, weight, total_chars);
return arena.dupe(u8, result);
}
// Tests
const testing = std.testing;
test "buildBlockBar empty" {
const bar = try buildBlockBar(testing.allocator, 0, 10);
defer testing.allocator.free(bar);
// All spaces
try testing.expectEqual(@as(usize, 10), bar.len);
try testing.expectEqualStrings(" ", bar);
}
test "buildBlockBar full" {
const bar = try buildBlockBar(testing.allocator, 1.0, 5);
defer testing.allocator.free(bar);
// 5 full blocks, each 3 bytes UTF-8 ( = E2 96 88)
try testing.expectEqual(@as(usize, 15), bar.len);
// Verify first block is
try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]);
}
test "buildBlockBar partial" {
const bar = try buildBlockBar(testing.allocator, 0.5, 10);
defer testing.allocator.free(bar);
// 50% of 10 chars = 5 full blocks (no partial)
// 5 full blocks (15 bytes) + 5 spaces = 20 bytes
try testing.expectEqual(@as(usize, 20), bar.len);
}
test "fmtBreakdownLine formats correctly" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const item = zfin.analysis.BreakdownItem{
.label = "US Stock",
.weight = 0.65,
.value = 130000,
};
const line = try fmtBreakdownLine(arena, item, 10, 12);
// Should contain the label, percentage, and dollar amount
try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null);
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null);
}
test "renderAnalysisLines with data" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme_mod.default_theme;
var asset_class = [_]zfin.analysis.BreakdownItem{
.{ .label = "US Stock", .weight = 0.60, .value = 120000 },
.{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 },
};
const result = zfin.analysis.AnalysisResult{
.asset_class = &asset_class,
.sector = &.{},
.geo = &.{},
.account = &.{},
.tax_type = &.{},
.unclassified = &.{},
.total_value = 200000,
};
const lines = try renderAnalysisLines(arena, th, result);
// Should have header section + asset class items
try testing.expect(lines.len >= 5);
// Find "Portfolio Analysis" header
var found_header = false;
for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true;
}
try testing.expect(found_header);
// Find asset class data
var found_us = false;
for (lines) |l| {
if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true;
}
try testing.expect(found_us);
}
test "renderAnalysisLines no data" {
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 renderAnalysisLines(arena, th, null);
try testing.expectEqual(@as(usize, 5), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
}

View file

@ -122,16 +122,31 @@ pub const ChartResult = struct {
rsi_latest: ?f64, rsi_latest: ?f64,
}; };
/// Render a complete financial chart to raw RGB pixel data. /// Cached indicator data to avoid recomputation across frames.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller. /// All slices are owned and must be freed with deinit().
pub fn renderChart( pub const CachedIndicators = struct {
closes: []f64,
volumes: []f64,
bb: []?zfin.indicators.BollingerBand,
rsi_vals: []?f64,
/// Free all allocated memory.
pub fn deinit(self: *CachedIndicators, alloc: std.mem.Allocator) void {
alloc.free(self.closes);
alloc.free(self.volumes);
alloc.free(self.bb);
alloc.free(self.rsi_vals);
self.* = undefined;
}
};
/// Compute indicators for the given candle data.
/// Returns owned CachedIndicators that must be freed with deinit().
pub fn computeIndicators(
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
candles: []const zfin.Candle, candles: []const zfin.Candle,
timeframe: Timeframe, timeframe: Timeframe,
width_px: u32, ) !CachedIndicators {
height_px: u32,
th: theme_mod.Theme,
) !ChartResult {
if (candles.len < 20) return error.InsufficientData; if (candles.len < 20) return error.InsufficientData;
// Slice candles to timeframe // Slice candles to timeframe
@ -141,15 +156,72 @@ pub fn renderChart(
// Extract data series // Extract data series
const closes = try zfin.indicators.closePrices(alloc, data); const closes = try zfin.indicators.closePrices(alloc, data);
defer alloc.free(closes); errdefer alloc.free(closes);
const vols = try zfin.indicators.volumes(alloc, data); const vols = try zfin.indicators.volumes(alloc, data);
defer alloc.free(vols); errdefer alloc.free(vols);
// Compute indicators // Compute indicators
const bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0); const bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0);
defer alloc.free(bb); errdefer alloc.free(bb);
const rsi_vals = try zfin.indicators.rsi(alloc, closes, 14); const rsi_vals = try zfin.indicators.rsi(alloc, closes, 14);
defer alloc.free(rsi_vals);
return .{
.closes = closes,
.volumes = vols,
.bb = bb,
.rsi_vals = rsi_vals,
};
}
/// Render a complete financial chart to raw RGB pixel data.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
/// If `cached` is provided, uses pre-computed indicators instead of recomputing.
pub fn renderChart(
alloc: std.mem.Allocator,
candles: []const zfin.Candle,
timeframe: Timeframe,
width_px: u32,
height_px: u32,
th: theme_mod.Theme,
cached: ?*const CachedIndicators,
) !ChartResult {
if (candles.len < 20) return error.InsufficientData;
// Slice candles to timeframe
const max_days = timeframe.tradingDays();
const n = @min(candles.len, max_days);
const data = candles[candles.len - n ..];
// Use cached indicators or compute fresh ones
var local_closes: ?[]f64 = null;
var local_vols: ?[]f64 = null;
var local_bb: ?[]?zfin.indicators.BollingerBand = null;
var local_rsi: ?[]?f64 = null;
defer {
if (local_closes) |c| alloc.free(c);
if (local_vols) |v| alloc.free(v);
if (local_bb) |b| alloc.free(b);
if (local_rsi) |r| alloc.free(r);
}
const closes: []const f64 = if (cached) |c| c.closes else blk: {
local_closes = try zfin.indicators.closePrices(alloc, data);
break :blk local_closes.?;
};
const vols: []const f64 = if (cached) |c| c.volumes else blk: {
local_vols = try zfin.indicators.volumes(alloc, data);
break :blk local_vols.?;
};
const bb: []const ?zfin.indicators.BollingerBand = if (cached) |c| c.bb else blk: {
local_bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0);
break :blk local_bb.?;
};
const rsi_vals: []const ?f64 = if (cached) |c| c.rsi_vals else blk: {
local_rsi = try zfin.indicators.rsi(alloc, closes, 14);
break :blk local_rsi.?;
};
// Create z2d surface use RGB (not RGBA) since we're rendering onto a solid // Create z2d surface use RGB (not RGBA) since we're rendering onto a solid
// background. This avoids integer overflow in z2d's RGBA compositor when // background. This avoids integer overflow in z2d's RGBA compositor when
@ -230,8 +302,8 @@ pub fn renderChart(
// Grid lines // Grid lines
const grid_color = blendColor(th.text_muted, 60, bg); const grid_color = blendColor(th.text_muted, 60, bg);
try drawHorizontalGridLines(&ctx, alloc, chart_left, chart_right, price_top, price_bottom, price_min, price_max, 5, grid_color); try drawHorizontalGridLines(&ctx, chart_left, chart_right, price_top, price_bottom, 5, grid_color);
try drawHorizontalGridLines(&ctx, alloc, chart_left, chart_right, rsi_top, rsi_bottom, 0, 100, 4, grid_color); try drawHorizontalGridLines(&ctx, chart_left, chart_right, rsi_top, rsi_bottom, 4, grid_color);
// Volume bars (overlaid on price panel bottom 25%) // Volume bars (overlaid on price panel bottom 25%)
{ {
@ -313,10 +385,10 @@ pub fn renderChart(
// Bollinger Band boundary lines + SMA (on top of fills) // Bollinger Band boundary lines + SMA (on top of fills)
{ {
const band_line_color = blendColor(th.text_muted, 100, bg); const band_line_color = blendColor(th.text_muted, 100, bg);
try drawLineSeries(&ctx, alloc, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .upper); try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .upper);
try drawLineSeries(&ctx, alloc, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .lower); try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .lower);
// SMA (middle) // SMA (middle)
try drawLineSeries(&ctx, alloc, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, blendColor(th.text_muted, 160, bg), 1.0, .middle); try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, blendColor(th.text_muted, 160, bg), 1.0, .middle);
} }
// Price line (on top of everything) // Price line (on top of everything)
@ -339,9 +411,9 @@ pub fn renderChart(
// RSI panel // RSI panel
{ {
const ref_color = blendColor(th.text_muted, 100, bg); const ref_color = blendColor(th.text_muted, 100, bg);
try drawHLine(&ctx, alloc, chart_left, chart_right, mapY(70, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0); try drawHLine(&ctx, chart_left, chart_right, mapY(70, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
try drawHLine(&ctx, alloc, chart_left, chart_right, mapY(30, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0); try drawHLine(&ctx, chart_left, chart_right, mapY(30, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
try drawHLine(&ctx, alloc, chart_left, chart_right, mapY(50, 0, 100, rsi_top, rsi_bottom), blendColor(th.text_muted, 50, bg), 1.0); try drawHLine(&ctx, chart_left, chart_right, mapY(50, 0, 100, rsi_top, rsi_bottom), blendColor(th.text_muted, 50, bg), 1.0);
const rsi_color = blendColor(th.info, 220, bg); const rsi_color = blendColor(th.info, 220, bg);
ctx.setSourceToPixel(rsi_color); ctx.setSourceToPixel(rsi_color);
@ -366,8 +438,8 @@ pub fn renderChart(
// Panel borders // Panel borders
{ {
const border_color = blendColor(th.border, 80, bg); const border_color = blendColor(th.border, 80, bg);
try drawRect(&ctx, alloc, chart_left, price_top, chart_right, price_bottom, border_color, 1.0); try drawRect(&ctx, chart_left, price_top, chart_right, price_bottom, border_color, 1.0);
try drawRect(&ctx, alloc, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0); try drawRect(&ctx, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0);
} }
// Get latest RSI // Get latest RSI
@ -436,7 +508,6 @@ const BandField = enum { upper, middle, lower };
fn drawLineSeries( fn drawLineSeries(
ctx: *Context, ctx: *Context,
alloc: std.mem.Allocator,
bb: []const ?zfin.indicators.BollingerBand, bb: []const ?zfin.indicators.BollingerBand,
len: usize, len: usize,
price_min: f64, price_min: f64,
@ -449,7 +520,6 @@ fn drawLineSeries(
line_w: f64, line_w: f64,
field: BandField, field: BandField,
) !void { ) !void {
_ = alloc;
ctx.setSourceToPixel(col); ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w); ctx.setLineWidth(line_w);
ctx.resetPath(); ctx.resetPath();
@ -477,23 +547,17 @@ fn drawLineSeries(
fn drawHorizontalGridLines( fn drawHorizontalGridLines(
ctx: *Context, ctx: *Context,
alloc: std.mem.Allocator,
left: f64, left: f64,
right: f64, right: f64,
top: f64, top: f64,
bottom: f64, bottom: f64,
min_val: f64,
max_val: f64,
n_lines: usize, n_lines: usize,
col: Pixel, col: Pixel,
) !void { ) !void {
_ = alloc;
ctx.setSourceToPixel(col); ctx.setSourceToPixel(col);
ctx.setLineWidth(0.5); ctx.setLineWidth(0.5);
for (1..n_lines) |i| { for (1..n_lines) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines)); const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
_ = min_val;
_ = max_val;
const y = top + frac * (bottom - top); const y = top + frac * (bottom - top);
ctx.resetPath(); ctx.resetPath();
try ctx.moveTo(left, y); try ctx.moveTo(left, y);
@ -503,8 +567,7 @@ fn drawHorizontalGridLines(
ctx.setLineWidth(2.0); ctx.setLineWidth(2.0);
} }
fn drawHLine(ctx: *Context, alloc: std.mem.Allocator, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void { fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void {
_ = alloc;
ctx.setSourceToPixel(col); ctx.setSourceToPixel(col);
ctx.setLineWidth(w); ctx.setLineWidth(w);
ctx.resetPath(); ctx.resetPath();
@ -514,8 +577,7 @@ fn drawHLine(ctx: *Context, alloc: std.mem.Allocator, x1: f64, x2: f64, y: f64,
ctx.setLineWidth(2.0); ctx.setLineWidth(2.0);
} }
fn drawRect(ctx: *Context, alloc: std.mem.Allocator, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, w: f64) !void { fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, w: f64) !void {
_ = alloc;
ctx.setSourceToPixel(col); ctx.setSourceToPixel(col);
ctx.setLineWidth(w); ctx.setLineWidth(w);
ctx.resetPath(); ctx.resetPath();

170
src/tui/earnings_tab.zig Normal file
View file

@ -0,0 +1,170 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Data loading
pub fn loadData(app: *App) void {
app.earnings_loaded = true;
app.freeEarnings();
const result = app.svc.getEarnings(app.symbol) catch |err| {
switch (err) {
zfin.DataError.NoApiKey => app.setStatus("No API key. Set FINNHUB_API_KEY"),
zfin.DataError.FetchFailed => {
app.earnings_disabled = true;
app.setStatus("No earnings data (ETF/index?)");
},
else => app.setStatus("Error loading earnings"),
}
return;
};
app.earnings_data = result.data;
app.earnings_timestamp = result.timestamp;
// Sort chronologically (oldest first) providers may return in any order
if (result.data.len > 1) {
std.mem.sort(zfin.EarningsEvent, result.data, {}, struct {
fn f(_: void, a: zfin.EarningsEvent, b: zfin.EarningsEvent) bool {
return a.date.days < b.date.days;
}
}.f);
}
if (result.data.len == 0) {
app.earnings_disabled = true;
app.setStatus("No earnings data available (ETF/index?)");
return;
}
app.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
}
// Rendering
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);
}
/// Render earnings tab content. Pure function no App dependency.
pub fn renderEarningsLines(
arena: std.mem.Allocator,
th: theme_mod.Theme,
symbol: []const u8,
earnings_disabled: bool,
earnings_data: ?[]const zfin.EarningsEvent,
earnings_timestamp: i64,
) ![]const StyledLine {
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
if (earnings_disabled) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
var earn_ago_buf: [16]u8 = undefined;
const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, earnings_timestamp);
if (earn_ago.len > 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ symbol, earn_ago }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}), .style = th.headerStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const ev = earnings_data orelse {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
if (ev.len == 0) {
try lines.append(arena, .{ .text = " No earnings events found.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10} {s:>5}", .{
"Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", "When",
}), .style = th.mutedStyle() });
for (ev) |e| {
var row_buf: [128]u8 = undefined;
const row = fmt.fmtEarningsRow(&row_buf, e);
const text = try std.fmt.allocPrint(arena, " {s} {s:>5}", .{ row.text, @tagName(e.report_time) });
const row_style = if (row.is_future) th.mutedStyle() else if (row.is_positive) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = text, .style = row_style });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
// Tests
const testing = std.testing;
test "renderEarningsLines with earnings data" {
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 events = [_]zfin.EarningsEvent{.{
.symbol = "AAPL",
.date = try zfin.Date.parse("2025-01-15"),
.quarter = 4,
.estimate = 1.50,
.actual = 1.65,
}};
const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0);
// blank + header + blank + col_header + data_row + blank + count = 7
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[3].text, "EPS Est") != null);
// Data row should contain the date
try testing.expect(std.mem.indexOf(u8, lines[4].text, "2025-01-15") != null);
}
test "renderEarningsLines no symbol" {
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, "", false, null, 0);
try testing.expectEqual(@as(usize, 2), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null);
}
test "renderEarningsLines disabled" {
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, "VTI", true, null, 0);
try testing.expectEqual(@as(usize, 2), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null);
}
test "renderEarningsLines no data" {
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);
try testing.expectEqual(@as(usize, 4), lines.len);
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null);
}

View file

@ -25,7 +25,6 @@ pub const Action = enum {
select_symbol, select_symbol,
symbol_input, symbol_input,
help, help,
edit,
reload_portfolio, reload_portfolio,
collapse_all_calls, collapse_all_calls,
collapse_all_puts, collapse_all_puts,
@ -108,7 +107,6 @@ const default_bindings = [_]Binding{
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } }, .{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
.{ .action = .symbol_input, .key = .{ .codepoint = '/' } }, .{ .action = .symbol_input, .key = .{ .codepoint = '/' } },
.{ .action = .help, .key = .{ .codepoint = '?' } }, .{ .action = .help, .key = .{ .codepoint = '?' } },
.{ .action = .edit, .key = .{ .codepoint = 'e' } },
.{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } }, .{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } },
.{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } }, .{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } },
.{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } }, .{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } },

139
src/tui/options_tab.zig Normal file
View file

@ -0,0 +1,139 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Data loading
pub fn loadData(app: *App) void {
app.options_loaded = true;
app.freeOptions();
const result = app.svc.getOptions(app.symbol) catch |err| {
switch (err) {
zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"),
else => app.setStatus("Error loading options"),
}
return;
};
app.options_data = result.data;
app.options_timestamp = result.timestamp;
app.options_cursor = 0;
app.options_expanded = [_]bool{false} ** 64;
app.options_calls_collapsed = [_]bool{false} ** 64;
app.options_puts_collapsed = [_]bool{false} ** 64;
app.rebuildOptionsRows();
app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
}
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
const chains = app.options_data orelse {
try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
if (chains.len == 0) {
try lines.append(arena, .{ .text = " No options data found.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
var opt_ago_buf: [16]u8 = undefined;
const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, app.options_timestamp);
if (opt_ago.len > 0) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ app.symbol, opt_ago }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{app.symbol}), .style = th.headerStyle() });
}
if (chains[0].underlying_price) |price| {
var price_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, app.options_near_the_money }), .style = th.contentStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Track header line count for mouse click mapping (after all non-data lines)
app.options_header_lines = lines.items.len;
// Flat list of options rows with inline expand/collapse
for (app.options_rows.items, 0..) |row, ri| {
const is_cursor = ri == app.options_cursor;
switch (row.kind) {
.expiration => {
if (row.exp_idx < chains.len) {
const chain = chains[row.exp_idx];
var db: [10]u8 = undefined;
const is_expanded = row.exp_idx < app.options_expanded.len and app.options_expanded[row.exp_idx];
const is_monthly = fmt.isMonthlyExpiration(chain.expiration);
const arrow: []const u8 = if (is_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{
arrow,
chain.expiration.format(&db),
chain.calls.len,
chain.puts.len,
});
const style = if (is_cursor) th.selectStyle() else if (is_monthly) th.contentStyle() else th.mutedStyle();
try lines.append(arena, .{ .text = text, .style = style });
}
},
.calls_header => {
const calls_collapsed = row.exp_idx < app.options_calls_collapsed.len and app.options_calls_collapsed[row.exp_idx];
const arrow: []const u8 = if (calls_collapsed) " > " else " v ";
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{
arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
}), .style = style });
},
.puts_header => {
const puts_collapsed = row.exp_idx < app.options_puts_collapsed.len and app.options_puts_collapsed[row.exp_idx];
const arrow: []const u8 = if (puts_collapsed) " > " else " v ";
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Puts", .{
arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
}), .style = style });
},
.call => {
if (row.contract) |cc| {
const atm_price = chains[0].underlying_price orelse 0;
const itm = cc.strike <= atm_price;
const prefix: []const u8 = if (itm) " |" else " ";
var contract_buf: [128]u8 = undefined;
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, cc));
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style });
}
},
.put => {
if (row.contract) |p| {
const atm_price = chains[0].underlying_price orelse 0;
const itm = p.strike >= atm_price;
const prefix: []const u8 = if (itm) " |" else " ";
var contract_buf: [128]u8 = undefined;
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p));
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style });
}
},
}
}
return lines.toOwnedSlice(arena);
}

208
src/tui/perf_tab.zig Normal file
View file

@ -0,0 +1,208 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
// Data loading
pub fn loadData(app: *App) void {
app.perf_loaded = true;
app.freeCandles();
app.freeDividends();
app.trailing_price = null;
app.trailing_total = null;
app.trailing_me_price = null;
app.trailing_me_total = null;
app.candle_count = 0;
app.candle_first_date = null;
app.candle_last_date = null;
const candle_result = app.svc.getCandles(app.symbol) catch |err| {
switch (err) {
zfin.DataError.NoApiKey => app.setStatus("No API key. Set TWELVEDATA_API_KEY"),
zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"),
else => app.setStatus("Error loading data"),
}
return;
};
app.candles = candle_result.data;
app.candle_timestamp = candle_result.timestamp;
const c = app.candles.?;
if (c.len == 0) {
app.setStatus("No data available for symbol");
return;
}
app.candle_count = c.len;
app.candle_first_date = c[0].date;
app.candle_last_date = c[c.len - 1].date;
const today = fmt.todayDate();
app.trailing_price = zfin.performance.trailingReturns(c);
app.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today);
if (app.svc.getDividends(app.symbol)) |div_result| {
app.dividends = div_result.data;
app.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data);
app.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
} else |_| {}
app.risk_metrics = zfin.risk.trailingRisk(c);
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
if (!app.etf_loaded) {
app.etf_loaded = true;
if (app.svc.getEtfProfile(app.symbol)) |etf_result| {
if (etf_result.data.isEtf()) {
app.etf_profile = etf_result.data;
}
} else |_| {}
}
app.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
}
// Rendering
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
if (app.candle_last_date) |d| {
var pdate_buf: [10]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ app.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.trailing_price == null) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
if (app.candle_count > 0) {
if (app.candle_first_date) |first| {
if (app.candle_last_date) |last| {
var fb: [10]u8 = undefined;
var lb: [10]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{
app.candle_count, first.format(&fb), last.format(&lb),
}), .style = th.mutedStyle() });
}
}
}
if (app.candles) |cc| {
if (cc.len > 0) {
var close_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoneyAbs(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() });
}
}
const has_total = app.trailing_total != null;
if (app.candle_last_date) |last| {
var db: [10]u8 = undefined;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {s}:", .{last.format(&db)}), .style = th.headerStyle() });
}
try appendStyledReturnsTable(arena, &lines, app.trailing_price.?, if (has_total) app.trailing_total else null, th);
{
const today = fmt.todayDate();
const month_end = today.lastDayOfPriorMonth();
var db: [10]u8 = undefined;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({s}):", .{month_end.format(&db)}), .style = th.headerStyle() });
}
if (app.trailing_me_price) |me_price| {
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th);
}
if (!has_total) {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() });
}
if (app.risk_metrics) |tr| {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() });
const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year };
const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" };
for (0..4) |i| {
if (risk_arr[i]) |rm| {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{
risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0,
}), .style = th.contentStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{
risk_labels[i], "", "", "",
}), .style = th.mutedStyle() });
}
}
}
return lines.toOwnedSlice(arena);
}
fn appendStyledReturnsTable(
arena: std.mem.Allocator,
lines: *std.ArrayList(StyledLine),
price: zfin.performance.TrailingReturns,
total: ?zfin.performance.TrailingReturns,
th: theme_mod.Theme,
) !void {
const has_total = total != null;
if (has_total) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}", .{ "", "Price Only", "Total Return" }), .style = th.mutedStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{ "", "Price Only" }), .style = th.mutedStyle() });
}
const price_arr = [4]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year };
const total_arr_vals: [4]?zfin.performance.PerformanceResult = if (total) |t|
.{ t.one_year, t.three_year, t.five_year, t.ten_year }
else
.{ null, null, null, null };
const labels = [4][]const u8{ "1-Year Return:", "3-Year Return:", "5-Year Return:", "10-Year Return:" };
const annualize = [4]bool{ false, true, true, true };
for (0..4) |i| {
var price_buf: [32]u8 = undefined;
var total_buf: [32]u8 = undefined;
const row = fmt.fmtReturnsRow(
&price_buf,
&total_buf,
price_arr[i],
if (has_total) total_arr_vals[i] else null,
annualize[i],
);
const row_style = if (price_arr[i] != null)
(if (row.price_positive) th.positiveStyle() else th.negativeStyle())
else
th.mutedStyle();
if (has_total) {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], row.price_str, row.total_str orelse "N/A", row.suffix }), .style = row_style });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], row.price_str, row.suffix }), .style = row_style });
}
}
}

1042
src/tui/portfolio_tab.zig Normal file

File diff suppressed because it is too large Load diff

605
src/tui/quote_tab.zig Normal file
View file

@ -0,0 +1,605 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const theme_mod = @import("theme.zig");
const chart_mod = @import("chart.zig");
const tui = @import("../tui.zig");
const App = tui.App;
const StyledLine = tui.StyledLine;
const glyph = tui.glyph;
// Rendering
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
/// falling back to braille sparkline otherwise.
pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
// Determine whether to use Kitty graphics
const use_kitty = switch (app.chart.config.mode) {
.braille => false,
.kitty => true,
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
};
if (use_kitty and app.candles != null and app.candles.?.len >= 40) {
drawWithKittyChart(app, ctx, buf, width, height) catch {
// On any failure, fall back to braille
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
};
} else {
// Fallback to styled lines with braille chart
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
}
}
/// Draw quote tab using Kitty graphics protocol for the chart.
fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
const arena = ctx.arena;
const th = app.theme;
const c = app.candles orelse return;
// Build text header (symbol, price, change) first few lines
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Symbol + price header
if (app.quote) |q| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (q.previous_close > 0) {
const change = q.close - q.previous_close;
const pct = (change / q.previous_close) * 100.0;
var chg_buf: [64]u8 = undefined;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
}
} else if (c.len > 0) {
const last = c[c.len - 1];
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ app.symbol, last.close });
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
if (c.len >= 2) {
const prev_close = c[c.len - 2].close;
const change = last.close - prev_close;
const pct = (change / prev_close) * 100.0;
var chg_buf: [64]u8 = undefined;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
}
}
// Timeframe selector line
{
var tf_buf: [80]u8 = undefined;
var tf_pos: usize = 0;
const prefix = " Chart: ";
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
tf_pos += prefix.len;
const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
for (timeframes) |tf| {
const lbl = tf.label();
if (tf == app.chart.timeframe) {
tf_buf[tf_pos] = '[';
tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
tf_pos += lbl.len;
tf_buf[tf_pos] = ']';
tf_pos += 1;
} else {
tf_buf[tf_pos] = ' ';
tf_pos += 1;
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
tf_pos += lbl.len;
tf_buf[tf_pos] = ' ';
tf_pos += 1;
}
tf_buf[tf_pos] = ' ';
tf_pos += 1;
}
const hint = " ([ ] to change)";
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
tf_pos += hint.len;
app.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on
try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Draw the text header
const header_lines = try lines.toOwnedSlice(arena);
try app.drawStyledContent(arena, buf, width, height, header_lines);
// Calculate chart area (below the header, leaving room for details below)
const header_rows: u16 = @intCast(@min(header_lines.len, height));
const detail_rows: u16 = 10; // reserve rows for quote details below chart
const chart_rows = height -| header_rows -| detail_rows;
if (chart_rows < 8) return; // not enough space
// Compute pixel dimensions from cell size
// cell_size may be 0 if terminal hasn't reported pixel dimensions yet
const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8;
const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16;
const label_cols: u16 = 10; // columns reserved for axis labels on the right
const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right
if (chart_cols == 0) return;
const px_w: u32 = @as(u32, chart_cols) * cell_w;
const px_h: u32 = @as(u32, chart_rows) * cell_h;
if (px_w < 100 or px_h < 100) return;
// Apply resolution cap from chart config
const capped_w = @min(px_w, app.chart.config.max_width);
const capped_h = @min(px_h, app.chart.config.max_height);
// Check if we need to re-render the chart image
const symbol_changed = app.chart.symbol_len != app.symbol.len or
!std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol);
const tf_changed = app.chart.timeframe_rendered == null or app.chart.timeframe_rendered.? != app.chart.timeframe;
if (app.chart.dirty or symbol_changed or tf_changed) {
// Free old image
if (app.chart.image_id) |old_id| {
if (app.vx_app) |va| {
va.vx.freeImage(va.tty.writer(), old_id);
}
app.chart.image_id = null;
}
// If symbol changed, invalidate the indicator cache
if (symbol_changed) {
app.chart.freeCache(app.allocator);
}
// Check if we can reuse cached indicators
const cache_valid = app.chart.isCacheValid(c, app.chart.timeframe);
// If cache is invalid, compute new indicators
if (!cache_valid) {
// Free old cache if it exists
app.chart.freeCache(app.allocator);
// Compute and cache new indicators
const new_cache = chart_mod.computeIndicators(
app.allocator,
c,
app.chart.timeframe,
) catch |err| {
app.chart.dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed";
app.setStatus(msg);
return;
};
app.chart.cached_indicators = new_cache;
// Update cache metadata
const max_days = app.chart.timeframe.tradingDays();
const n = @min(c.len, max_days);
const data = c[c.len - n ..];
app.chart.cache_candle_count = data.len;
app.chart.cache_timeframe = app.chart.timeframe;
app.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0;
}
// Render and transmit use the app's main allocator, NOT the arena,
// because z2d allocates large pixel buffers that would bloat the arena.
if (app.vx_app) |va| {
// Pass cached indicators to avoid recomputation during rendering
const cached_ptr: ?*const chart_mod.CachedIndicators = if (app.chart.cached_indicators) |*ci| ci else null;
const chart_result = chart_mod.renderChart(
app.allocator,
c,
app.chart.timeframe,
capped_w,
capped_h,
th,
cached_ptr,
) catch |err| {
app.chart.dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
app.setStatus(msg);
return;
};
defer app.allocator.free(chart_result.rgb_data);
// Base64-encode and transmit raw RGB data directly via Kitty protocol.
// This avoids the PNG encode file write file read PNG decode roundtrip.
const base64_enc = std.base64.standard.Encoder;
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
app.chart.dirty = false;
app.setStatus("Chart: base64 alloc failed");
return;
};
defer app.allocator.free(b64_buf);
const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);
const img = va.vx.transmitPreEncodedImage(
va.tty.writer(),
encoded,
chart_result.width,
chart_result.height,
.rgb,
) catch |err| {
app.chart.dirty = false;
var err_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
app.setStatus(msg);
return;
};
app.chart.image_id = img.id;
app.chart.image_width = @intCast(chart_cols);
app.chart.image_height = chart_rows;
// Track what we rendered
const sym_len = @min(app.symbol.len, 16);
@memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
app.chart.symbol_len = sym_len;
app.chart.timeframe_rendered = app.chart.timeframe;
app.chart.price_min = chart_result.price_min;
app.chart.price_max = chart_result.price_max;
app.chart.rsi_latest = chart_result.rsi_latest;
app.chart.dirty = false;
}
}
// Place the image in the cell buffer
if (app.chart.image_id) |img_id| {
// Place image at the first cell of the chart area
const chart_row_start: usize = header_rows;
const chart_col_start: usize = 1; // 1 col left margin
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
if (buf_idx < buf.len) {
buf[buf_idx] = .{
.char = .{ .grapheme = " " },
.style = th.contentStyle(),
.image = .{
.img_id = img_id,
.options = .{
.size = .{
.rows = app.chart.image_height,
.cols = app.chart.image_width,
},
.scale = .contain,
},
},
};
}
// Axis labels (terminal text in the right margin)
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
// Map these to terminal rows to position labels.
const img_rows = app.chart.image_height;
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.chart.image_width) + 1;
const label_style = th.mutedStyle();
if (label_col + 8 <= width and img_rows >= 4 and app.chart.price_max > app.chart.price_min) {
// Price axis labels evenly spaced across the price panel (top 72%)
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
const n_price_labels: usize = 5;
for (0..n_price_labels) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
const price_val = app.chart.price_max - frac * (app.chart.price_max - app.chart.price_min);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
var lbl_buf: [16]u8 = undefined;
const lbl = fmt.fmtMoneyAbs(&lbl_buf, price_val);
const start_idx = row * @as(usize, width) + label_col;
for (lbl, 0..) |ch, ci| {
const idx = start_idx + ci;
if (idx < buf.len and label_col + ci < width) {
buf[idx] = .{
.char = .{ .grapheme = glyph(ch) },
.style = label_style,
};
}
}
}
// RSI axis labels positioned within the RSI panel (bottom 20%, after 80% offset)
const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80;
const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20;
const rsi_labels = [_]struct { val: f64, label: []const u8 }{
.{ .val = 70, .label = "70" },
.{ .val = 50, .label = "50" },
.{ .val = 30, .label = "30" },
};
for (rsi_labels) |rl| {
// RSI maps 0-100 top-to-bottom within the RSI panel
const rsi_frac = 1.0 - (rl.val / 100.0);
const row_f = @as(f64, @floatFromInt(chart_row_start)) + rsi_panel_start_f + rsi_frac * rsi_panel_h;
const row: usize = @intFromFloat(@round(row_f));
if (row >= height) continue;
const start_idx = row * @as(usize, width) + label_col;
for (rl.label, 0..) |ch, ci| {
const idx = start_idx + ci;
if (idx < buf.len and label_col + ci < width) {
buf[idx] = .{
.char = .{ .grapheme = glyph(ch) },
.style = label_style,
};
}
}
}
}
// Render quote details below the chart image as styled text
const detail_start_row = header_rows + app.chart.image_height;
if (detail_start_row + 8 < height) {
var detail_lines: std.ArrayList(StyledLine) = .empty;
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const latest = c[c.len - 1];
const quote_data = app.quote;
const price = if (quote_data) |q| q.close else latest.close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close);
// Write detail lines into the buffer below the image
const detail_buf_start = detail_start_row * @as(usize, width);
const remaining_height = height - @as(u16, @intCast(detail_start_row));
const detail_slice = try detail_lines.toOwnedSlice(arena);
if (detail_buf_start < buf.len) {
try app.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice);
}
}
}
}
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
const th = app.theme;
var lines: std.ArrayList(StyledLine) = .empty;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
if (app.symbol.len == 0) {
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
var ago_buf: [16]u8 = undefined;
if (app.quote != null and app.quote_timestamp > 0) {
const ago_str = fmt.fmtTimeAgo(&ago_buf, app.quote_timestamp);
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ app.symbol, ago_str }), .style = th.headerStyle() });
} else if (app.candle_last_date) |d| {
var cdate_buf: [10]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ app.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
// Use stored real-time quote if available (fetched on manual refresh)
const quote_data = app.quote;
const c = app.candles orelse {
if (quote_data) |q| {
// No candle data but have a quote - show it
var qclose_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&qclose_buf, q.close)}), .style = th.contentStyle() });
{
var chg_buf: [64]u8 = undefined;
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
}
return lines.toOwnedSlice(arena);
}
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
};
if (c.len == 0) {
try lines.append(arena, .{ .text = " No candle data.", .style = th.mutedStyle() });
return lines.toOwnedSlice(arena);
}
// Use real-time quote price if available, otherwise latest candle
const price = if (quote_data) |q| q.close else c[c.len - 1].close;
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
const latest = c[c.len - 1];
try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close);
// Braille sparkline chart of recent 60 trading days
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const chart_days: usize = @min(c.len, 60);
const chart_data = c[c.len - chart_days ..];
try tui.renderBrailleToStyledLines(arena, &lines, chart_data, th);
// Recent history table
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });
const start_idx = if (c.len > 20) c.len - 20 else 0;
for (c[start_idx..]) |candle| {
var row_buf: [128]u8 = undefined;
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });
}
return lines.toOwnedSlice(arena);
}
// Quote detail columns (price/OHLCV | ETF stats | sectors | holdings)
const Column = struct {
texts: std.ArrayList([]const u8),
styles: std.ArrayList(vaxis.Style),
width: usize, // fixed column width for padding
fn init() Column {
return .{
.texts = .empty,
.styles = .empty,
.width = 0,
};
}
fn add(app: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
try app.texts.append(arena, text);
try app.styles.append(arena, style);
}
fn len(app: *const Column) usize {
return app.texts.items.len;
}
};
fn buildDetailColumns(
app: *App,
arena: std.mem.Allocator,
lines: *std.ArrayList(StyledLine),
latest: zfin.Candle,
quote_data: ?zfin.Quote,
price: f64,
prev_close: f64,
) !void {
const th = app.theme;
var date_buf: [10]u8 = undefined;
var close_buf: [24]u8 = undefined;
var vol_buf: [32]u8 = undefined;
// Column 1: Price/OHLCV
var col1 = Column.init();
col1.width = 30;
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), th.contentStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&close_buf, price)}), th.contentStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
if (prev_close > 0) {
const change = price - prev_close;
const pct = (change / prev_close) * 100.0;
var chg_buf: [64]u8 = undefined;
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style);
}
// Columns 2-4: ETF profile (only for actual ETFs)
var col2 = Column.init(); // ETF stats
col2.width = 22;
var col3 = Column.init(); // Sectors
col3.width = 26;
var col4 = Column.init(); // Top holdings
col4.width = 30;
if (app.etf_profile) |profile| {
// Col 2: ETF key stats
try col2.add(arena, "ETF Profile", th.headerStyle());
if (profile.expense_ratio) |er| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Expense: {d:.2}%", .{er * 100.0}), th.contentStyle());
}
if (profile.net_assets) |na| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Assets: ${s}", .{std.mem.trimRight(u8, &fmt.fmtLargeNum(na), &.{' '})}), th.contentStyle());
}
if (profile.dividend_yield) |dy| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Yield: {d:.2}%", .{dy * 100.0}), th.contentStyle());
}
if (profile.total_holdings) |th_val| {
try col2.add(arena, try std.fmt.allocPrint(arena, " Holdings: {d}", .{th_val}), th.mutedStyle());
}
// Col 3: Sector allocation
if (profile.sectors) |sectors| {
if (sectors.len > 0) {
try col3.add(arena, "Sectors", th.headerStyle());
const show = @min(sectors.len, 7);
for (sectors[0..show]) |sec| {
var title_buf: [64]u8 = undefined;
const title_name = fmt.toTitleCase(&title_buf, sec.name);
const name = if (title_name.len > 20) title_name[0..20] else title_name;
try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle());
}
}
}
// Col 4: Top holdings
if (profile.holdings) |holdings| {
if (holdings.len > 0) {
try col4.add(arena, "Top Holdings", th.headerStyle());
const show = @min(holdings.len, 7);
for (holdings[0..show]) |h| {
const sym_str = h.symbol orelse "--";
try col4.add(arena, try std.fmt.allocPrint(arena, " {s:>6} {d:>5.1}%", .{ sym_str, h.weight * 100.0 }), th.contentStyle());
}
}
}
}
// Merge all columns into grapheme-based StyledLines
const gap: usize = 3;
const bg_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(th.bg) };
const cols = [_]*const Column{ &col1, &col2, &col3, &col4 };
var max_rows: usize = 0;
for (cols) |col| max_rows = @max(max_rows, col.len());
// Total max width for allocation
const max_width = col1.width + gap + col2.width + gap + col3.width + gap + col4.width + 4;
for (0..max_rows) |ri| {
const graphemes = try arena.alloc([]const u8, max_width);
const col_styles = try arena.alloc(vaxis.Style, max_width);
var pos: usize = 0;
for (cols, 0..) |col, ci| {
if (ci > 0 and col.len() == 0) continue; // skip empty columns entirely
if (ci > 0) {
// Gap between columns
for (0..gap) |_| {
if (pos < max_width) {
graphemes[pos] = " ";
col_styles[pos] = bg_style;
pos += 1;
}
}
}
if (ri < col.len()) {
const text = col.texts.items[ri];
const style = col.styles.items[ri];
// Write text characters
for (0..@min(text.len, col.width)) |ci2| {
if (pos < max_width) {
graphemes[pos] = glyph(text[ci2]);
col_styles[pos] = style;
pos += 1;
}
}
// Pad to column width
if (text.len < col.width) {
for (0..col.width - text.len) |_| {
if (pos < max_width) {
graphemes[pos] = " ";
col_styles[pos] = bg_style;
pos += 1;
}
}
}
} else {
// Empty row in this column - pad full width
for (0..col.width) |_| {
if (pos < max_width) {
graphemes[pos] = " ";
col_styles[pos] = bg_style;
pos += 1;
}
}
}
}
try lines.append(arena, .{
.text = "",
.style = bg_style,
.graphemes = graphemes[0..pos],
.cell_styles = col_styles[0..pos],
});
}
}