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
## TUI issues
## CLI options command UX
Display artifacts that don't go away when switching tabs (need specific steps
to reproduce). Lower priority now that ^L has been introduced to re-paint
The `options` command auto-expands only the nearest monthly expiration and
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
@ -42,6 +44,14 @@ Commands:
- `src/commands/quote.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
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.
/// 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(
alloc: std.mem.Allocator,
closes: []const f64,
@ -30,25 +34,61 @@ pub fn bollingerBands(
k: f64,
) ![]?BollingerBand {
const result = try alloc.alloc(?BollingerBand, closes.len);
for (result, 0..) |*r, i| {
const mean = sma(closes, i, period) orelse {
r.* = null;
continue;
};
// 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.* = .{
if (closes.len < period or period == 0) {
@memset(result, null);
return result;
}
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,
.middle = mean,
.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;
}
@ -208,3 +248,89 @@ test "rsi insufficient data" {
// All should be null since len < period + 1
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;
/// 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 {
_ = config;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {
// Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
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);
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);
defer prices.deinit();
// First pass: try cached candle prices
for (positions) |pos| {
if (pos.shares <= 0) continue;
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 {
try cli.stderrPrint("Error computing portfolio summary.\n");
return;
// 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");
return;
},
else => return err,
};
defer summary.deinit(allocator);
defer pf_data.deinit(allocator);
// Load classification metadata
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(
allocator,
summary.allocations,
pf_data.summary.allocations,
cm,
portfolio,
summary.total_value,
pf_data.summary.total_value,
acct_map_opt,
) catch {
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 out.print("========================================\n\n", .{});
// Asset Class
try cli.setBold(out, color);
try cli.setFg(out, color, cli.CLR_HEADER);
try out.print(" Asset Class\n", .{});
try cli.reset(out, color);
try printBreakdownSection(out, result.asset_class, label_width, bar_width, color);
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" },
};
// Sector
if (result.sector.len > 0) {
try out.print("\n", .{});
for (sections, 0..) |sec, si| {
if (si > 0 and sec.items.len == 0) continue;
if (si > 0) try out.print("\n", .{});
try cli.setBold(out, color);
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 printBreakdownSection(out, result.sector, 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);
try printBreakdownSection(out, sec.items, label_width, bar_width, color);
}
// 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
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);
// 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");
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});
}
pub fn fmtEps(val: f64) [12]u8 {
var buf: [12]u8 = .{' '} ** 12;
_ = std.fmt.bufPrint(&buf, "${d:.2}", .{val}) catch {};
return buf;
}
// 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" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);

View file

@ -3,6 +3,36 @@ const zfin = @import("../root.zig");
const cli = @import("common.zig");
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.
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,
/// 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);
}
const sector_raw = overview.sector orelse "Unknown";
var sector_buf: [64]u8 = undefined;
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";
};
const meta = deriveMetadata(overview, &sector_buf);
if (overview.name) |name| {
try out.print("# {s}\n", .{name});
}
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);
}
const sector_raw = overview.sector orelse "Unknown";
var sector_buf: [64]u8 = undefined;
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;
// 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";
};
const meta = deriveMetadata(overview, &sector_buf);
// Comment with the name for readability
if (overview.name) |name| {
try out.print("# {s}\n", .{name});
}
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;
}

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 {
const result = svc.getCandles(symbol) catch |err| switch (err) {
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;
},
else => {
@ -46,7 +46,7 @@ pub fn display(candles: []const zfin.Candle, symbol: []const u8, color: bool, ou
for (candles) |candle| {
var db: [10]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", .{
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" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
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 = 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" {
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const calls = [_]zfin.OptionContract{};
const puts = [_]zfin.OptionContract{};
const chains = [_]zfin.OptionsChain{

View file

@ -124,7 +124,7 @@ pub fn printReturnsTable(
);
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 {
try cli.setFg(out, color, cli.CLR_MUTED);
}
@ -133,7 +133,7 @@ pub fn printReturnsTable(
if (has_total) {
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 cli.reset(out, color);
} else {

View file

@ -3,7 +3,7 @@ const zfin = @import("../root.zig");
const cli = @import("common.zig");
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
const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| {
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;
if (all_syms_count > 0) {
if (config.twelvedata_key == null) {
try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
}
// Progress callback for per-symbol output
var progress_ctx = cli.LoadProgress{
.svc = svc,
.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);
}
// Use consolidated parallel loader
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;
}
// 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");
return;
// Build portfolio summary, candle map, and historical snapshots
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");
return;
},
else => return err,
};
defer summary.deinit(allocator);
defer pf_data.deinit(allocator);
// 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 {
return std.mem.lessThan(u8, a.display_symbol, b.display_symbol);
}
}.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.
// Includes watch lots from portfolio + symbols from separate watchlist file.
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);
defer watch_seen.deinit();
// Exclude portfolio position symbols from watchlist
for (summary.allocations) |a| {
for (pf_data.summary.allocations) |a| {
try watch_seen.put(a.symbol, {});
}
@ -165,25 +120,15 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
// Separate watchlist file (backward compat)
if (watchlist_path) |wl_path| {
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
if (wl_data) |wd| {
defer allocator.free(wd);
var wl_lines = std.mem.splitScalar(u8, wd, '\n');
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;
try watch_seen.put(sym, {});
try watch_list.append(allocator, sym);
if (svc.getCachedLastClose(sym)) |close| {
try watch_prices.put(sym, close);
}
}
const wl_syms = cli.loadWatchlist(allocator, wl_path);
defer cli.freeWatchlist(allocator, wl_syms);
if (wl_syms) |syms_list| {
for (syms_list) |sym| {
if (watch_seen.contains(sym)) continue;
try watch_seen.put(sym, {});
try watch_list.append(allocator, sym);
if (svc.getCachedLastClose(sym)) |close| {
try watch_prices.put(sym, close);
}
}
}
@ -197,9 +142,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
file_path,
&portfolio,
positions,
&summary,
prices,
candle_map,
&pf_data,
watch_list.items,
watch_prices,
);
@ -213,12 +156,11 @@ pub fn display(
file_path: []const u8,
portfolio: *const zfin.Portfolio,
positions: []const zfin.Position,
summary: *const zfin.valuation.PortfolioSummary,
prices: std.StringHashMap(f64),
candle_map: std.StringHashMap([]const zfin.Candle),
pf_data: *const cli.PortfolioData,
watch_symbols: []const []const u8,
watch_prices: std.StringHashMap(f64),
) !void {
const summary = &pf_data.summary;
// Header with summary
try cli.setBold(out, color);
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;
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);
if (summary.unrealized_gain_loss >= 0) {
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
} else {
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
}
try out.print("Gain/Loss: {c}{s} ({d:.1}%)", .{
@as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
fmt.fmtMoneyAbs(&gl_buf, gl_abs),
summary.unrealized_return * 100.0,
});
try cli.reset(out, color);
try out.print("\n", .{});
}
@ -255,13 +197,7 @@ pub fn display(
// Historical portfolio value snapshots
{
if (candle_map.count() > 0) {
const snapshots = zfin.valuation.computeHistoricalSnapshots(
fmt.todayDate(),
positions,
prices,
candle_map,
);
if (pf_data.snapshots) |snapshots| {
try out.print(" Historical: ", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
@ -378,31 +314,15 @@ pub fn display(
const drip = fmt.aggregateDripLots(lots_for_sym.items);
if (!drip.st.isEmpty()) {
var avg_buf: [24]u8 = undefined;
var d1_buf: [10]u8 = undefined;
var d2_buf: [10]u8 = undefined;
var drip_buf: [128]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
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 out.print(" {s}\n", .{fmt.fmtDripSummary(&drip_buf, "ST", drip.st)});
try cli.reset(out, color);
}
if (!drip.lt.isEmpty()) {
var avg_buf2: [24]u8 = undefined;
var d1_buf2: [10]u8 = undefined;
var d2_buf2: [10]u8 = undefined;
var drip_buf2: [128]u8 = undefined;
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
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 out.print(" {s}\n", .{fmt.fmtDripSummary(&drip_buf2, "LT", drip.lt)});
try cli.reset(out, color);
}
}
@ -423,11 +343,10 @@ pub fn display(
"", "", "", "TOTAL", fmt.fmtMoneyAbs(&total_mv_buf, summary.total_value),
});
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
if (summary.unrealized_gain_loss >= 0) {
try out.print("+{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
} else {
try out.print("-{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
}
try out.print("{c}{s:>13}", .{
@as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
fmt.fmtMoneyAbs(&total_gl_buf, gl_abs),
});
try cli.reset(out, color);
try out.print(" {s:>7}\n", .{"100.0%"});
}
@ -436,11 +355,10 @@ pub fn display(
var rpl_buf: [24]u8 = undefined;
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);
if (summary.realized_gain_loss >= 0) {
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
} else {
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
}
try out.print("\n Realized P&L: {c}{s}\n", .{
@as(u8, if (summary.realized_gain_loss >= 0) '+' else '-'),
fmt.fmtMoneyAbs(&rpl_buf, rpl_abs),
});
try cli.reset(out, color);
}
@ -640,7 +558,7 @@ pub fn display(
var any_risk = false;
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);
if (tr.three_year) |metrics| {
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" {
var buf: [8192]u8 = undefined;
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 = "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);
defer prices.deinit();
@ -765,13 +691,14 @@ test "display shows header and summary" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
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();
// Header present
@ -806,7 +733,7 @@ test "display with watchlist" {
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 },
};
var summary = testSummary(&allocs);
const summary = testSummary(&allocs);
var prices = std.StringHashMap(f64).init(testing.allocator);
defer prices.deinit();
@ -814,6 +741,7 @@ test "display with watchlist" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
// Watchlist with prices
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("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();
// Watchlist header and symbols
@ -859,11 +787,12 @@ test "display with options section" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
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();
// Options section present
@ -900,11 +829,12 @@ test "display with CDs and cash" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
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();
// 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);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
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();
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{
.{ .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);
defer prices.deinit();
@ -977,11 +908,12 @@ test "display empty watchlist not shown" {
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
defer candle_map.deinit();
const pf_data = testPortfolioData(summary, candle_map);
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
defer watch_prices.deinit();
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();
// 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
const candle_result = svc.getCandles(symbol) catch |err| switch (err) {
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;
},
else => {
@ -120,7 +120,7 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
for (candles[start_idx..]) |candle| {
var row_buf: [128]u8 = undefined;
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 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")
server_url: ?[]const u8 = null,
cache_dir: []const u8,
cache_dir_owned: bool = false, // true when cache_dir was allocated via path.join
allocator: ?std.mem.Allocator = null,
/// Raw .env file contents (keys/values in env_map point into this).
env_buf: ?[]const u8 = null,
/// Parsed KEY=VALUE pairs from .env file.
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 {
var self = Config{
@ -40,12 +43,14 @@ pub const Config = struct {
const env_cache = self.resolve("ZFIN_CACHE_DIR");
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
const base = std.posix.getenv("XDG_CACHE_HOME") orelse fallback: {
const home = std.posix.getenv("HOME") orelse "/tmp";
const xdg = self.resolve("XDG_CACHE_HOME");
const base = xdg orelse fallback: {
const home = self.resolve("HOME") orelse "/tmp";
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);
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 {
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| {
var map = m.*;
map.deinit();
}
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);
}
}
@ -77,9 +81,14 @@ pub const Config = struct {
self.tiingo_key != null;
}
/// Look up a key: environment variable first, then .env file fallback.
fn resolve(self: Config, key: []const u8) ?[]const u8 {
if (std.posix.getenv(key)) |v| return v;
/// Look up a key: process environment first, then .env file fallback.
fn resolve(self: *Config, key: []const u8) ?[]const u8 {
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);
return null;
}

View file

@ -427,6 +427,23 @@ pub fn aggregateDripLots(lots: []const Lot) DripAggregation {
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)
/// Layout constants for analysis breakdown views.
@ -863,11 +880,11 @@ pub fn writeBrailleAnsi(
// ANSI color helpers (for CLI)
/// 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 {
if (no_color_flag) return false;
if (std.posix.getenv("NO_COLOR")) |_| return false;
// Check if stdout is a TTY
return std.posix.isatty(std.fs.File.stdout().handle);
return std.Io.tty.Config.detect(std.fs.File.stdout()) != .no_color;
}
/// Write an ANSI 24-bit foreground color escape.

View file

@ -183,7 +183,7 @@ pub fn main() !u8 {
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")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
@ -211,7 +211,7 @@ pub fn main() !u8 {
break;
}
}
try commands.analysis.run(allocator, config, &svc, analysis_file, color, out);
try commands.analysis.run(allocator, &svc, analysis_file, color, out);
} else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
return 1;

View file

@ -48,7 +48,7 @@ pub const Client = struct {
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;
while (true) : (attempt += 1) {
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);
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();
return parseResponse(allocator, response.body, symbol);

View file

@ -748,6 +748,356 @@ pub const DataService = struct {
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
/// 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,
};
/// Render a complete financial chart to raw RGB pixel data.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
pub fn renderChart(
/// Cached indicator data to avoid recomputation across frames.
/// All slices are owned and must be freed with deinit().
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,
candles: []const zfin.Candle,
timeframe: Timeframe,
width_px: u32,
height_px: u32,
th: theme_mod.Theme,
) !ChartResult {
) !CachedIndicators {
if (candles.len < 20) return error.InsufficientData;
// Slice candles to timeframe
@ -141,15 +156,72 @@ pub fn renderChart(
// Extract data series
const closes = try zfin.indicators.closePrices(alloc, data);
defer alloc.free(closes);
errdefer alloc.free(closes);
const vols = try zfin.indicators.volumes(alloc, data);
defer alloc.free(vols);
errdefer alloc.free(vols);
// Compute indicators
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);
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
// background. This avoids integer overflow in z2d's RGBA compositor when
@ -230,8 +302,8 @@ pub fn renderChart(
// Grid lines
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, alloc, chart_left, chart_right, rsi_top, rsi_bottom, 0, 100, 4, grid_color);
try drawHorizontalGridLines(&ctx, chart_left, chart_right, price_top, price_bottom, 5, grid_color);
try drawHorizontalGridLines(&ctx, chart_left, chart_right, rsi_top, rsi_bottom, 4, grid_color);
// Volume bars (overlaid on price panel bottom 25%)
{
@ -313,10 +385,10 @@ pub fn renderChart(
// Bollinger Band boundary lines + SMA (on top of fills)
{
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, 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, .upper);
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)
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)
@ -339,9 +411,9 @@ pub fn renderChart(
// RSI panel
{
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, alloc, 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(70, 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, 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);
ctx.setSourceToPixel(rsi_color);
@ -366,8 +438,8 @@ pub fn renderChart(
// Panel borders
{
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, alloc, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0);
try drawRect(&ctx, chart_left, price_top, chart_right, price_bottom, border_color, 1.0);
try drawRect(&ctx, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0);
}
// Get latest RSI
@ -436,7 +508,6 @@ const BandField = enum { upper, middle, lower };
fn drawLineSeries(
ctx: *Context,
alloc: std.mem.Allocator,
bb: []const ?zfin.indicators.BollingerBand,
len: usize,
price_min: f64,
@ -449,7 +520,6 @@ fn drawLineSeries(
line_w: f64,
field: BandField,
) !void {
_ = alloc;
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
@ -477,23 +547,17 @@ fn drawLineSeries(
fn drawHorizontalGridLines(
ctx: *Context,
alloc: std.mem.Allocator,
left: f64,
right: f64,
top: f64,
bottom: f64,
min_val: f64,
max_val: f64,
n_lines: usize,
col: Pixel,
) !void {
_ = alloc;
ctx.setSourceToPixel(col);
ctx.setLineWidth(0.5);
for (1..n_lines) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
_ = min_val;
_ = max_val;
const y = top + frac * (bottom - top);
ctx.resetPath();
try ctx.moveTo(left, y);
@ -503,8 +567,7 @@ fn drawHorizontalGridLines(
ctx.setLineWidth(2.0);
}
fn drawHLine(ctx: *Context, alloc: std.mem.Allocator, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void {
_ = alloc;
fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(w);
ctx.resetPath();
@ -514,8 +577,7 @@ fn drawHLine(ctx: *Context, alloc: std.mem.Allocator, x1: f64, x2: f64, y: f64,
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 {
_ = alloc;
fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(w);
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,
symbol_input,
help,
edit,
reload_portfolio,
collapse_all_calls,
collapse_all_puts,
@ -108,7 +107,6 @@ const default_bindings = [_]Binding{
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
.{ .action = .symbol_input, .key = .{ .codepoint = '/' } },
.{ .action = .help, .key = .{ .codepoint = '?' } },
.{ .action = .edit, .key = .{ .codepoint = 'e' } },
.{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } },
.{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } },
.{ .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],
});
}
}