From 529143cf4946ba4fba02cb4ad8f11209b59915a8 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Thu, 26 Feb 2026 07:40:51 -0800 Subject: [PATCH] ai: bring cli and tui to near parity --- src/analytics/indicators.zig | 136 ++++++ src/cli/main.zig | 907 +++++++++++++++++++++++++---------- src/format.zig | 529 ++++++++++++++++++++ src/models/etf_profile.zig | 10 + src/providers/cboe.zig | 11 +- src/root.zig | 3 + src/tui/chart.zig | 529 ++++++++++++++++++++ src/tui/main.zig | 691 +++++++++++++------------- 8 files changed, 2186 insertions(+), 630 deletions(-) create mode 100644 src/analytics/indicators.zig create mode 100644 src/format.zig create mode 100644 src/tui/chart.zig diff --git a/src/analytics/indicators.zig b/src/analytics/indicators.zig new file mode 100644 index 0000000..07394bb --- /dev/null +++ b/src/analytics/indicators.zig @@ -0,0 +1,136 @@ +//! Technical indicators for financial charting. +//! Bollinger Bands, RSI, SMA — all computed from candle close prices. + +const std = @import("std"); +const Candle = @import("../models/candle.zig").Candle; + +/// Simple Moving Average for a window of `period` values ending at index `end` (inclusive). +/// Returns null if there aren't enough data points. +pub fn sma(closes: []const f64, end: usize, period: usize) ?f64 { + if (end + 1 < period) return null; + var sum: f64 = 0; + const start = end + 1 - period; + for (closes[start .. end + 1]) |v| sum += v; + return sum / @as(f64, @floatFromInt(period)); +} + +/// Bollinger Bands output for a single data point. +pub const BollingerBand = struct { + upper: f64, + middle: f64, // SMA + lower: f64, +}; + +/// Compute Bollinger Bands (SMA ± k * stddev) for the full series. +/// Returns a slice of optional BollingerBand — null where period hasn't been reached. +pub fn bollingerBands( + alloc: std.mem.Allocator, + closes: []const f64, + period: usize, + 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.* = .{ + .upper = mean + k * stddev, + .middle = mean, + .lower = mean - k * stddev, + }; + } + return result; +} + +/// RSI (Relative Strength Index) for the full series using Wilder's smoothing. +/// Returns a slice of optional f64 — null for the first `period` data points. +pub fn rsi( + alloc: std.mem.Allocator, + closes: []const f64, + period: usize, +) ![]?f64 { + const result = try alloc.alloc(?f64, closes.len); + if (closes.len < period + 1) { + @memset(result, null); + return result; + } + + // Seed: average gain/loss over first `period` changes + var avg_gain: f64 = 0; + var avg_loss: f64 = 0; + for (1..period + 1) |i| { + const change = closes[i] - closes[i - 1]; + if (change > 0) avg_gain += change else avg_loss += -change; + } + const p_f: f64 = @floatFromInt(period); + avg_gain /= p_f; + avg_loss /= p_f; + + // First `period` values are null + for (0..period) |i| result[i] = null; + // Value at index `period` + if (avg_loss == 0) { + result[period] = 100.0; + } else { + const rs = avg_gain / avg_loss; + result[period] = 100.0 - (100.0 / (1.0 + rs)); + } + + // Wilder's smoothing for the rest + for (period + 1..closes.len) |i| { + const change = closes[i] - closes[i - 1]; + const gain = if (change > 0) change else 0; + const loss = if (change < 0) -change else 0; + avg_gain = (avg_gain * (p_f - 1.0) + gain) / p_f; + avg_loss = (avg_loss * (p_f - 1.0) + loss) / p_f; + if (avg_loss == 0) { + result[i] = 100.0; + } else { + const rs = avg_gain / avg_loss; + result[i] = 100.0 - (100.0 / (1.0 + rs)); + } + } + return result; +} + +/// Extract close prices from candles into a contiguous f64 slice. +pub fn closePrices(alloc: std.mem.Allocator, candles: []const Candle) ![]f64 { + const result = try alloc.alloc(f64, candles.len); + for (candles, 0..) |c, i| result[i] = c.close; + return result; +} + +/// Extract volumes from candles. +pub fn volumes(alloc: std.mem.Allocator, candles: []const Candle) ![]f64 { + const result = try alloc.alloc(f64, candles.len); + for (candles, 0..) |c, i| result[i] = @floatFromInt(c.volume); + return result; +} + +test "sma basic" { + const closes = [_]f64{ 1, 2, 3, 4, 5 }; + try std.testing.expectEqual(@as(?f64, null), sma(&closes, 1, 3)); + try std.testing.expectApproxEqAbs(@as(f64, 2.0), sma(&closes, 2, 3).?, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 3.0), sma(&closes, 3, 3).?, 0.001); +} + +test "rsi basic" { + const alloc = std.testing.allocator; + // 15 prices with a clear uptrend + const closes = [_]f64{ 44, 44.34, 44.09, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08, 45.89, 46.03, 45.61, 46.28, 46.28 }; + const result = try rsi(alloc, &closes, 14); + defer alloc.free(result); + // First 14 should be null, last should have a value + try std.testing.expect(result[13] == null); + try std.testing.expect(result[14] != null); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 9e4ae82..3bd9fc3 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const zfin = @import("zfin"); +const fmt = zfin.format; const tui = @import("tui"); const usage = @@ -8,17 +9,20 @@ const usage = \\Commands: \\ interactive [opts] Launch interactive TUI \\ perf Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style) - \\ quote Show latest quote + \\ quote Show latest quote with chart and history \\ history Show recent price history \\ divs Show dividend history \\ splits Show split history - \\ options Show options chain (nearest expiration) + \\ options Show options chain (all expirations) \\ earnings Show earnings history and upcoming \\ etf Show ETF profile (holdings, sectors, expense ratio) \\ portfolio Load and analyze a portfolio (.srf file) \\ cache stats Show cache statistics \\ cache clear Clear all cached data \\ + \\Global options: + \\ --no-color Disable colored output + \\ \\Interactive mode options: \\ -p, --portfolio Portfolio file (.srf) \\ -w, --watchlist Watchlist file (default: watchlist.srf) @@ -27,15 +31,29 @@ const usage = \\ --default-keys Print default keybindings \\ --default-theme Print default theme \\ + \\Options command options: + \\ --ntm Show +/- N strikes near the money (default: 8) + \\ + \\Portfolio command options: + \\ -w, --watchlist Watchlist file + \\ \\Environment Variables: \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) \\ POLYGON_API_KEY Polygon.io API key (dividends, splits) \\ FINNHUB_API_KEY Finnhub API key (earnings) \\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles) \\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin) + \\ NO_COLOR Disable colored output (https://no-color.org) \\ ; +// ── Default CLI colors (match TUI default theme) ───────────── +const CLR_GREEN = [3]u8{ 166, 227, 161 }; // positive +const CLR_RED = [3]u8{ 243, 139, 168 }; // negative +const CLR_MUTED = [3]u8{ 128, 128, 128 }; // muted/dim +const CLR_HEADER = [3]u8{ 205, 214, 244 }; // headers +const CLR_ACCENT = [3]u8{ 137, 180, 250 }; // info/accent + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -49,6 +67,13 @@ pub fn main() !void { return; } + // Scan for global --no-color flag + var no_color_flag = false; + for (args[1..]) |arg| { + if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true; + } + const color = fmt.shouldUseColor(no_color_flag); + var config = zfin.Config.fromEnv(allocator); defer config.deinit(); const command = args[1]; @@ -69,31 +94,49 @@ pub fn main() !void { if (std.mem.eql(u8, command, "perf")) { if (args.len < 3) return try stderr_print("Error: 'perf' requires a symbol argument\n"); - try cmdPerf(allocator, &svc, args[2]); + try cmdPerf(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "quote")) { if (args.len < 3) return try stderr_print("Error: 'quote' requires a symbol argument\n"); - try cmdQuote(allocator, config, args[2]); + try cmdQuote(allocator, config, &svc, args[2], color); } else if (std.mem.eql(u8, command, "history")) { if (args.len < 3) return try stderr_print("Error: 'history' requires a symbol argument\n"); - try cmdHistory(allocator, &svc, args[2]); + try cmdHistory(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "divs")) { if (args.len < 3) return try stderr_print("Error: 'divs' requires a symbol argument\n"); - try cmdDivs(allocator, &svc, config, args[2]); + try cmdDivs(allocator, &svc, config, args[2], color); } else if (std.mem.eql(u8, command, "splits")) { if (args.len < 3) return try stderr_print("Error: 'splits' requires a symbol argument\n"); - try cmdSplits(allocator, &svc, args[2]); + try cmdSplits(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "options")) { if (args.len < 3) return try stderr_print("Error: 'options' requires a symbol argument\n"); - try cmdOptions(allocator, &svc, args[2]); + // Parse --ntm flag + var ntm: usize = 8; + var ai: usize = 3; + while (ai < args.len) : (ai += 1) { + if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) { + ai += 1; + ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8; + } + } + try cmdOptions(allocator, &svc, args[2], ntm, color); } else if (std.mem.eql(u8, command, "earnings")) { if (args.len < 3) return try stderr_print("Error: 'earnings' requires a symbol argument\n"); - try cmdEarnings(allocator, &svc, args[2]); + try cmdEarnings(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "etf")) { if (args.len < 3) return try stderr_print("Error: 'etf' requires a symbol argument\n"); - try cmdEtf(allocator, &svc, args[2]); + try cmdEtf(allocator, &svc, args[2], color); } else if (std.mem.eql(u8, command, "portfolio")) { if (args.len < 3) return try stderr_print("Error: 'portfolio' requires a file path argument\n"); - try cmdPortfolio(allocator, config, args[2]); + // Parse -w/--watchlist flag + var watchlist_path: ?[]const u8 = null; + var pi: usize = 3; + while (pi < args.len) : (pi += 1) { + if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) { + pi += 1; + watchlist_path = args[pi]; + } + } + try cmdPortfolio(allocator, config, args[2], watchlist_path, color); } else if (std.mem.eql(u8, command, "cache")) { if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n"); try cmdCache(allocator, config, args[2]); @@ -102,7 +145,32 @@ pub fn main() !void { } } -fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { +// ── ANSI color helpers ─────────────────────────────────────── + +fn setFg(out: anytype, c: bool, rgb: [3]u8) !void { + if (c) try fmt.ansiSetFg(out, rgb[0], rgb[1], rgb[2]); +} + +fn setBold(out: anytype, c: bool) !void { + if (c) try fmt.ansiBold(out); +} + +fn reset(out: anytype, c: bool) !void { + if (c) try fmt.ansiReset(out); +} + +fn setGainLoss(out: anytype, c: bool, value: f64) !void { + if (c) { + if (value >= 0) + try fmt.ansiSetFg(out, CLR_GREEN[0], CLR_GREEN[1], CLR_GREEN[2]) + else + try fmt.ansiSetFg(out, CLR_RED[0], CLR_RED[1], CLR_RED[2]); + } +} + +// ── Commands ───────────────────────────────────────────────── + +fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getTrailingReturns(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n"); @@ -120,15 +188,18 @@ fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const const c = result.candles; const end_date = c[c.len - 1].date; - const today = todayDate(); + const today = fmt.todayDate(); const month_end = today.lastDayOfPriorMonth(); var buf: [8192]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; + try setBold(out, color); try out.print("\nTrailing Returns for {s}\n", .{symbol}); + try reset(out, color); try out.print("========================================\n", .{}); + try setFg(out, color, CLR_MUTED); try out.print("Data points: {d} (", .{c.len}); { var db: [10]u8 = undefined; @@ -139,26 +210,34 @@ fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const var db: [10]u8 = undefined; try out.print("{s}", .{end_date.format(&db)}); } - try out.print(")\nLatest close: ${d:.2}\n", .{c[c.len - 1].close}); + try reset(out, color); + var close_buf: [24]u8 = undefined; + try out.print(")\nLatest close: {s}\n", .{fmt.fmtMoney(&close_buf, c[c.len - 1].close)}); const has_divs = result.asof_total != null; - // -- As-of-date returns (matches Morningstar "Trailing Returns" page) -- + // -- As-of-date returns -- { var db: [10]u8 = undefined; + try setBold(out, color); try out.print("\nAs-of {s}:\n", .{end_date.format(&db)}); + try reset(out, color); } - try printReturnsTable(out, result.asof_price, if (has_divs) result.asof_total else null); + try printReturnsTable(out, result.asof_price, if (has_divs) result.asof_total else null, color); - // -- Month-end returns (matches Morningstar "Performance" page) -- + // -- Month-end returns -- { var db: [10]u8 = undefined; + try setBold(out, color); try out.print("\nMonth-end ({s}):\n", .{month_end.format(&db)}); + try reset(out, color); } - try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null); + try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null, color); if (!has_divs) { + try setFg(out, color, CLR_MUTED); try out.print("\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{}); + try reset(out, color); } try out.print("\n", .{}); try out.flush(); @@ -168,9 +247,11 @@ fn printReturnsTable( out: anytype, price: zfin.performance.TrailingReturns, total: ?zfin.performance.TrailingReturns, + color: bool, ) !void { const has_total = total != null; + try setFg(out, color, CLR_MUTED); if (has_total) { try out.print("{s:>22} {s:>14} {s:>14}\n", .{ "", "Price Only", "Total Return" }); try out.print("{s:->22} {s:->14} {s:->14}\n", .{ "", "", "" }); @@ -178,6 +259,7 @@ fn printReturnsTable( try out.print("{s:>22} {s:>14}\n", .{ "", "Price Only" }); try out.print("{s:->22} {s:->14}\n", .{ "", "" }); } + try reset(out, color); const periods = [_]struct { label: []const u8, years: u16 }{ .{ .label = "1-Year Return:", .years = 1 }, @@ -201,77 +283,175 @@ fn printReturnsTable( if (price_arr[i]) |r| { var rb: [32]u8 = undefined; const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return; + try setGainLoss(out, color, val); try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); + try reset(out, color); } else { + try setFg(out, color, CLR_MUTED); try out.print(" {s:>13}", .{"N/A"}); + try reset(out, color); } if (has_total) { if (total_arr[i]) |r| { var rb: [32]u8 = undefined; const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return; + try setGainLoss(out, color, val); try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); + try reset(out, color); } else { + try setFg(out, color, CLR_MUTED); try out.print(" {s:>13}", .{"N/A"}); + try reset(out, color); } } if (period.years > 1) { + try setFg(out, color, CLR_MUTED); try out.print(" ann.", .{}); + try reset(out, color); } try out.print("\n", .{}); } } -fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, symbol: []const u8) !void { - // Quote is a real-time endpoint, not cached -- use TwelveData directly - const td_key = config.twelvedata_key orelse { - try stderr_print("Error: TWELVEDATA_API_KEY not set.\n"); - return; +fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { + // Fetch candle data for chart and history + const candle_result = svc.getCandles(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try stderr_print("Error: TWELVEDATA_API_KEY not set.\n"); + return; + }, + else => { + try stderr_print("Error fetching candle data.\n"); + return; + }, }; + defer allocator.free(candle_result.data); + const candles = candle_result.data; - var td = zfin.TwelveData.init(allocator, td_key); - defer td.deinit(); + // Fetch real-time quote + var q_close: f64 = 0; + var q_open: f64 = 0; + var q_high: f64 = 0; + var q_low: f64 = 0; + var q_volume: u64 = 0; + var q_prev_close: f64 = 0; + var has_quote = false; - var qr = td.fetchQuote(allocator, symbol) catch |err| { - try stderr_print("API error: "); - try stderr_print(@errorName(err)); - try stderr_print("\n"); - return; - }; - defer qr.deinit(); + if (config.twelvedata_key) |key| { + var td = zfin.TwelveData.init(allocator, key); + defer td.deinit(); + if (td.fetchQuote(allocator, symbol)) |qr_val| { + var qr = qr_val; + defer qr.deinit(); + if (qr.parse(allocator)) |q_val| { + var q = q_val; + defer q.deinit(); + q_close = q.close(); + q_open = q.open(); + q_high = q.high(); + q_low = q.low(); + q_volume = q.volume(); + q_prev_close = q.previous_close(); + has_quote = true; + } else |_| {} + } else |_| {} + } - var q = qr.parse(allocator) catch |err| { - try stderr_print("Parse error: "); - try stderr_print(@errorName(err)); - try stderr_print("\n"); - return; - }; - defer q.deinit(); - - var buf: [4096]u8 = undefined; + var buf: [16384]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; - try out.print("\n{s} -- {s}\n", .{ q.symbol(), q.name() }); + // Header + try setBold(out, color); + if (has_quote) { + var price_buf: [24]u8 = undefined; + try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoney(&price_buf, q_close) }); + } else if (candles.len > 0) { + var price_buf: [24]u8 = undefined; + try out.print("\n{s} {s} (close)\n", .{ symbol, fmt.fmtMoney(&price_buf, candles[candles.len - 1].close) }); + } else { + try out.print("\n{s}\n", .{symbol}); + } + try reset(out, color); try out.print("========================================\n", .{}); - try out.print(" Exchange: {s}\n", .{q.exchange()}); - try out.print(" Date: {s}\n", .{q.datetime()}); - try out.print(" Close: ${d:.2}\n", .{q.close()}); - try out.print(" Open: ${d:.2}\n", .{q.open()}); - try out.print(" High: ${d:.2}\n", .{q.high()}); - try out.print(" Low: ${d:.2}\n", .{q.low()}); - try out.print(" Volume: {d}\n", .{q.volume()}); - try out.print(" Prev Close: ${d:.2}\n", .{q.previous_close()}); - try out.print(" Change: ${d:.2} ({d:.2}%)\n", .{ q.change(), q.percent_change() }); - try out.print(" 52-Week Low: ${d:.2}\n", .{q.fifty_two_week_low()}); - try out.print(" 52-Week High: ${d:.2}\n", .{q.fifty_two_week_high()}); - try out.print(" Avg Volume: {d}\n\n", .{q.average_volume()}); + + // Quote details + const price = if (has_quote) q_close else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0); + const prev_close = if (has_quote) q_prev_close else if (candles.len >= 2) candles[candles.len - 2].close else @as(f64, 0); + + if (candles.len > 0 or has_quote) { + const latest_date = if (candles.len > 0) candles[candles.len - 1].date else fmt.todayDate(); + const open_val = if (has_quote) q_open else if (candles.len > 0) candles[candles.len - 1].open else @as(f64, 0); + const high_val = if (has_quote) q_high else if (candles.len > 0) candles[candles.len - 1].high else @as(f64, 0); + const low_val = if (has_quote) q_low else if (candles.len > 0) candles[candles.len - 1].low else @as(f64, 0); + const vol_val = if (has_quote) q_volume else if (candles.len > 0) candles[candles.len - 1].volume else @as(u64, 0); + + var date_buf: [10]u8 = undefined; + var vol_buf: [32]u8 = undefined; + try out.print(" Date: {s}\n", .{latest_date.format(&date_buf)}); + try out.print(" Open: ${d:.2}\n", .{open_val}); + try out.print(" High: ${d:.2}\n", .{high_val}); + try out.print(" Low: ${d:.2}\n", .{low_val}); + try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)}); + + if (prev_close > 0) { + const change = price - prev_close; + const pct = (change / prev_close) * 100.0; + try setGainLoss(out, color, change); + if (change >= 0) { + try out.print(" Change: +${d:.2} (+{d:.2}%)\n", .{ change, pct }); + } else { + try out.print(" Change: -${d:.2} ({d:.2}%)\n", .{ -change, pct }); + } + try reset(out, color); + } + } + + // Braille chart (60 columns, 10 rows) + if (candles.len >= 2) { + try out.print("\n", .{}); + const chart_days: usize = @min(candles.len, 60); + const chart_data = candles[candles.len - chart_days ..]; + var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, CLR_GREEN, CLR_RED) catch null; + if (chart) |*ch| { + defer ch.deinit(allocator); + try fmt.writeBrailleAnsi(out, ch, color, CLR_MUTED); + } + } + + // Recent history table (last 20 candles) + if (candles.len > 0) { + try out.print("\n", .{}); + try setBold(out, color); + try out.print(" Recent History:\n", .{}); + try reset(out, color); + try setFg(out, color, CLR_MUTED); + try out.print(" {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{ + "Date", "Open", "High", "Low", "Close", "Volume", + }); + try reset(out, color); + + const start_idx = if (candles.len > 20) candles.len - 20 else 0; + for (candles[start_idx..]) |candle| { + var db: [10]u8 = undefined; + var vb: [32]u8 = undefined; + const day_gain = candle.close >= candle.open; + try setGainLoss(out, color, if (day_gain) @as(f64, 1) else @as(f64, -1)); + 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), + }); + try reset(out, color); + } + try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len}); + } + + try out.print("\n", .{}); try out.flush(); } -fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { - // History uses getCandles but filters to last 30 days +fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getCandles(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: TWELVEDATA_API_KEY not set.\n"); @@ -289,53 +469,42 @@ fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []co const all = result.data; if (all.len == 0) return try stderr_print("No data available.\n"); - // Filter to last 30 days - const today = todayDate(); + const today = fmt.todayDate(); const one_month_ago = today.addDays(-30); - const c = filterCandlesFrom(all, one_month_ago); + const c = fmt.filterCandlesFrom(all, one_month_ago); if (c.len == 0) return try stderr_print("No data available.\n"); var buf: [8192]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; + try setBold(out, color); try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol}); + try reset(out, color); try out.print("========================================\n", .{}); + try setFg(out, color, CLR_MUTED); try out.print("{s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{ "Date", "Open", "High", "Low", "Close", "Volume", }); try out.print("{s:->12} {s:->10} {s:->10} {s:->10} {s:->10} {s:->12}\n", .{ "", "", "", "", "", "", }); + try reset(out, color); for (c) |candle| { var db: [10]u8 = undefined; - try out.print("{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {d:>12}\n", .{ - candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, candle.volume, + var vb: [32]u8 = undefined; + try setGainLoss(out, color, if (candle.close >= candle.open) @as(f64, 1) else @as(f64, -1)); + 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), }); + try reset(out, color); } try out.print("\n{d} trading days\n\n", .{c.len}); try out.flush(); } -/// Return a slice view of candles on or after the given date (no allocation). -fn filterCandlesFrom(candles: []const zfin.Candle, from: zfin.Date) []const zfin.Candle { - // Binary search for first candle >= from - var lo: usize = 0; - var hi: usize = candles.len; - while (lo < hi) { - const mid = lo + (hi - lo) / 2; - if (candles[mid].date.lessThan(from)) { - lo = mid + 1; - } else { - hi = mid; - } - } - if (lo >= candles.len) return candles[0..0]; - return candles[lo..]; -} - -fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8) !void { +fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool) !void { const result = svc.getDividends(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); @@ -372,23 +541,29 @@ fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Co var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; + try setBold(out, color); try out.print("\nDividend History for {s}\n", .{symbol}); + try reset(out, color); try out.print("========================================\n", .{}); if (d.len == 0) { + try setFg(out, color, CLR_MUTED); try out.print(" No dividends found.\n\n", .{}); + try reset(out, color); try out.flush(); return; } + try setFg(out, color, CLR_MUTED); try out.print("{s:>12} {s:>10} {s:>12} {s:>6} {s:>10}\n", .{ "Ex-Date", "Amount", "Pay Date", "Freq", "Type", }); try out.print("{s:->12} {s:->10} {s:->12} {s:->6} {s:->10}\n", .{ "", "", "", "", "", }); + try reset(out, color); - const today = todayDate(); + const today = fmt.todayDate(); const one_year_ago = today.subtractYears(1); var total: f64 = 0; var ttm: f64 = 0; @@ -413,18 +588,20 @@ fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Co } try out.print("\n{d} dividends, total: ${d:.4}\n", .{ d.len, total }); + try setFg(out, color, CLR_ACCENT); try out.print("TTM dividends: ${d:.4}", .{ttm}); - if (current_price) |price| { - if (price > 0) { - const yield = (ttm / price) * 100.0; - try out.print(" (yield: {d:.2}% at ${d:.2})", .{ yield, price }); + if (current_price) |cp| { + if (cp > 0) { + const yield = (ttm / cp) * 100.0; + try out.print(" (yield: {d:.2}% at ${d:.2})", .{ yield, cp }); } } + try reset(out, color); try out.print("\n\n", .{}); try out.flush(); } -fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { +fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getSplits(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); @@ -445,17 +622,23 @@ fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []con var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; + try setBold(out, color); try out.print("\nSplit History for {s}\n", .{symbol}); + try reset(out, color); try out.print("========================================\n", .{}); if (sp.len == 0) { + try setFg(out, color, CLR_MUTED); try out.print(" No splits found.\n\n", .{}); + try reset(out, color); try out.flush(); return; } + try setFg(out, color, CLR_MUTED); try out.print("{s:>12} {s:>10}\n", .{ "Date", "Ratio" }); try out.print("{s:->12} {s:->10}\n", .{ "", "" }); + try reset(out, color); for (sp) |s| { var db: [10]u8 = undefined; @@ -465,7 +648,7 @@ fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []con try out.flush(); } -fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { +fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool) !void { const result = svc.getOptions(symbol) catch |err| switch (err) { zfin.DataError.FetchFailed => { try stderr_print("Error fetching options data from CBOE.\n"); @@ -478,9 +661,8 @@ fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []co }; const ch = result.data; defer { - // All chains share the same underlying_symbol pointer; free it once. - if (ch.len > 0) allocator.free(ch[0].underlying_symbol); for (ch) |chain| { + allocator.free(chain.underlying_symbol); allocator.free(chain.calls); allocator.free(chain.puts); } @@ -494,77 +676,103 @@ fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []co return; } - var buf: [16384]u8 = undefined; + var buf: [32768]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; + try setBold(out, color); try out.print("\nOptions Chain for {s}\n", .{symbol}); + try reset(out, color); try out.print("========================================\n", .{}); if (ch[0].underlying_price) |price| { - try out.print("Underlying: ${d:.2}\n", .{price}); + var price_buf: [24]u8 = undefined; + try out.print("Underlying: {s} {d} expiration(s) +/- {d} strikes NTM\n", .{ fmt.fmtMoney(&price_buf, price), ch.len, ntm }); + } else { + try out.print("{d} expiration(s) available\n", .{ch.len}); } - try out.print("{d} expiration(s) available\n", .{ch.len}); - // List expirations - try out.print("\nExpirations:\n", .{}); - for (ch) |chain| { + // Find nearest monthly expiration to auto-expand + var auto_expand_idx: ?usize = null; + for (ch, 0..) |chain, ci| { + if (fmt.isMonthlyExpiration(chain.expiration)) { + auto_expand_idx = ci; + break; + } + } + // If no monthly found, expand the first one + if (auto_expand_idx == null and ch.len > 0) auto_expand_idx = 0; + + const atm_price = if (ch[0].underlying_price) |p| p else @as(f64, 0); + + // List all expirations, expanding the nearest monthly + for (ch, 0..) |chain, ci| { var db: [10]u8 = undefined; - try out.print(" {s} ({d} calls, {d} puts)\n", .{ - chain.expiration.format(&db), - chain.calls.len, - chain.puts.len, - }); - } + const is_monthly = fmt.isMonthlyExpiration(chain.expiration); + const is_expanded = auto_expand_idx != null and ci == auto_expand_idx.?; - // Show nearest expiration chain in detail - const nearest = ch[0]; - { - var db: [10]u8 = undefined; - try out.print("\nNearest Expiration: {s}\n", .{nearest.expiration.format(&db)}); - } - try out.print("{s:->64}\n", .{""}); - - // Calls - try out.print("\n CALLS\n", .{}); - try out.print(" {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8}\n", .{ - "Strike", "Last", "Bid", "Ask", "Volume", "OI", - }); - try out.print(" {s:->10} {s:->10} {s:->10} {s:->10} {s:->10} {s:->8}\n", .{ - "", "", "", "", "", "", - }); - for (nearest.calls) |c| { - try out.print(" {d:>10.2}", .{c.strike}); - if (c.last_price) |p| try out.print(" {d:>10.2}", .{p}) else try out.print(" {s:>10}", .{"--"}); - if (c.bid) |b| try out.print(" {d:>10.2}", .{b}) else try out.print(" {s:>10}", .{"--"}); - if (c.ask) |a| try out.print(" {d:>10.2}", .{a}) else try out.print(" {s:>10}", .{"--"}); - if (c.volume) |v| try out.print(" {d:>10}", .{v}) else try out.print(" {s:>10}", .{"--"}); - if (c.open_interest) |oi| try out.print(" {d:>8}", .{oi}) else try out.print(" {s:>8}", .{"--"}); try out.print("\n", .{}); - } + if (is_expanded) { + try setBold(out, color); + try out.print("{s} ({d} calls, {d} puts)", .{ + chain.expiration.format(&db), chain.calls.len, chain.puts.len, + }); + if (is_monthly) try out.print(" [monthly]", .{}); + try reset(out, color); + try out.print("\n", .{}); - // Puts - try out.print("\n PUTS\n", .{}); - try out.print(" {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8}\n", .{ - "Strike", "Last", "Bid", "Ask", "Volume", "OI", - }); - try out.print(" {s:->10} {s:->10} {s:->10} {s:->10} {s:->10} {s:->8}\n", .{ - "", "", "", "", "", "", - }); - for (nearest.puts) |p| { - try out.print(" {d:>10.2}", .{p.strike}); - if (p.last_price) |lp| try out.print(" {d:>10.2}", .{lp}) else try out.print(" {s:>10}", .{"--"}); - if (p.bid) |b| try out.print(" {d:>10.2}", .{b}) else try out.print(" {s:>10}", .{"--"}); - if (p.ask) |a| try out.print(" {d:>10.2}", .{a}) else try out.print(" {s:>10}", .{"--"}); - if (p.volume) |v| try out.print(" {d:>10}", .{v}) else try out.print(" {s:>10}", .{"--"}); - if (p.open_interest) |oi| try out.print(" {d:>8}", .{oi}) else try out.print(" {s:>8}", .{"--"}); - try out.print("\n", .{}); + // Print calls + try printOptionsSection(out, allocator, "CALLS", chain.calls, atm_price, ntm, true, color); + try out.print("\n", .{}); + // Print puts + try printOptionsSection(out, allocator, "PUTS", chain.puts, atm_price, ntm, false, color); + } else { + try setFg(out, color, if (is_monthly) CLR_HEADER else CLR_MUTED); + try out.print("{s} ({d} calls, {d} puts)", .{ + chain.expiration.format(&db), chain.calls.len, chain.puts.len, + }); + if (is_monthly) try out.print(" [monthly]", .{}); + try reset(out, color); + try out.print("\n", .{}); + } } try out.print("\n", .{}); try out.flush(); } -fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { +fn printOptionsSection( + out: anytype, + allocator: std.mem.Allocator, + label: []const u8, + contracts: []const zfin.OptionContract, + atm_price: f64, + ntm: usize, + is_calls: bool, + color: bool, +) !void { + try setBold(out, color); + try out.print(" {s}\n", .{label}); + try reset(out, color); + try setFg(out, color, CLR_MUTED); + try out.print(" {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}\n", .{ + "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV", + }); + try out.print(" {s:->10} {s:->10} {s:->10} {s:->10} {s:->10} {s:->8} {s:->8}\n", .{ + "", "", "", "", "", "", "", + }); + try reset(out, color); + + const filtered = fmt.filterNearMoney(contracts, atm_price, ntm); + for (filtered) |c| { + const itm = if (is_calls) c.strike <= atm_price else c.strike >= atm_price; + const prefix: []const u8 = if (itm) " |" else " "; + const line = try fmt.fmtContractLine(allocator, prefix, c); + defer allocator.free(line); + try out.print("{s}\n", .{line}); + } +} + +fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getEarnings(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n"); @@ -585,51 +793,75 @@ fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []c var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; + try setBold(out, color); try out.print("\nEarnings History for {s}\n", .{symbol}); + try reset(out, color); try out.print("========================================\n", .{}); if (ev.len == 0) { + try setFg(out, color, CLR_MUTED); try out.print(" No earnings data found.\n\n", .{}); + try reset(out, color); try out.flush(); return; } - try out.print("{s:>12} {s:>4} {s:>10} {s:>10} {s:>10} {s:>16} {s:>5}\n", .{ - "Date", "Q", "Estimate", "Actual", "Surprise", "Revenue", "When", + try setFg(out, color, CLR_MUTED); + try out.print("{s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10} {s:>5}\n", .{ + "Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", "When", }); - try out.print("{s:->12} {s:->4} {s:->10} {s:->10} {s:->10} {s:->16} {s:->5}\n", .{ + try out.print("{s:->12} {s:->4} {s:->12} {s:->12} {s:->12} {s:->10} {s:->5}\n", .{ "", "", "", "", "", "", "", }); + try reset(out, color); for (ev) |e| { var db: [10]u8 = undefined; + const is_future = e.isFuture(); + const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true; + + if (is_future) { + try setFg(out, color, CLR_MUTED); + } else if (surprise_positive) { + try setFg(out, color, CLR_GREEN); + } else { + try setFg(out, color, CLR_RED); + } + try out.print("{s:>12}", .{e.date.format(&db)}); if (e.quarter) |q| try out.print(" Q{d}", .{q}) else try out.print(" {s:>4}", .{"--"}); - if (e.estimate) |est| try out.print(" {d:>10.4}", .{est}) else try out.print(" {s:>10}", .{"--"}); - if (e.actual) |act| try out.print(" {d:>10.4}", .{act}) else try out.print(" {s:>10}", .{"--"}); + if (e.estimate) |est| try out.print(" {s:>12}", .{fmtEps(est)}) else try out.print(" {s:>12}", .{"--"}); + if (e.actual) |act| try out.print(" {s:>12}", .{fmtEps(act)}) else try out.print(" {s:>12}", .{"--"}); if (e.surpriseAmount()) |s| { - if (s >= 0) - try out.print(" +{d:.4}", .{s}) - else - try out.print(" {d:.4}", .{s}); + var surp_buf: [12]u8 = undefined; + const surp_str = if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?"; + try out.print(" {s:>12}", .{surp_str}); + } else { + try out.print(" {s:>12}", .{"--"}); + } + if (e.surprisePct()) |sp| { + var pct_buf: [12]u8 = undefined; + const pct_str = if (sp >= 0) std.fmt.bufPrint(&pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&pct_buf, "{d:.1}%", .{sp}) catch "?"; + try out.print(" {s:>10}", .{pct_str}); } else { try out.print(" {s:>10}", .{"--"}); } - if (e.revenue_actual) |rev| { - try out.print(" {s:>14}", .{formatLargeNum(rev)}); - } else if (e.revenue_estimate) |rev| { - try out.print(" ~{s:>14}", .{formatLargeNum(rev)}); - } else { - try out.print(" {s:>16}", .{"--"}); - } - try out.print(" {s:>5}\n", .{@tagName(e.report_time)}); + try out.print(" {s:>5}", .{@tagName(e.report_time)}); + try reset(out, color); + try out.print("\n", .{}); } try out.print("\n{d} earnings event(s)\n\n", .{ev.len}); try out.flush(); } -fn cmdEtf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { +fn fmtEps(val: f64) [12]u8 { + var buf: [12]u8 = .{' '} ** 12; + _ = std.fmt.bufPrint(&buf, "${d:.2}", .{val}) catch {}; + return buf; +} + +fn cmdEtf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool) !void { const result = svc.getEtfProfile(symbol) catch |err| switch (err) { zfin.DataError.NoApiKey => { try stderr_print("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); @@ -658,22 +890,24 @@ fn cmdEtf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const if (result.source == .cached) try stderr_print("(using cached ETF profile)\n"); - try printEtfProfile(profile, symbol); + try printEtfProfile(profile, symbol, color); } -fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8) !void { +fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8, color: bool) !void { var buf: [16384]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; + try setBold(out, color); try out.print("\nETF Profile: {s}\n", .{symbol}); + try reset(out, color); try out.print("========================================\n", .{}); if (profile.expense_ratio) |er| { try out.print(" Expense Ratio: {d:.2}%\n", .{er * 100.0}); } if (profile.net_assets) |na| { - try out.print(" Net Assets: ${s}\n", .{formatLargeNum(na)}); + try out.print(" Net Assets: ${s}\n", .{std.mem.trimRight(u8, &fmt.fmtLargeNum(na), &.{' '})}); } if (profile.dividend_yield) |dy| { try out.print(" Dividend Yield: {d:.2}%\n", .{dy * 100.0}); @@ -686,7 +920,9 @@ fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8) !void { try out.print(" Inception Date: {s}\n", .{d.format(&db)}); } if (profile.leveraged) { + try setFg(out, color, CLR_RED); try out.print(" Leveraged: YES\n", .{}); + try reset(out, color); } if (profile.total_holdings) |th| { try out.print(" Total Holdings: {d}\n", .{th}); @@ -695,9 +931,14 @@ fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8) !void { // Sectors if (profile.sectors) |sectors| { if (sectors.len > 0) { + try setBold(out, color); try out.print("\n Sector Allocation:\n", .{}); + try reset(out, color); for (sectors) |sec| { - try out.print(" {d:>5.1}% {s}\n", .{ sec.weight * 100.0, sec.sector }); + try setFg(out, color, CLR_ACCENT); + try out.print(" {d:>5.1}%", .{sec.weight * 100.0}); + try reset(out, color); + try out.print(" {s}\n", .{sec.sector}); } } } @@ -705,12 +946,19 @@ fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8) !void { // Top holdings if (profile.holdings) |holdings| { if (holdings.len > 0) { + try setBold(out, color); try out.print("\n Top Holdings:\n", .{}); + try reset(out, color); + try setFg(out, color, CLR_MUTED); try out.print(" {s:>6} {s:>7} {s}\n", .{ "Symbol", "Weight", "Name" }); try out.print(" {s:->6} {s:->7} {s:->30}\n", .{ "", "", "" }); + try reset(out, color); for (holdings) |h| { if (h.symbol) |s| { - try out.print(" {s:>6} {d:>6.2}% {s}\n", .{ s, h.weight * 100.0, h.name }); + try setFg(out, color, CLR_ACCENT); + try out.print(" {s:>6}", .{s}); + try reset(out, color); + try out.print(" {d:>6.2}% {s}\n", .{ h.weight * 100.0, h.name }); } else { try out.print(" {s:>6} {d:>6.2}% {s}\n", .{ "--", h.weight * 100.0, h.name }); } @@ -722,7 +970,7 @@ fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8) !void { try out.flush(); } -fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []const u8) !void { +fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []const u8, watchlist_path: ?[]const u8, color: bool) !void { // Load portfolio from SRF file const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| { try stderr_print("Error reading portfolio file: "); @@ -784,104 +1032,260 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] }; defer summary.deinit(allocator); - var buf: [16384]u8 = undefined; + var buf: [32768]u8 = undefined; var writer = std.fs.File.stdout().writer(&buf); const out = &writer.interface; - // Header + // Header with summary + try setBold(out, color); try out.print("\nPortfolio Summary ({s})\n", .{file_path}); + try reset(out, color); try out.print("========================================\n", .{}); + // Summary bar + { + var val_buf: [24]u8 = undefined; + var cost_buf: [24]u8 = undefined; + var gl_buf: [24]u8 = undefined; + const gl_abs = if (summary.unrealized_pnl >= 0) summary.unrealized_pnl else -summary.unrealized_pnl; + try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoney(&val_buf, summary.total_value), fmt.fmtMoney(&cost_buf, summary.total_cost) }); + try setGainLoss(out, color, summary.unrealized_pnl); + if (summary.unrealized_pnl >= 0) { + try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 }); + } else { + try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 }); + } + try reset(out, color); + try out.print("\n", .{}); + } + // Lot counts var open_lots: u32 = 0; var closed_lots: u32 = 0; for (portfolio.lots) |lot| { if (lot.isOpen()) open_lots += 1 else closed_lots += 1; } - try out.print(" Lots: {d} open, {d} closed\n", .{ open_lots, closed_lots }); - try out.print(" Positions: {d} symbols\n\n", .{positions.len}); + try setFg(out, color, CLR_MUTED); + try out.print(" Lots: {d} open, {d} closed Positions: {d} symbols\n", .{ open_lots, closed_lots, positions.len }); + try reset(out, color); - // Positions table - try out.print("{s:>6} {s:>8} {s:>10} {s:>10} {s:>12} {s:>10} {s:>8}\n", .{ - "Symbol", "Shares", "Avg Cost", "Price", "Mkt Value", "P&L", "Weight", + // Column headers + try out.print("\n", .{}); + try setFg(out, color, CLR_MUTED); + try out.print(" {s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}\n", .{ + "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account", }); - try out.print("{s:->6} {s:->8} {s:->10} {s:->10} {s:->12} {s:->10} {s:->8}\n", .{ - "", "", "", "", "", "", "", + try out.print(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8} {s:->13} {s:->8}\n", .{ + "", "", "", "", "", "", "", "", "", }); + try reset(out, color); + // Position rows with lot detail for (summary.allocations) |a| { - try out.print("{s:>6} {d:>8.1} {d:>10.2} {d:>10.2} {d:>12.2} ", .{ - a.symbol, a.shares, a.avg_cost, a.current_price, a.market_value, - }); - if (a.unrealized_pnl >= 0) { - try out.print("+{d:>9.2}", .{a.unrealized_pnl}); - } else { - try out.print("{d:>10.2}", .{a.unrealized_pnl}); + // Count lots for this symbol + var lots_for_sym: std.ArrayList(zfin.Lot) = .empty; + defer lots_for_sym.deinit(allocator); + for (portfolio.lots) |lot| { + if (std.mem.eql(u8, lot.symbol, a.symbol)) { + try lots_for_sym.append(allocator, lot); + } } - try out.print(" {d:>6.1}%\n", .{a.weight * 100.0}); - } + std.mem.sort(zfin.Lot, lots_for_sym.items, {}, fmt.lotSortFn); + const is_multi = lots_for_sym.items.len > 1; - try out.print("{s:->6} {s:->8} {s:->10} {s:->10} {s:->12} {s:->10} {s:->8}\n", .{ - "", "", "", "", "", "", "", - }); - try out.print("{s:>6} {s:>8} {s:>10} {s:>10} {d:>12.2} ", .{ - "", "", "", "TOTAL", summary.total_value, - }); - if (summary.unrealized_pnl >= 0) { - try out.print("+{d:>9.2}", .{summary.unrealized_pnl}); - } else { - try out.print("{d:>10.2}", .{summary.unrealized_pnl}); - } - try out.print(" {s:>7}\n", .{"100.0%"}); + // Position summary row + { + var mv_buf: [24]u8 = undefined; + var cost_buf2: [24]u8 = undefined; + var price_buf2: [24]u8 = undefined; + var gl_val_buf: [24]u8 = undefined; + const gl_abs = if (a.unrealized_pnl >= 0) a.unrealized_pnl else -a.unrealized_pnl; + const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs); + const sign: []const u8 = if (a.unrealized_pnl >= 0) "+" else "-"; - try out.print("\n Cost Basis: ${d:.2}\n", .{summary.total_cost}); - try out.print(" Market Value: ${d:.2}\n", .{summary.total_value}); - try out.print(" Unrealized P&L: ", .{}); - if (summary.unrealized_pnl >= 0) { - try out.print("+${d:.2} ({d:.2}%)\n", .{ summary.unrealized_pnl, summary.unrealized_return * 100.0 }); - } else { - try out.print("-${d:.2} ({d:.2}%)\n", .{ -summary.unrealized_pnl, summary.unrealized_return * 100.0 }); - } - if (summary.realized_pnl != 0) { - try out.print(" Realized P&L: ", .{}); - if (summary.realized_pnl >= 0) { - try out.print("+${d:.2}\n", .{summary.realized_pnl}); - } else { - try out.print("-${d:.2}\n", .{-summary.realized_pnl}); - } - } + // Date + ST/LT for single-lot positions + var date_col: [24]u8 = .{' '} ** 24; + var date_col_len: usize = 0; + if (!is_multi and lots_for_sym.items.len == 1) { + const lot = lots_for_sym.items[0]; + var pos_date_buf: [10]u8 = undefined; + const ds = lot.open_date.format(&pos_date_buf); + const indicator = fmt.capitalGainsIndicator(lot.open_date); + const written = std.fmt.bufPrint(&date_col, "{s} {s}", .{ ds, indicator }) catch ""; + date_col_len = written.len; + } - // Risk metrics for each position if we have candles cached - var store = zfin.cache.Store.init(allocator, config.cache_dir); - var any_risk = false; - - for (summary.allocations) |a| { - const cached = store.readRaw(a.symbol, .candles_daily) catch null; - if (cached) |cdata| { - defer allocator.free(cdata); - if (zfin.cache.Store.deserializeCandles(allocator, cdata)) |candles| { - defer allocator.free(candles); - if (zfin.risk.computeRisk(candles)) |metrics| { - if (!any_risk) { - try out.print("\n Risk Metrics (from cached price data):\n", .{}); - try out.print(" {s:>6} {s:>10} {s:>8} {s:>10}\n", .{ - "Symbol", "Volatility", "Sharpe", "Max DD", - }); - try out.print(" {s:->6} {s:->10} {s:->8} {s:->10}\n", .{ - "", "", "", "", - }); - any_risk = true; - } - try out.print(" {s:>6} {d:>9.1}% {d:>8.2} {d:>9.1}%", .{ - a.symbol, metrics.volatility * 100.0, metrics.sharpe, metrics.max_drawdown * 100.0, - }); - if (metrics.drawdown_trough) |dt| { - var db: [10]u8 = undefined; - try out.print(" (trough {s})", .{dt.format(&db)}); - } - try out.print("\n", .{}); + try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ + a.symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost), fmt.fmtMoney2(&price_buf2, a.current_price), fmt.fmtMoney(&mv_buf, a.market_value), + }); + try setGainLoss(out, color, a.unrealized_pnl); + try out.print("{s}{s:>13}", .{ sign, gl_money }); + try reset(out, color); + try out.print(" {d:>7.1}%", .{a.weight * 100.0}); + if (date_col_len > 0) { + try out.print(" {s}", .{date_col[0..date_col_len]}); + } + // Account for single-lot + if (!is_multi and lots_for_sym.items.len == 1) { + if (lots_for_sym.items[0].account) |acct| { + try out.print(" {s}", .{acct}); } - } else |_| {} + } + try out.print("\n", .{}); + } + + // Lot detail rows (always expanded for CLI) + if (is_multi) { + for (lots_for_sym.items) |lot| { + var lot_price_buf: [24]u8 = undefined; + var lot_date_buf: [10]u8 = undefined; + const date_str = lot.open_date.format(&lot_date_buf); + const indicator = fmt.capitalGainsIndicator(lot.open_date); + const status_str: []const u8 = if (lot.isOpen()) "open" else "closed"; + const acct_col: []const u8 = lot.account orelse ""; + + // Compute lot gain/loss + const use_price = lot.close_price orelse a.current_price; + const gl = lot.shares * (use_price - lot.open_price); + var lot_gl_buf: [24]u8 = undefined; + const lot_gl_abs = if (gl >= 0) gl else -gl; + const lot_gl_money = fmt.fmtMoney(&lot_gl_buf, lot_gl_abs); + const lot_sign: []const u8 = if (gl >= 0) "+" else "-"; + + try setFg(out, color, CLR_MUTED); + try out.print(" {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{ + status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", "", + }); + try reset(out, color); + try setGainLoss(out, color, gl); + try out.print("{s}{s:>13}", .{ lot_sign, lot_gl_money }); + try reset(out, color); + try setFg(out, color, CLR_MUTED); + try out.print(" {s:>8} {s} {s} {s}\n", .{ "", date_str, indicator, acct_col }); + try reset(out, color); + } + } + } + + // Totals line + try setFg(out, color, CLR_MUTED); + try out.print(" {s:->6} {s:->8} {s:->10} {s:->10} {s:->16} {s:->14} {s:->8}\n", .{ + "", "", "", "", "", "", "", + }); + try reset(out, color); + { + var total_mv_buf: [24]u8 = undefined; + var total_gl_buf: [24]u8 = undefined; + const gl_abs = if (summary.unrealized_pnl >= 0) summary.unrealized_pnl else -summary.unrealized_pnl; + try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {s:>16} ", .{ + "", "", "", "TOTAL", fmt.fmtMoney(&total_mv_buf, summary.total_value), + }); + try setGainLoss(out, color, summary.unrealized_pnl); + if (summary.unrealized_pnl >= 0) { + try out.print("+{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)}); + } else { + try out.print("-{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)}); + } + try reset(out, color); + try out.print(" {s:>7}\n", .{"100.0%"}); + } + + if (summary.realized_pnl != 0) { + var rpl_buf: [24]u8 = undefined; + const rpl_abs = if (summary.realized_pnl >= 0) summary.realized_pnl else -summary.realized_pnl; + try setGainLoss(out, color, summary.realized_pnl); + if (summary.realized_pnl >= 0) { + try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)}); + } else { + try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)}); + } + try reset(out, color); + } + + // Watchlist + 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 store = zfin.cache.Store.init(allocator, config.cache_dir); + + try out.print("\n", .{}); + try setBold(out, color); + try out.print(" Watchlist:\n", .{}); + try reset(out, color); + + 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) { + // Get price from cache if available + var price_str: [16]u8 = undefined; + var ps: []const u8 = "--"; + const cached = store.readRaw(sym, .candles_daily) catch null; + if (cached) |cdata| { + defer allocator.free(cdata); + if (zfin.cache.Store.deserializeCandles(allocator, cdata)) |candles| { + defer allocator.free(candles); + if (candles.len > 0) { + ps = fmt.fmtMoney2(&price_str, candles[candles.len - 1].close); + } + } else |_| {} + } + try out.print(" {s:<6} {s:>10}\n", .{ sym, ps }); + } + } + } + } + } + + // Risk metrics + { + var store = zfin.cache.Store.init(allocator, config.cache_dir); + var any_risk = false; + + for (summary.allocations) |a| { + const cached = store.readRaw(a.symbol, .candles_daily) catch null; + if (cached) |cdata| { + defer allocator.free(cdata); + if (zfin.cache.Store.deserializeCandles(allocator, cdata)) |candles| { + defer allocator.free(candles); + if (zfin.risk.computeRisk(candles)) |metrics| { + if (!any_risk) { + try out.print("\n", .{}); + try setBold(out, color); + try out.print(" Risk Metrics (from cached price data):\n", .{}); + try reset(out, color); + try setFg(out, color, CLR_MUTED); + try out.print(" {s:>6} {s:>10} {s:>8} {s:>10}\n", .{ + "Symbol", "Volatility", "Sharpe", "Max DD", + }); + try out.print(" {s:->6} {s:->10} {s:->8} {s:->10}\n", .{ + "", "", "", "", + }); + try reset(out, color); + any_risk = true; + } + try out.print(" {s:>6} {d:>9.1}% {d:>8.2} ", .{ + a.symbol, metrics.volatility * 100.0, metrics.sharpe, + }); + try setFg(out, color, CLR_RED); + try out.print("{d:>9.1}%", .{metrics.max_drawdown * 100.0}); + try reset(out, color); + if (metrics.drawdown_trough) |dt| { + var db: [10]u8 = undefined; + try setFg(out, color, CLR_MUTED); + try out.print(" (trough {s})", .{dt.format(&db)}); + try reset(out, color); + } + try out.print("\n", .{}); + } + } else |_| {} + } } } @@ -889,20 +1293,6 @@ fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: [] try out.flush(); } -fn formatLargeNum(val: f64) [15]u8 { - var result: [15]u8 = .{' '} ** 15; - if (val >= 1_000_000_000_000) { - _ = std.fmt.bufPrint(&result, "{d:.1}T", .{val / 1_000_000_000_000}) catch {}; - } else if (val >= 1_000_000_000) { - _ = std.fmt.bufPrint(&result, "{d:.1}B", .{val / 1_000_000_000}) catch {}; - } else if (val >= 1_000_000) { - _ = std.fmt.bufPrint(&result, "{d:.1}M", .{val / 1_000_000}) catch {}; - } else { - _ = std.fmt.bufPrint(&result, "{d:.0}", .{val}) catch {}; - } - return result; -} - fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8) !void { if (std.mem.eql(u8, subcommand, "stats")) { var buf: [4096]u8 = undefined; @@ -914,7 +1304,6 @@ fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []con try out.flush(); return; }; - // List symbol directories var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch { try out.print(" (empty -- no cached data)\n", .{}); try out.flush(); @@ -944,11 +1333,7 @@ fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []con } } -fn todayDate() zfin.Date { - const ts = std.time.timestamp(); - const days: i32 = @intCast(@divFloor(ts, 86400)); - return .{ .days = days }; -} +// ── Output helpers ─────────────────────────────────────────── fn stdout_print(msg: []const u8) !void { var buf: [4096]u8 = undefined; diff --git a/src/format.zig b/src/format.zig new file mode 100644 index 0000000..2256526 --- /dev/null +++ b/src/format.zig @@ -0,0 +1,529 @@ +//! Shared formatting utilities used by both CLI and TUI. +//! +//! Number formatting (fmtMoney, fmtIntCommas, etc.), financial helpers +//! (capitalGainsIndicator, filterNearMoney), and braille chart computation. + +const std = @import("std"); +const Date = @import("models/date.zig").Date; +const Candle = @import("models/candle.zig").Candle; +const OptionContract = @import("models/option.zig").OptionContract; +const Lot = @import("models/portfolio.zig").Lot; + +// ── Number formatters ──────────────────────────────────────── + +/// Format a dollar amount with commas and 2 decimals: $1,234.56 +pub fn fmtMoney(buf: []u8, amount: f64) []const u8 { + const cents = @as(i64, @intFromFloat(@round(amount * 100.0))); + const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents)); + const dollars = abs_cents / 100; + const rem = abs_cents % 100; + + var tmp: [24]u8 = undefined; + var pos: usize = tmp.len; + + // Cents + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(rem % 10)); + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(rem / 10)); + pos -= 1; + tmp[pos] = '.'; + + // Dollars with commas + var d = dollars; + var digit_count: usize = 0; + if (d == 0) { + pos -= 1; + tmp[pos] = '0'; + } else { + while (d > 0) { + if (digit_count > 0 and digit_count % 3 == 0) { + pos -= 1; + tmp[pos] = ','; + } + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(d % 10)); + d /= 10; + digit_count += 1; + } + } + pos -= 1; + tmp[pos] = '$'; + + const len = tmp.len - pos; + if (len > buf.len) return "$?"; + @memcpy(buf[0..len], tmp[pos..]); + return buf[0..len]; +} + +/// Format price with 2 decimals (no commas, for per-share prices): $185.23 +pub fn fmtMoney2(buf: []u8, amount: f64) []const u8 { + return std.fmt.bufPrint(buf, "${d:.2}", .{amount}) catch "$?"; +} + +/// Format an integer with commas (e.g. 1234567 -> "1,234,567"). +pub fn fmtIntCommas(buf: []u8, value: u64) []const u8 { + var tmp: [32]u8 = undefined; + var pos: usize = tmp.len; + var v = value; + var digit_count: usize = 0; + if (v == 0) { + pos -= 1; + tmp[pos] = '0'; + } else { + while (v > 0) { + if (digit_count > 0 and digit_count % 3 == 0) { + pos -= 1; + tmp[pos] = ','; + } + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(v % 10)); + v /= 10; + digit_count += 1; + } + } + const len = tmp.len - pos; + if (len > buf.len) return "?"; + @memcpy(buf[0..len], tmp[pos..]); + return buf[0..len]; +} + +/// Format a unix timestamp as relative time ("just now", "5m ago", "2h ago", "3d ago"). +pub fn fmtTimeAgo(buf: []u8, timestamp: i64) []const u8 { + if (timestamp == 0) return ""; + const now = std.time.timestamp(); + const delta = now - timestamp; + if (delta < 0) return "just now"; + if (delta < 60) return "just now"; + if (delta < 3600) { + return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divFloor(delta, 60)))}) catch "?"; + } + if (delta < 86400) { + return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, 3600)))}) catch "?"; + } + return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, 86400)))}) catch "?"; +} + +/// Format large numbers with T/B/M suffixes (e.g. "1.5B", "45.6M"). +pub fn fmtLargeNum(val: f64) [15]u8 { + var result: [15]u8 = .{' '} ** 15; + if (val >= 1_000_000_000_000) { + _ = std.fmt.bufPrint(&result, "{d:.1}T", .{val / 1_000_000_000_000}) catch {}; + } else if (val >= 1_000_000_000) { + _ = std.fmt.bufPrint(&result, "{d:.1}B", .{val / 1_000_000_000}) catch {}; + } else if (val >= 1_000_000) { + _ = std.fmt.bufPrint(&result, "{d:.1}M", .{val / 1_000_000}) catch {}; + } else { + _ = std.fmt.bufPrint(&result, "{d:.0}", .{val}) catch {}; + } + return result; +} + +// ── Date / financial helpers ───────────────────────────────── + +/// Get today's date. +pub fn todayDate() Date { + const ts = std.time.timestamp(); + const days: i32 = @intCast(@divFloor(ts, 86400)); + return .{ .days = days }; +} + +/// Return "LT" if held > 1 year from open_date to today, "ST" otherwise. +pub fn capitalGainsIndicator(open_date: Date) []const u8 { + const today = todayDate(); + return if (today.days - open_date.days > 365) "LT" else "ST"; +} + +/// Return a slice view of candles on or after the given date (no allocation). +pub fn filterCandlesFrom(candles: []const Candle, from: Date) []const Candle { + var lo: usize = 0; + var hi: usize = candles.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (candles[mid].date.lessThan(from)) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo >= candles.len) return candles[0..0]; + return candles[lo..]; +} + +// ── Options helpers ────────────────────────────────────────── + +/// Filter options contracts to +/- N strikes from ATM. +pub fn filterNearMoney(contracts: []const OptionContract, atm: f64, n: usize) []const OptionContract { + if (atm <= 0 or contracts.len == 0) return contracts; + + var best_idx: usize = 0; + var best_dist: f64 = @abs(contracts[0].strike - atm); + for (contracts, 0..) |c, i| { + const dist = @abs(c.strike - atm); + if (dist < best_dist) { + best_dist = dist; + best_idx = i; + } + } + + const start = if (best_idx >= n) best_idx - n else 0; + const end = @min(best_idx + n + 1, contracts.len); + return contracts[start..end]; +} + +/// Check if an expiration date is a standard monthly (3rd Friday of the month). +pub fn isMonthlyExpiration(date: Date) bool { + const dow = date.dayOfWeek(); // 0=Mon..4=Fri + if (dow != 4) return false; // Must be Friday + const d = date.day(); + return d >= 15 and d <= 21; // 3rd Friday is between 15th and 21st +} + +/// Format an options contract line: strike + last + bid + ask + volume + OI + IV. +pub fn fmtContractLine(alloc: std.mem.Allocator, prefix: []const u8, c: OptionContract) ![]const u8 { + var last_buf: [12]u8 = undefined; + const last_str = if (c.last_price) |p| std.fmt.bufPrint(&last_buf, "{d:>10.2}", .{p}) catch "--" else "--"; + var bid_buf: [12]u8 = undefined; + const bid_str = if (c.bid) |b| std.fmt.bufPrint(&bid_buf, "{d:>10.2}", .{b}) catch "--" else "--"; + var ask_buf: [12]u8 = undefined; + const ask_str = if (c.ask) |a| std.fmt.bufPrint(&ask_buf, "{d:>10.2}", .{a}) catch "--" else "--"; + var vol_buf: [12]u8 = undefined; + const vol_str = if (c.volume) |v| std.fmt.bufPrint(&vol_buf, "{d:>10}", .{v}) catch "--" else "--"; + var oi_buf: [10]u8 = undefined; + const oi_str = if (c.open_interest) |oi| std.fmt.bufPrint(&oi_buf, "{d:>8}", .{oi}) catch "--" else "--"; + var iv_buf: [10]u8 = undefined; + const iv_str = if (c.implied_volatility) |iv| std.fmt.bufPrint(&iv_buf, "{d:>6.1}%", .{iv * 100.0}) catch "--" else "--"; + + return std.fmt.allocPrint(alloc, "{s}{d:>10.2} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}", .{ + prefix, c.strike, last_str, bid_str, ask_str, vol_str, oi_str, iv_str, + }); +} + +// ── Portfolio helpers ──────────────────────────────────────── + +/// Sort lots: open lots first (date descending), closed lots last (date descending). +pub fn lotSortFn(_: void, a: Lot, b: Lot) bool { + const a_open = a.isOpen(); + const b_open = b.isOpen(); + if (a_open and !b_open) return true; // open before closed + if (!a_open and b_open) return false; + return a.open_date.days > b.open_date.days; // newest first +} + +// ── Color helpers ──────────────────────────────────────────── + +/// Interpolate color between two RGB values. t in [0.0, 1.0]. +pub fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 { + return .{ + @intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t), + @intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t), + @intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t), + }; +} + +// ── Braille chart ──────────────────────────────────────────── + +/// Braille dot patterns for the 2x4 matrix within each character cell. +/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08 +/// [1][4] dot1=0x02, dot4=0x10 +/// [2][5] dot2=0x04, dot5=0x20 +/// [6][7] dot6=0x40, dot7=0x80 +pub const braille_dots = [4][2]u8{ + .{ 0x01, 0x08 }, // row 0 (top) + .{ 0x02, 0x10 }, // row 1 + .{ 0x04, 0x20 }, // row 2 + .{ 0x40, 0x80 }, // row 3 (bottom) +}; + +/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF). +/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo. +pub const braille_utf8 = blk: { + var table: [256][3]u8 = undefined; + for (0..256) |i| { + const cp: u21 = 0x2800 + @as(u21, @intCast(i)); + table[i] = .{ + @as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))), + @as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))), + @as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))), + }; + } + break :blk table; +}; + +/// Return a static-lifetime grapheme slice for a braille pattern byte. +pub fn brailleGlyph(pattern: u8) []const u8 { + return &braille_utf8[pattern]; +} + +/// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis). +pub const BrailleChart = struct { + /// Braille pattern bytes: patterns[row * n_cols + col] + patterns: []u8, + /// RGB color per data column + col_colors: [][3]u8, + n_cols: usize, + chart_height: usize, + max_label: [16]u8, + max_label_len: usize, + min_label: [16]u8, + min_label_len: usize, + /// Date of first candle in the chart data + start_date: Date, + /// Date of last candle in the chart data + end_date: Date, + + pub fn maxLabel(self: *const BrailleChart) []const u8 { + return self.max_label[0..self.max_label_len]; + } + + pub fn minLabel(self: *const BrailleChart) []const u8 { + return self.min_label[0..self.min_label_len]; + } + + pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 { + return self.patterns[row * self.n_cols + col]; + } + + /// Format a date as "MMM DD" or "MMM 'YY" depending on whether it's the same year as `ref_year`. + /// Returns the number of bytes written. + pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 { + const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + const m = date.month(); + const d = date.day(); + const y = date.year(); + const mon = if (m >= 1 and m <= 12) months[m - 1] else "???"; + // Use "MMM DD 'YY" is too long (10 chars). Use "MMM 'YY" (7 chars) for year context, + // or "MMM DD" (6 chars) for day-level precision. We'll use "MMM DD" for compactness + // and add the year as a separate concern if dates span multiple years. + // Actually let's just use "YYYY-MM-DD" is too long. "Mon DD" is 6 chars. + buf[0] = mon[0]; + buf[1] = mon[1]; + buf[2] = mon[2]; + buf[3] = ' '; + if (d >= 10) { + buf[4] = '0' + d / 10; + } else { + buf[4] = '0'; + } + buf[5] = '0' + d % 10; + // If we want to show year when it differs, store in extra chars: + _ = y; + return buf[0..6]; + } + + pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void { + alloc.free(self.patterns); + alloc.free(self.col_colors); + } +}; + +/// Compute braille sparkline chart data from candle close prices. +/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell. +/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point. +/// +/// Returns a BrailleChart with the pattern grid and per-column colors. +/// Caller must call deinit() when done (unless using an arena allocator). +pub fn computeBrailleChart( + alloc: std.mem.Allocator, + data: []const Candle, + chart_width: usize, + chart_height: usize, + positive_color: [3]u8, + negative_color: [3]u8, +) !BrailleChart { + if (data.len < 2) return error.InsufficientData; + + const dot_rows: usize = chart_height * 4; // vertical dot resolution + + // Find min/max close prices + var min_price: f64 = data[0].close; + var max_price: f64 = data[0].close; + for (data) |d| { + if (d.close < min_price) min_price = d.close; + if (d.close > max_price) max_price = d.close; + } + if (max_price == min_price) max_price = min_price + 1.0; + const price_range = max_price - min_price; + + // Price labels + var result: BrailleChart = undefined; + const max_str = std.fmt.bufPrint(&result.max_label, "${d:.0}", .{max_price}) catch ""; + result.max_label_len = max_str.len; + const min_str = std.fmt.bufPrint(&result.min_label, "${d:.0}", .{min_price}) catch ""; + result.min_label_len = min_str.len; + + const n_cols = @min(data.len, chart_width); + result.n_cols = n_cols; + result.chart_height = chart_height; + result.start_date = data[0].date; + result.end_date = data[data.len - 1].date; + + // Map each data column to a dot-row position and color + const dot_y = try alloc.alloc(usize, n_cols); + defer alloc.free(dot_y); + + result.col_colors = try alloc.alloc([3]u8, n_cols); + errdefer alloc.free(result.col_colors); + + for (0..n_cols) |col| { + const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1)); + const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1); + const close = data[data_idx].close; + const norm = (close - min_price) / price_range; // 0 = min, 1 = max + // Inverted: 0 = top dot row, dot_rows-1 = bottom + const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1)); + dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1); + // Color: gradient from negative (bottom) to positive (top) + result.col_colors[col] = lerpColor(negative_color, positive_color, norm); + } + + // Build the braille pattern grid + result.patterns = try alloc.alloc(u8, chart_height * n_cols); + @memset(result.patterns, 0); + + for (0..n_cols) |col| { + const target_y = dot_y[col]; + // Fill from target_y down to the bottom + for (target_y..dot_rows) |dy| { + const term_row = dy / 4; + const sub_row = dy % 4; + result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0]; + } + + // Interpolate between this point and the next for smooth contour + if (col + 1 < n_cols) { + const y0 = dot_y[col]; + const y1 = dot_y[col + 1]; + const min_y = @min(y0, y1); + const max_y = @max(y0, y1); + for (min_y..max_y + 1) |dy| { + const term_row = dy / 4; + const sub_row = dy % 4; + result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0]; + } + } + } + + return result; +} + +/// Write a braille chart to a writer with ANSI color escapes. +/// Used by the CLI for terminal output. +pub fn writeBrailleAnsi( + out: anytype, + chart: *const BrailleChart, + use_color: bool, + muted_color: [3]u8, +) !void { + var last_r: u8 = 0; + var last_g: u8 = 0; + var last_b: u8 = 0; + var color_active = false; + + for (0..chart.chart_height) |row| { + try out.writeAll(" "); // 2 leading spaces + + for (0..chart.n_cols) |col| { + const pat = chart.pattern(row, col); + if (use_color and pat != 0) { + const c = chart.col_colors[col]; + // Only emit color escape if color changed + if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) { + try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] }); + last_r = c[0]; + last_g = c[1]; + last_b = c[2]; + color_active = true; + } + } else if (color_active and pat == 0) { + try out.writeAll("\x1b[0m"); + color_active = false; + } + try out.writeAll(brailleGlyph(pat)); + } + + if (color_active) { + try out.writeAll("\x1b[0m"); + color_active = false; + } + + // Price label on first/last row + if (row == 0) { + if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); + try out.print(" {s}", .{chart.maxLabel()}); + if (use_color) try out.writeAll("\x1b[0m"); + } else if (row == chart.chart_height - 1) { + if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); + try out.print(" {s}", .{chart.minLabel()}); + if (use_color) try out.writeAll("\x1b[0m"); + } + try out.writeAll("\n"); + } + + // Date axis below chart + var start_buf: [7]u8 = undefined; + var end_buf: [7]u8 = undefined; + const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf); + const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf); + + if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); + try out.writeAll(" "); // match leading indent + try out.writeAll(start_label); + // Fill gap between start and end labels + const total_width = chart.n_cols; + if (total_width > start_label.len + end_label.len) { + const gap = total_width - start_label.len - end_label.len; + for (0..gap) |_| try out.writeAll(" "); + } + try out.writeAll(end_label); + if (use_color) try out.writeAll("\x1b[0m"); + try out.writeAll("\n"); +} + +// ── ANSI color helpers (for CLI) ───────────────────────────── + +/// Determine whether to use ANSI color output. +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); +} + +/// Write an ANSI 24-bit foreground color escape. +pub fn ansiSetFg(out: anytype, r: u8, g: u8, b: u8) !void { + try out.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b }); +} + +/// Write ANSI bold. +pub fn ansiBold(out: anytype) !void { + try out.writeAll("\x1b[1m"); +} + +/// Write ANSI dim. +pub fn ansiDim(out: anytype) !void { + try out.writeAll("\x1b[2m"); +} + +/// Reset all ANSI attributes. +pub fn ansiReset(out: anytype) !void { + try out.writeAll("\x1b[0m"); +} + +// ── Tests ──────────────────────────────────────────────────── + +test "fmtMoney" { + var buf: [24]u8 = undefined; + try std.testing.expectEqualStrings("$0.00", fmtMoney(&buf, 0)); + try std.testing.expectEqualStrings("$1.23", fmtMoney(&buf, 1.23)); + try std.testing.expectEqualStrings("$1,234.56", fmtMoney(&buf, 1234.56)); + try std.testing.expectEqualStrings("$1,234,567.89", fmtMoney(&buf, 1234567.89)); +} + +test "fmtIntCommas" { + var buf: [32]u8 = undefined; + try std.testing.expectEqualStrings("0", fmtIntCommas(&buf, 0)); + try std.testing.expectEqualStrings("999", fmtIntCommas(&buf, 999)); + try std.testing.expectEqualStrings("1,000", fmtIntCommas(&buf, 1000)); + try std.testing.expectEqualStrings("1,234,567", fmtIntCommas(&buf, 1234567)); +} diff --git a/src/models/etf_profile.zig b/src/models/etf_profile.zig index 74223c3..f0fdf3b 100644 --- a/src/models/etf_profile.zig +++ b/src/models/etf_profile.zig @@ -40,4 +40,14 @@ pub const EtfProfile = struct { inception_date: ?Date = null, /// Whether the fund is leveraged leveraged: bool = false, + + /// Returns true if the profile contains meaningful ETF data. + /// Non-ETF symbols return empty profiles from Alpha Vantage. + pub fn isEtf(self: EtfProfile) bool { + return self.expense_ratio != null or + self.net_assets != null or + self.holdings != null or + self.sectors != null or + self.total_holdings != null; + } }; diff --git a/src/providers/cboe.zig b/src/providers/cboe.zig index 55208ac..204a13c 100644 --- a/src/providers/cboe.zig +++ b/src/providers/cboe.zig @@ -135,10 +135,8 @@ fn parseResponse( } // Convert to sorted OptionsChain slice - const owned_symbol = allocator.dupe(u8, symbol) catch return provider.ProviderError.OutOfMemory; - errdefer allocator.free(owned_symbol); - - return exp_map.toOwnedChains(allocator, owned_symbol, underlying_price); + // Each chain gets its own dupe of the symbol so callers can free them independently. + return exp_map.toOwnedChains(allocator, symbol, underlying_price); } // ── OCC symbol parsing ────────────────────────────────────────────── @@ -212,10 +210,11 @@ const ExpMap = struct { /// Convert to owned []OptionsChain, sorted by expiration ascending. /// Frees internal structures; caller owns the returned chains. + /// Each chain gets its own dupe of `symbol` so callers can free them independently. fn toOwnedChains( self: *ExpMap, allocator: std.mem.Allocator, - owned_symbol: []const u8, + symbol: []const u8, underlying_price: ?f64, ) provider.ProviderError![]OptionsChain { // Sort entries by expiration @@ -230,6 +229,8 @@ const ExpMap = struct { errdefer allocator.free(chains); for (self.entries.items, 0..) |*entry, i| { + const owned_symbol = allocator.dupe(u8, symbol) catch + return provider.ProviderError.OutOfMemory; const calls = entry.calls.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; const puts = entry.puts.toOwnedSlice(allocator) catch { diff --git a/src/root.zig b/src/root.zig index 942c3c2..8b83fc7 100644 --- a/src/root.zig +++ b/src/root.zig @@ -38,6 +38,9 @@ pub const performance = @import("analytics/performance.zig"); pub const risk = @import("analytics/risk.zig"); pub const indicators = @import("analytics/indicators.zig"); +// -- Formatting (shared between CLI and TUI) -- +pub const format = @import("format.zig"); + // -- Service layer -- pub const DataService = @import("service.zig").DataService; pub const DataError = @import("service.zig").DataError; diff --git a/src/tui/chart.zig b/src/tui/chart.zig new file mode 100644 index 0000000..2c62780 --- /dev/null +++ b/src/tui/chart.zig @@ -0,0 +1,529 @@ +//! Financial chart renderer using z2d. +//! Renders price + Bollinger Bands, volume bars, and RSI panel to raw RGB pixel data +//! suitable for Kitty graphics protocol transmission. + +const std = @import("std"); +const z2d = @import("z2d"); +const zfin = @import("zfin"); +const theme_mod = @import("theme.zig"); + +const Surface = z2d.Surface; + +/// Chart rendering mode. +pub const ChartMode = enum { + /// Auto-detect: use Kitty graphics if terminal supports it, otherwise braille. + auto, + /// Force braille chart (no pixel graphics). + braille, + /// Kitty graphics with a custom resolution cap (width x height). + kitty, +}; + +/// Chart graphics configuration. +pub const ChartConfig = struct { + mode: ChartMode = .auto, + max_width: u32 = 1920, + max_height: u32 = 1080, + + /// Parse a --chart argument value. + /// Accepted formats: + /// "auto" — auto-detect (default) + /// "braille" — force braille + /// "WxH" — Kitty graphics with custom resolution (e.g. "1920x1080") + pub fn parse(value: []const u8) ?ChartConfig { + if (std.mem.eql(u8, value, "auto")) return .{ .mode = .auto }; + if (std.mem.eql(u8, value, "braille")) return .{ .mode = .braille }; + + // Try WxH format + if (std.mem.indexOfScalar(u8, value, 'x')) |sep| { + const w = std.fmt.parseInt(u32, value[0..sep], 10) catch return null; + const h = std.fmt.parseInt(u32, value[sep + 1 ..], 10) catch return null; + if (w < 100 or h < 100) return null; + return .{ .mode = .kitty, .max_width = w, .max_height = h }; + } + return null; + } +}; +const Context = z2d.Context; +const Path = z2d.Path; +const Pixel = z2d.Pixel; +const Color = z2d.Color; + +/// Chart timeframe selection. +pub const Timeframe = enum { + @"6M", + ytd, + @"1Y", + @"3Y", + @"5Y", + + pub fn label(self: Timeframe) []const u8 { + return switch (self) { + .@"6M" => "6M", + .ytd => "YTD", + .@"1Y" => "1Y", + .@"3Y" => "3Y", + .@"5Y" => "5Y", + }; + } + + pub fn tradingDays(self: Timeframe) usize { + return switch (self) { + .@"6M" => 126, + .ytd => 252, // approximation, we'll clamp + .@"1Y" => 252, + .@"3Y" => 756, + .@"5Y" => 1260, + }; + } + + pub fn next(self: Timeframe) Timeframe { + return switch (self) { + .@"6M" => .ytd, + .ytd => .@"1Y", + .@"1Y" => .@"3Y", + .@"3Y" => .@"5Y", + .@"5Y" => .@"6M", + }; + } + + pub fn prev(self: Timeframe) Timeframe { + return switch (self) { + .@"6M" => .@"5Y", + .ytd => .@"6M", + .@"1Y" => .ytd, + .@"3Y" => .@"1Y", + .@"5Y" => .@"3Y", + }; + } +}; + +/// Layout constants (fractions of total height). +const price_frac: f64 = 0.72; // price panel takes 72% +const rsi_frac: f64 = 0.20; // RSI panel takes 20% +const gap_frac: f64 = 0.08; // gap between panels + +/// Margins in pixels. +const margin_left: f64 = 4; +const margin_right: f64 = 4; +const margin_top: f64 = 4; +const margin_bottom: f64 = 4; + +/// Chart render result — raw RGB pixel data ready for Kitty graphics transmission. +pub const ChartResult = struct { + /// Raw RGB pixel data (3 bytes per pixel, row-major). + rgb_data: []const u8, + width: u16, + height: u16, + /// Price range for external label rendering. + price_min: f64, + price_max: f64, + /// Latest RSI value (or null if not enough data). + 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( + alloc: std.mem.Allocator, + candles: []const zfin.Candle, + timeframe: Timeframe, + width_px: u32, + height_px: u32, + th: theme_mod.Theme, +) !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 ..]; + + // Extract data series + const closes = try zfin.indicators.closePrices(alloc, data); + defer alloc.free(closes); + const vols = try zfin.indicators.volumes(alloc, data); + defer alloc.free(vols); + + // Compute indicators + const bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0); + defer alloc.free(bb); + const rsi_vals = try zfin.indicators.rsi(alloc, closes, 14); + defer alloc.free(rsi_vals); + + // 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 + // compositing semi-transparent fills (alpha < 255). + const w: i32 = @intCast(width_px); + const h: i32 = @intCast(height_px); + var sfc = try Surface.init(.image_surface_rgb, alloc, w, h); + defer sfc.deinit(alloc); + + // Create drawing context + var ctx = Context.init(alloc, &sfc); + defer ctx.deinit(); + + // Disable anti-aliasing and use direct pixel writes (.source operator) + // to avoid integer overflow bugs in z2d's src_over compositor. + // Semi-transparent colors are pre-blended against bg in blendColor(). + ctx.setAntiAliasingMode(.none); + ctx.setOperator(.src); + + const bg = th.bg; + const fwidth: f64 = @floatFromInt(width_px); + const fheight: f64 = @floatFromInt(height_px); + + // Background + ctx.setSourceToPixel(opaqueColor(bg)); + ctx.resetPath(); + try ctx.moveTo(0, 0); + try ctx.lineTo(fwidth, 0); + try ctx.lineTo(fwidth, fheight); + try ctx.lineTo(0, fheight); + try ctx.closePath(); + try ctx.fill(); + + // Panel dimensions + const chart_left = margin_left; + const chart_right = fwidth - margin_right; + const chart_w = chart_right - chart_left; + const chart_top = margin_top; + const total_h = fheight - margin_top - margin_bottom; + + const price_h = total_h * price_frac; + const price_top = chart_top; + const price_bottom = price_top + price_h; + + const gap_h = total_h * gap_frac; + + const rsi_h = total_h * rsi_frac; + const rsi_top = price_bottom + gap_h; + const rsi_bottom = rsi_top + rsi_h; + + // Price range (include Bollinger bands in range) + var price_min: f64 = closes[0]; + var price_max: f64 = closes[0]; + for (closes) |c| { + if (c < price_min) price_min = c; + if (c > price_max) price_max = c; + } + for (bb) |b_opt| { + if (b_opt) |b| { + if (b.lower < price_min) price_min = b.lower; + if (b.upper > price_max) price_max = b.upper; + } + } + // Add 5% padding + const price_pad = (price_max - price_min) * 0.05; + price_min -= price_pad; + price_max += price_pad; + + // Volume max + var vol_max: f64 = 0; + for (vols) |v| { + if (v > vol_max) vol_max = v; + } + if (vol_max == 0) vol_max = 1; + + // Helper: map data index to x + const x_step = chart_w / @as(f64, @floatFromInt(data.len - 1)); + + // ── 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); + + // ── Volume bars (overlaid on price panel bottom 25%) ───────────── + { + const vol_panel_h = price_h * 0.25; + const vol_bottom_y = price_bottom; + const bar_w = @max(x_step * 0.7, 1.0); + + for (data, 0..) |candle, ci| { + const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; + const vol_h_px = (vols[ci] / vol_max) * vol_panel_h; + const bar_top = vol_bottom_y - vol_h_px; + + const is_up = candle.close >= candle.open; + const col = if (is_up) blendColor(th.positive, 50, bg) else blendColor(th.negative, 50, bg); + ctx.setSourceToPixel(col); + ctx.resetPath(); + try ctx.moveTo(x - bar_w / 2, bar_top); + try ctx.lineTo(x + bar_w / 2, bar_top); + try ctx.lineTo(x + bar_w / 2, vol_bottom_y); + try ctx.lineTo(x - bar_w / 2, vol_bottom_y); + try ctx.closePath(); + try ctx.fill(); + } + } + + // ── Bollinger Bands fill (drawn FIRST so price fill paints over it) ── + { + const band_fill_color = blendColor(th.accent, 25, bg); + ctx.setSourceToPixel(band_fill_color); + ctx.resetPath(); + + var started = false; + for (bb, 0..) |b_opt, ci| { + if (b_opt) |b| { + const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; + const y = mapY(b.upper, price_min, price_max, price_top, price_bottom); + if (!started) { + try ctx.moveTo(x, y); + started = true; + } else { + try ctx.lineTo(x, y); + } + } + } + if (started) { + var ci: usize = data.len; + while (ci > 0) { + ci -= 1; + if (bb[ci]) |b| { + const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; + const y = mapY(b.lower, price_min, price_max, price_top, price_bottom); + try ctx.lineTo(x, y); + } + } + try ctx.closePath(); + try ctx.fill(); + } + } + + // ── Price filled area (on top of BB fill) ────────────────────────── + { + const start_price = closes[0]; + const end_price = closes[closes.len - 1]; + const fill_color = if (end_price >= start_price) blendColor(th.positive, 30, bg) else blendColor(th.negative, 30, bg); + ctx.setSourceToPixel(fill_color); + ctx.resetPath(); + for (closes, 0..) |c, ci| { + const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; + const y = mapY(c, price_min, price_max, price_top, price_bottom); + if (ci == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + const last_x = chart_left + @as(f64, @floatFromInt(closes.len - 1)) * x_step; + try ctx.lineTo(last_x, price_bottom); + try ctx.lineTo(chart_left, price_bottom); + try ctx.closePath(); + try ctx.fill(); + } + + // ── 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); + // 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); + } + + // ── Price line (on top of everything) ───────────────────────────── + { + const start_price = closes[0]; + const end_price = closes[closes.len - 1]; + const price_color = if (end_price >= start_price) opaqueColor(th.positive) else opaqueColor(th.negative); + + ctx.setSourceToPixel(price_color); + ctx.setLineWidth(2.0); + ctx.resetPath(); + for (closes, 0..) |c, ci| { + const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; + const y = mapY(c, price_min, price_max, price_top, price_bottom); + if (ci == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y); + } + try ctx.stroke(); + } + + // ── 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); + + const rsi_color = blendColor(th.info, 220, bg); + ctx.setSourceToPixel(rsi_color); + ctx.setLineWidth(1.5); + ctx.resetPath(); + var rsi_started = false; + for (rsi_vals, 0..) |r_opt, ci| { + if (r_opt) |r| { + const x = chart_left + @as(f64, @floatFromInt(ci)) * x_step; + const y = mapY(r, 0, 100, rsi_top, rsi_bottom); + if (!rsi_started) { + try ctx.moveTo(x, y); + rsi_started = true; + } else { + try ctx.lineTo(x, y); + } + } + } + if (rsi_started) try ctx.stroke(); + } + + // ── 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); + } + + // Get latest RSI + var rsi_latest: ?f64 = null; + { + var ri: usize = rsi_vals.len; + while (ri > 0) { + ri -= 1; + if (rsi_vals[ri]) |r| { + rsi_latest = r; + break; + } + } + } + + // Extract raw RGB pixel data from the z2d surface buffer. + // The surface is image_surface_rgb, so the buffer is []pixel.RGB (packed u24). + // We need to convert to a flat []u8 of R,G,B triplets. + const rgb_buf = switch (sfc) { + .image_surface_rgb => |s| s.buf, + else => unreachable, + }; + const pixel_count = rgb_buf.len; + const raw = try alloc.alloc(u8, pixel_count * 3); + for (rgb_buf, 0..) |px, i| { + raw[i * 3 + 0] = px.r; + raw[i * 3 + 1] = px.g; + raw[i * 3 + 2] = px.b; + } + return .{ + .rgb_data = raw, + .width = @intCast(width_px), + .height = @intCast(height_px), + .price_min = price_min, + .price_max = price_max, + .rsi_latest = rsi_latest, + }; +} + +// ── Drawing helpers ─────────────────────────────────────────────────── + +fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 { + if (max_val == min_val) return (top_px + bottom_px) / 2; + const norm = (value - min_val) / (max_val - min_val); + return bottom_px - norm * (bottom_px - top_px); +} + +/// Pre-blend a foreground color with alpha against a background color. +/// Returns a fully opaque pixel. This avoids z2d's broken src_over compositor. +fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel { + const a = @as(f64, @floatFromInt(alpha)) / 255.0; + const inv_a = 1.0 - a; + return .{ .rgb = .{ + .r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a), + .g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a), + .b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a), + } }; +} + +/// Opaque pixel from theme color. +fn opaqueColor(c: [3]u8) Pixel { + return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } }; +} + +const BandField = enum { upper, middle, lower }; + +fn drawLineSeries( + ctx: *Context, + alloc: std.mem.Allocator, + bb: []const ?zfin.indicators.BollingerBand, + len: usize, + price_min: f64, + price_max: f64, + price_top: f64, + price_bottom: f64, + chart_left: f64, + x_step: f64, + col: Pixel, + line_w: f64, + field: BandField, +) !void { + _ = alloc; + ctx.setSourceToPixel(col); + ctx.setLineWidth(line_w); + ctx.resetPath(); + var started = false; + for (0..len) |i| { + if (bb[i]) |b| { + const val = switch (field) { + .upper => b.upper, + .middle => b.middle, + .lower => b.lower, + }; + const x = chart_left + @as(f64, @floatFromInt(i)) * x_step; + const y = mapY(val, price_min, price_max, price_top, price_bottom); + if (!started) { + try ctx.moveTo(x, y); + started = true; + } else { + try ctx.lineTo(x, y); + } + } + } + if (started) try ctx.stroke(); + ctx.setLineWidth(2.0); +} + +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); + try ctx.lineTo(right, y); + try ctx.stroke(); + } + ctx.setLineWidth(2.0); +} + +fn drawHLine(ctx: *Context, alloc: std.mem.Allocator, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void { + _ = alloc; + ctx.setSourceToPixel(col); + ctx.setLineWidth(w); + ctx.resetPath(); + try ctx.moveTo(x1, y); + try ctx.lineTo(x2, y); + try ctx.stroke(); + 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; + ctx.setSourceToPixel(col); + ctx.setLineWidth(w); + ctx.resetPath(); + try ctx.moveTo(x1, y1); + try ctx.lineTo(x2, y1); + try ctx.lineTo(x2, y2); + try ctx.lineTo(x1, y2); + try ctx.closePath(); + try ctx.stroke(); + ctx.setLineWidth(2.0); +} diff --git a/src/tui/main.zig b/src/tui/main.zig index 2b9eeeb..2837e2d 100644 --- a/src/tui/main.zig +++ b/src/tui/main.zig @@ -1,6 +1,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("zfin"); +const fmt = zfin.format; const keybinds = @import("keybinds.zig"); const theme_mod = @import("theme.zig"); const chart_mod = @import("chart.zig"); @@ -21,6 +22,13 @@ fn glyph(ch: u8) []const u8 { return " "; } +/// Return a string of `n` spaces using the arena allocator. +fn allocSpaces(arena: std.mem.Allocator, n: usize) ![]const u8 { + const buf = try arena.alloc(u8, n); + @memset(buf, ' '); + return buf; +} + const Tab = enum { portfolio, quote, @@ -155,6 +163,9 @@ const App = struct { quote_timestamp: i64 = 0, // Track whether earnings tab should be disabled (ETF, no data) earnings_disabled: bool = false, + // ETF profile (loaded lazily on quote tab) + etf_profile: ?zfin.EtfProfile = null, + etf_loaded: bool = false, // Signal to the run loop to launch $EDITOR then restart wants_edit: bool = false, @@ -625,7 +636,7 @@ const App = struct { // Calls contracts (only if not collapsed) if (!(ci < self.options_calls_collapsed.len and self.options_calls_collapsed[ci])) { - const filtered_calls = filterNearMoney(chain.calls, atm_price, self.options_near_the_money); + const filtered_calls = fmt.filterNearMoney(chain.calls, atm_price, self.options_near_the_money); for (filtered_calls) |cc| { self.options_rows.append(self.allocator, .{ .kind = .call, @@ -643,7 +654,7 @@ const App = struct { // Puts contracts (only if not collapsed) if (!(ci < self.options_puts_collapsed.len and self.options_puts_collapsed[ci])) { - const filtered_puts = filterNearMoney(chain.puts, atm_price, self.options_near_the_money); + const filtered_puts = fmt.filterNearMoney(chain.puts, atm_price, self.options_near_the_money); for (filtered_puts) |p| { self.options_rows.append(self.allocator, .{ .kind = .put, @@ -681,6 +692,7 @@ const App = struct { self.earnings_loaded = false; self.earnings_disabled = false; self.options_loaded = false; + self.etf_loaded = false; self.options_cursor = 0; self.options_expanded = [_]bool{false} ** 64; self.options_calls_collapsed = [_]bool{false} ** 64; @@ -695,6 +707,7 @@ const App = struct { self.freeDividends(); self.freeEarnings(); self.freeOptions(); + self.freeEtfProfile(); self.trailing_price = null; self.trailing_total = null; self.trailing_me_price = null; @@ -878,7 +891,7 @@ const App = struct { matching.append(self.allocator, lot) catch continue; } } - std.mem.sort(zfin.Lot, matching.items, {}, lotSortFn); + std.mem.sort(zfin.Lot, matching.items, {}, fmt.lotSortFn); for (matching.items) |lot| { self.portfolio_rows.append(self.allocator, .{ .kind = .lot, @@ -945,7 +958,7 @@ const App = struct { self.candle_first_date = c[0].date; self.candle_last_date = c[c.len - 1].date; - const today = todayDate(); + const today = fmt.todayDate(); self.trailing_price = zfin.performance.trailingReturns(c); self.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today); @@ -956,6 +969,17 @@ const App = struct { } else |_| {} self.risk_metrics = zfin.risk.computeRisk(c); + + // Try to load ETF profile (non-fatal, won't show for non-ETFs) + if (!self.etf_loaded) { + self.etf_loaded = true; + if (self.svc.getEtfProfile(self.symbol)) |etf_result| { + if (etf_result.data.isEtf()) { + self.etf_profile = etf_result.data; + } + } else |_| {} + } + self.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); } @@ -1044,6 +1068,24 @@ const App = struct { self.options_data = null; } + fn freeEtfProfile(self: *App) void { + if (self.etf_profile) |profile| { + if (profile.holdings) |h| { + for (h) |holding| { + if (holding.symbol) |s| self.allocator.free(s); + self.allocator.free(holding.name); + } + self.allocator.free(h); + } + if (profile.sectors) |s| { + for (s) |sec| self.allocator.free(sec.sector); + self.allocator.free(s); + } + } + self.etf_profile = null; + self.etf_loaded = false; + } + fn freePortfolioSummary(self: *App) void { if (self.portfolio_summary) |*s| s.deinit(self.allocator); self.portfolio_summary = null; @@ -1054,6 +1096,7 @@ const App = struct { self.freeDividends(); self.freeEarnings(); self.freeOptions(); + self.freeEtfProfile(); self.freePortfolioSummary(); self.portfolio_rows.deinit(self.allocator); self.options_rows.deinit(self.allocator); @@ -1273,10 +1316,10 @@ const App = struct { var val_buf: [24]u8 = undefined; var cost_buf: [24]u8 = undefined; var gl_buf: [24]u8 = undefined; - const val_str = fmtMoney(&val_buf, s.total_value); - const cost_str = fmtMoney(&cost_buf, s.total_cost); + const val_str = fmt.fmtMoney(&val_buf, s.total_value); + const cost_str = fmt.fmtMoney(&cost_buf, s.total_cost); const gl_abs = if (s.unrealized_pnl >= 0) s.unrealized_pnl else -s.unrealized_pnl; - const gl_str = fmtMoney(&gl_buf, gl_abs); + const gl_str = fmt.fmtMoney(&gl_buf, gl_abs); const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{ val_str, cost_str, if (s.unrealized_pnl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0, }); @@ -1323,18 +1366,18 @@ const App = struct { const pnl_pct = if (a.cost_basis > 0) (a.unrealized_pnl / a.cost_basis) * 100.0 else @as(f64, 0); var gl_val_buf: [24]u8 = undefined; const gl_abs = if (a.unrealized_pnl >= 0) a.unrealized_pnl else -a.unrealized_pnl; - const gl_money = fmtMoney(&gl_val_buf, gl_abs); + const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs); var pnl_buf: [20]u8 = undefined; const pnl_str = if (a.unrealized_pnl >= 0) std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?" else std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?"; var mv_buf: [24]u8 = undefined; - const mv_str = fmtMoney(&mv_buf, a.market_value); + const mv_str = fmt.fmtMoney(&mv_buf, a.market_value); var cost_buf2: [24]u8 = undefined; - const cost_str = fmtMoney2(&cost_buf2, a.avg_cost); + const cost_str = fmt.fmtMoney2(&cost_buf2, a.avg_cost); var price_buf2: [24]u8 = undefined; - const price_str = fmtMoney2(&price_buf2, a.current_price); + const price_str = fmt.fmtMoney2(&price_buf2, a.current_price); // Date + ST/LT: show for single-lot, blank for multi-lot var pos_date_buf: [10]u8 = undefined; @@ -1343,7 +1386,7 @@ const App = struct { for (pf.lots) |lot| { if (std.mem.eql(u8, lot.symbol, a.symbol)) { const ds = lot.open_date.format(&pos_date_buf); - const indicator = capitalGainsIndicator(lot.open_date); + const indicator = fmt.capitalGainsIndicator(lot.open_date); break :blk std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; } } @@ -1386,7 +1429,7 @@ const App = struct { const gl = lot.shares * (use_price - lot.open_price); lot_positive = gl >= 0; var lot_gl_money_buf: [24]u8 = undefined; - const lot_gl_money = fmtMoney(&lot_gl_money_buf, if (gl >= 0) gl else -gl); + const lot_gl_money = fmt.fmtMoney(&lot_gl_money_buf, if (gl >= 0) gl else -gl); lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{ if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), lot_gl_money, }); @@ -1394,9 +1437,9 @@ const App = struct { } var price_str2: [24]u8 = undefined; - const lot_price_str = fmtMoney2(&price_str2, lot.open_price); + const lot_price_str = fmt.fmtMoney2(&price_str2, lot.open_price); const status_str: []const u8 = if (lot.isOpen()) "open" else "closed"; - const indicator = capitalGainsIndicator(lot.open_date); + const indicator = fmt.capitalGainsIndicator(lot.open_date); const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator }); const acct_col: []const u8 = lot.account orelse ""; const text = try std.fmt.allocPrint(arena, " {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{ @@ -1418,7 +1461,7 @@ const App = struct { const ps = if (self.svc.getCachedCandles(row.symbol)) |candles_slice| blk: { defer self.allocator.free(candles_slice); if (candles_slice.len > 0) - break :blk fmtMoney2(&price_str3, candles_slice[candles_slice.len - 1].close) + break :blk fmt.fmtMoney2(&price_str3, candles_slice[candles_slice.len - 1].close) else break :blk @as([]const u8, "--"); } else "--"; @@ -1719,7 +1762,7 @@ const App = struct { if (row >= height) continue; var lbl_buf: [16]u8 = undefined; - const lbl = fmtMoney2(&lbl_buf, price_val); + const lbl = fmt.fmtMoney2(&lbl_buf, price_val); const start_idx = row * @as(usize, width) + label_col; for (lbl, 0..) |ch, ci| { const idx = start_idx + ci; @@ -1771,26 +1814,7 @@ const App = struct { 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); - var date_buf2: [10]u8 = undefined; - var close_buf2: [24]u8 = undefined; - var vol_buf2: [32]u8 = undefined; - try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf2)}), .style = th.contentStyle() }); - try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmtMoney(&close_buf2, price)}), .style = th.contentStyle() }); - try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), .style = th.mutedStyle() }); - try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), .style = th.mutedStyle() }); - try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), .style = th.mutedStyle() }); - try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volume: {s}", .{fmtIntCommas(&vol_buf2, if (quote_data) |q| q.volume else latest.volume)}), .style = th.mutedStyle() }); - - if (prev_close > 0) { - const change = price - prev_close; - const pct = (change / prev_close) * 100.0; - const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); - if (change >= 0) { - try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), .style = change_style }); - } else { - try detail_lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), .style = change_style }); - } - } + try self.buildDetailColumns(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); @@ -1816,7 +1840,7 @@ const App = struct { var ago_buf: [16]u8 = undefined; if (self.quote != null and self.quote_timestamp > 0) { - const ago_str = fmtTimeAgo(&ago_buf, self.quote_timestamp); + const ago_str = fmt.fmtTimeAgo(&ago_buf, self.quote_timestamp); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ self.symbol, ago_str }), .style = th.headerStyle() }); } else if (self.candle_last_date) |d| { var cdate_buf: [10]u8 = undefined; @@ -1835,7 +1859,7 @@ const App = struct { 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}", .{fmtMoney(&qclose_buf, q.close)}), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoney(&qclose_buf, q.close)}), .style = th.contentStyle() }); if (q.change >= 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ q.change, q.percent_change }), .style = th.positiveStyle() }); } else { @@ -1855,32 +1879,14 @@ const App = struct { 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]; - var date_buf: [10]u8 = undefined; - var close_buf: [24]u8 = undefined; - var vol_buf: [32]u8 = undefined; - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), .style = th.contentStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmtMoney(&close_buf, price)}), .style = th.contentStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), .style = th.mutedStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), .style = th.mutedStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), .style = th.mutedStyle() }); - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volume: {s}", .{fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), .style = th.mutedStyle() }); - if (prev_close > 0) { - const change = price - prev_close; - const pct = (change / prev_close) * 100.0; - const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); - if (change >= 0) { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), .style = change_style }); - } else { - try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), .style = change_style }); - } - } + try self.buildDetailColumns(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 buildBrailleChart(arena, &lines, chart_data, th); + try renderBrailleToStyledLines(arena, &lines, chart_data, th); // Recent history table try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -1893,13 +1899,192 @@ const App = struct { var vb: [32]u8 = undefined; const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle(); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}", .{ - candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmtIntCommas(&vb, candle.volume), + candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume), }), .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(self: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void { + try self.texts.append(arena, text); + try self.styles.append(arena, style); + } + + fn len(self: *const Column) usize { + return self.texts.items.len; + } + }; + + fn buildDetailColumns( + self: *App, + arena: std.mem.Allocator, + lines: *std.ArrayList(StyledLine), + latest: zfin.Candle, + quote_data: ?zfin.Quote, + price: f64, + prev_close: f64, + ) !void { + const th = self.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.fmtMoney(&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; + const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); + if (change >= 0) { + try col1.add(arena, try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), change_style); + } else { + try col1.add(arena, try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -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 (self.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| { + // Truncate long sector names + const name = if (sec.sector.len > 20) sec.sector[0..20] else sec.sector; + 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 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] = " "; + 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]); + 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] = " "; + 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] = " "; + styles[pos] = bg_style; + pos += 1; + } + } + } + } + + try lines.append(arena, .{ + .text = "", + .style = bg_style, + .graphemes = graphemes[0..pos], + .cell_styles = styles[0..pos], + }); + } + } + // ── Performance tab ────────────────────────────────────────── fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { @@ -1943,7 +2128,7 @@ const App = struct { if (self.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}", .{fmtMoney(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoney(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() }); } } @@ -1957,7 +2142,7 @@ const App = struct { try appendStyledReturnsTable(arena, &lines, self.trailing_price.?, if (has_total) self.trailing_total else null, th); { - const today = todayDate(); + const today = fmt.todayDate(); const month_end = today.lastDayOfPriorMonth(); var db: [10]u8 = undefined; try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -2062,7 +2247,7 @@ const App = struct { } var opt_ago_buf: [16]u8 = undefined; - const opt_ago = fmtTimeAgo(&opt_ago_buf, self.options_timestamp); + const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, self.options_timestamp); if (opt_ago.len > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ self.symbol, opt_ago }), .style = th.headerStyle() }); } else { @@ -2071,7 +2256,7 @@ const App = struct { 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)", .{ fmtMoney(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoney(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() }); } try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); @@ -2089,7 +2274,7 @@ const App = struct { const chain = chains[row.exp_idx]; var db: [10]u8 = undefined; const is_expanded = row.exp_idx < self.options_expanded.len and self.options_expanded[row.exp_idx]; - const is_monthly = isMonthlyExpiration(chain.expiration); + 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, @@ -2123,7 +2308,7 @@ const App = struct { const atm_price = chains[0].underlying_price orelse 0; const itm = cc.strike <= atm_price; const prefix: []const u8 = if (itm) " |" else " "; - const text = try fmtContractLine(arena, prefix, cc); + const text = try fmt.fmtContractLine(arena, prefix, cc); const style = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = style }); } @@ -2133,7 +2318,7 @@ const App = struct { const atm_price = chains[0].underlying_price orelse 0; const itm = p.strike >= atm_price; const prefix: []const u8 = if (itm) " |" else " "; - const text = try fmtContractLine(arena, prefix, p); + const text = try fmt.fmtContractLine(arena, prefix, p); const style = if (is_cursor) th.selectStyle() else th.contentStyle(); try lines.append(arena, .{ .text = text, .style = style }); } @@ -2162,7 +2347,7 @@ const App = struct { } var earn_ago_buf: [16]u8 = undefined; - const earn_ago = fmtTimeAgo(&earn_ago_buf, self.earnings_timestamp); + const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, self.earnings_timestamp); if (earn_ago.len > 0) { try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ self.symbol, earn_ago }), .style = th.headerStyle() }); } else { @@ -2315,296 +2500,16 @@ const App = struct { // ── Utility functions ──────────────────────────────────────── -fn todayDate() zfin.Date { - const ts = std.time.timestamp(); - const days: i32 = @intCast(@divFloor(ts, 86400)); - return .{ .days = days }; -} +/// Render a braille sparkline chart from candle close prices into StyledLines. +/// Uses the shared BrailleChart computation, then wraps results in vaxis styles. +fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme_mod.Theme) !void { + var chart = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return; + // No deinit needed: arena handles cleanup -/// Format a dollar amount with commas and 2 decimals: $1,234.56 -fn fmtMoney(buf: []u8, amount: f64) []const u8 { - const cents = @as(i64, @intFromFloat(@round(amount * 100.0))); - const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents)); - const dollars = abs_cents / 100; - const rem = abs_cents % 100; - - // Build digits from right to left - var tmp: [24]u8 = undefined; - var pos: usize = tmp.len; - - // Cents - pos -= 1; - tmp[pos] = '0' + @as(u8, @intCast(rem % 10)); - pos -= 1; - tmp[pos] = '0' + @as(u8, @intCast(rem / 10)); - pos -= 1; - tmp[pos] = '.'; - - // Dollars with commas - var d = dollars; - var digit_count: usize = 0; - if (d == 0) { - pos -= 1; - tmp[pos] = '0'; - } else { - while (d > 0) { - if (digit_count > 0 and digit_count % 3 == 0) { - pos -= 1; - tmp[pos] = ','; - } - pos -= 1; - tmp[pos] = '0' + @as(u8, @intCast(d % 10)); - d /= 10; - digit_count += 1; - } - } - pos -= 1; - tmp[pos] = '$'; - - const len = tmp.len - pos; - if (len > buf.len) return "$?"; - @memcpy(buf[0..len], tmp[pos..]); - return buf[0..len]; -} - -/// Format price with 2 decimals (no commas, for per-share prices) -fn fmtMoney2(buf: []u8, amount: f64) []const u8 { - return std.fmt.bufPrint(buf, "${d:.2}", .{amount}) catch "$?"; -} - -/// Format an integer with commas (e.g. 1234567 → "1,234,567"). -fn fmtIntCommas(buf: []u8, value: u64) []const u8 { - var tmp: [32]u8 = undefined; - var pos: usize = tmp.len; - var v = value; - var digit_count: usize = 0; - if (v == 0) { - pos -= 1; - tmp[pos] = '0'; - } else { - while (v > 0) { - if (digit_count > 0 and digit_count % 3 == 0) { - pos -= 1; - tmp[pos] = ','; - } - pos -= 1; - tmp[pos] = '0' + @as(u8, @intCast(v % 10)); - v /= 10; - digit_count += 1; - } - } - const len = tmp.len - pos; - if (len > buf.len) return "?"; - @memcpy(buf[0..len], tmp[pos..]); - return buf[0..len]; -} - -/// Format a unix timestamp as relative time ("just now", "5m ago", "2h ago", "3d ago"). -fn fmtTimeAgo(buf: []u8, timestamp: i64) []const u8 { - if (timestamp == 0) return ""; - const now = std.time.timestamp(); - const delta = now - timestamp; - if (delta < 0) return "just now"; - if (delta < 60) return "just now"; - if (delta < 3600) { - return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divFloor(delta, 60)))}) catch "?"; - } - if (delta < 86400) { - return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, 3600)))}) catch "?"; - } - return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, 86400)))}) catch "?"; -} - -/// Check if an expiration date is a standard monthly (3rd Friday of the month) -fn isMonthlyExpiration(date: zfin.Date) bool { - const dow = date.dayOfWeek(); // 0=Mon..4=Fri - if (dow != 4) return false; // Must be Friday - const d = date.day(); - return d >= 15 and d <= 21; // 3rd Friday is between 15th and 21st -} - -/// Return "LT" if held > 1 year from open_date to today, "ST" otherwise. -fn capitalGainsIndicator(open_date: zfin.Date) []const u8 { - const today = todayDate(); - // Long-term if held more than 365 days - return if (today.days - open_date.days > 365) "LT" else "ST"; -} - -/// Sort lots: open lots first (date descending), closed lots last (date descending). -fn lotSortFn(_: void, a: zfin.Lot, b: zfin.Lot) bool { - const a_open = a.isOpen(); - const b_open = b.isOpen(); - if (a_open and !b_open) return true; // open before closed - if (!a_open and b_open) return false; - return a.open_date.days > b.open_date.days; // newest first -} - -/// Filter options contracts to +/- N strikes from ATM -fn filterNearMoney(contracts: []const zfin.OptionContract, atm: f64, n: usize) []const zfin.OptionContract { - if (atm <= 0 or contracts.len == 0) return contracts; - - // Find the ATM index - var best_idx: usize = 0; - var best_dist: f64 = @abs(contracts[0].strike - atm); - for (contracts, 0..) |c, i| { - const dist = @abs(c.strike - atm); - if (dist < best_dist) { - best_dist = dist; - best_idx = i; - } - } - - const start = if (best_idx >= n) best_idx - n else 0; - const end = @min(best_idx + n + 1, contracts.len); - return contracts[start..end]; -} - -fn fmtContractLine(arena: std.mem.Allocator, prefix: []const u8, c: zfin.OptionContract) ![]const u8 { - var last_buf: [12]u8 = undefined; - const last_str = if (c.last_price) |p| std.fmt.bufPrint(&last_buf, "{d:>10.2}", .{p}) catch "--" else "--"; - var bid_buf: [12]u8 = undefined; - const bid_str = if (c.bid) |b| std.fmt.bufPrint(&bid_buf, "{d:>10.2}", .{b}) catch "--" else "--"; - var ask_buf: [12]u8 = undefined; - const ask_str = if (c.ask) |a| std.fmt.bufPrint(&ask_buf, "{d:>10.2}", .{a}) catch "--" else "--"; - var vol_buf: [12]u8 = undefined; - const vol_str = if (c.volume) |v| std.fmt.bufPrint(&vol_buf, "{d:>10}", .{v}) catch "--" else "--"; - var oi_buf: [10]u8 = undefined; - const oi_str = if (c.open_interest) |oi| std.fmt.bufPrint(&oi_buf, "{d:>8}", .{oi}) catch "--" else "--"; - var iv_buf: [10]u8 = undefined; - const iv_str = if (c.implied_volatility) |iv| std.fmt.bufPrint(&iv_buf, "{d:>6.1}%", .{iv * 100.0}) catch "--" else "--"; - - return std.fmt.allocPrint(arena, "{s}{d:>10.2} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}", .{ - prefix, c.strike, last_str, bid_str, ask_str, vol_str, oi_str, iv_str, - }); -} - -/// Braille dot patterns for the 2x4 matrix within each character cell. -/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08 -/// [1][4] dot1=0x02, dot4=0x10 -/// [2][5] dot2=0x04, dot5=0x20 -/// [6][7] dot6=0x40, dot7=0x80 -const braille_dots = [4][2]u8{ - .{ 0x01, 0x08 }, // row 0 (top) - .{ 0x02, 0x10 }, // row 1 - .{ 0x04, 0x20 }, // row 2 - .{ 0x40, 0x80 }, // row 3 (bottom) -}; - -/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF). -/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo. -const braille_utf8 = blk: { - var table: [256][3]u8 = undefined; - for (0..256) |i| { - const cp: u21 = 0x2800 + @as(u21, @intCast(i)); - table[i] = .{ - @as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))), - @as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))), - @as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))), - }; - } - break :blk table; -}; - -/// Return a static-lifetime grapheme slice for a braille pattern byte. -fn brailleGlyph(pattern: u8) []const u8 { - return &braille_utf8[pattern]; -} - -/// Interpolate color between two RGB values. t in [0.0, 1.0]. -fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 { - return .{ - @intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t), - @intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t), - @intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t), - }; -} - -/// Build a braille sparkline chart from candle close prices. -/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell. -/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point. -fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme_mod.Theme) !void { - if (data.len < 2) return; - - const chart_width: usize = 60; - const chart_height: usize = 10; // terminal rows - const dot_rows: usize = chart_height * 4; // vertical dot resolution - - // Find min/max close prices - var min_price: f64 = data[0].close; - var max_price: f64 = data[0].close; - for (data) |d| { - if (d.close < min_price) min_price = d.close; - if (d.close > max_price) max_price = d.close; - } - if (max_price == min_price) max_price = min_price + 1.0; - const price_range = max_price - min_price; - - // Price labels - var max_buf: [16]u8 = undefined; - var min_buf: [16]u8 = undefined; - const max_label = std.fmt.bufPrint(&max_buf, "${d:.0}", .{max_price}) catch ""; - const min_label = std.fmt.bufPrint(&min_buf, "${d:.0}", .{min_price}) catch ""; - - // Map each data column to a dot-row position (0 = top, dot_rows-1 = bottom) - const n_cols = @min(data.len, chart_width); - const dot_y = try arena.alloc(usize, n_cols); - const col_color = try arena.alloc([3]u8, n_cols); - - for (0..n_cols) |col| { - const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1)); - const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1); - const close = data[data_idx].close; - const norm = (close - min_price) / price_range; // 0 = min, 1 = max - // Inverted: 0 = top dot row, dot_rows-1 = bottom - const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1)); - dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1); - - // Color: gradient from negative (bottom) to positive (top) - col_color[col] = lerpColor(th.negative, th.positive, norm); - } - - // Build the braille grid: each cell is a pattern byte - // Grid is [chart_height][padded_width] where padded_width includes 2 leading spaces - const padded_width: usize = n_cols + 2; // 2 leading spaces - - // Allocate pattern grid - const patterns = try arena.alloc(u8, chart_height * padded_width); - @memset(patterns, 0); - - // For each column, draw a filled area from dot_y[col] down to the bottom, - // plus interpolate a line between adjacent points for smooth contour. - for (0..n_cols) |col| { - const target_y = dot_y[col]; - // Fill from target_y to bottom - for (target_y..dot_rows) |dy| { - const term_row = dy / 4; - const sub_row = dy % 4; - // Left column of the braille cell (we use col 0 of each 2-wide cell) - const grid_col = col + 2; // offset for 2 leading spaces - patterns[term_row * padded_width + grid_col] |= braille_dots[sub_row][0]; - } - - // Interpolate between this point and the next for the line contour - if (col + 1 < n_cols) { - const y0 = dot_y[col]; - const y1 = dot_y[col + 1]; - const min_y = @min(y0, y1); - const max_y = @max(y0, y1); - // Fill intermediate dots on this column for vertical segments - for (min_y..max_y + 1) |dy| { - const term_row = dy / 4; - const sub_row = dy % 4; - const grid_col = col + 2; - patterns[term_row * padded_width + grid_col] |= braille_dots[sub_row][0]; - } - } - } - - // Render each terminal row as a StyledLine with graphemes const bg = th.bg; - for (0..chart_height) |row| { - const graphemes = try arena.alloc([]const u8, padded_width + 10); // extra for label - const styles = try arena.alloc(vaxis.Style, padded_width + 10); + for (0..chart.chart_height) |row| { + const graphemes = try arena.alloc([]const u8, chart.n_cols + 12); // chart + padding + label + const styles = try arena.alloc(vaxis.Style, chart.n_cols + 12); var gpos: usize = 0; // 2 leading spaces @@ -2616,12 +2521,11 @@ fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine) gpos += 1; // Chart columns - for (0..n_cols) |col| { - const pattern = patterns[row * padded_width + col + 2]; - graphemes[gpos] = brailleGlyph(pattern); - // Use the column's color for non-empty cells, dim for empty + for (0..chart.n_cols) |col| { + const pattern = chart.pattern(row, col); + graphemes[gpos] = fmt.brailleGlyph(pattern); if (pattern != 0) { - styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(col_color[col]), .bg = theme_mod.Theme.vcolor(bg) }; + styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(chart.col_colors[col]), .bg = theme_mod.Theme.vcolor(bg) }; } else { styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(bg), .bg = theme_mod.Theme.vcolor(bg) }; } @@ -2630,7 +2534,7 @@ fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine) // Right-side price labels if (row == 0) { - const lbl = try std.fmt.allocPrint(arena, " {s}", .{max_label}); + const lbl = try std.fmt.allocPrint(arena, " {s}", .{chart.maxLabel()}); for (lbl) |ch| { if (gpos < graphemes.len) { graphemes[gpos] = glyph(ch); @@ -2638,8 +2542,8 @@ fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine) gpos += 1; } } - } else if (row == chart_height - 1) { - const lbl = try std.fmt.allocPrint(arena, " {s}", .{min_label}); + } else if (row == chart.chart_height - 1) { + const lbl = try std.fmt.allocPrint(arena, " {s}", .{chart.minLabel()}); for (lbl) |ch| { if (gpos < graphemes.len) { graphemes[gpos] = glyph(ch); @@ -2656,6 +2560,65 @@ fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine) .cell_styles = styles[0..gpos], }); } + + // Date axis below chart + { + var start_buf: [7]u8 = undefined; + var end_buf: [7]u8 = undefined; + const start_label = fmt.BrailleChart.fmtShortDate(chart.start_date, &start_buf); + const end_label = fmt.BrailleChart.fmtShortDate(chart.end_date, &end_buf); + const muted_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) }; + + const date_graphemes = try arena.alloc([]const u8, chart.n_cols + 12); + const date_styles = try arena.alloc(vaxis.Style, chart.n_cols + 12); + var dpos: usize = 0; + + // 2 leading spaces + date_graphemes[dpos] = " "; + date_styles[dpos] = muted_style; + dpos += 1; + date_graphemes[dpos] = " "; + date_styles[dpos] = muted_style; + dpos += 1; + + // Start date label + for (start_label) |ch| { + if (dpos < date_graphemes.len) { + date_graphemes[dpos] = glyph(ch); + date_styles[dpos] = muted_style; + dpos += 1; + } + } + + // Gap between labels + const total_width = chart.n_cols; + if (total_width > start_label.len + end_label.len) { + const gap = total_width - start_label.len - end_label.len; + for (0..gap) |_| { + if (dpos < date_graphemes.len) { + date_graphemes[dpos] = " "; + date_styles[dpos] = muted_style; + dpos += 1; + } + } + } + + // End date label + for (end_label) |ch| { + if (dpos < date_graphemes.len) { + date_graphemes[dpos] = glyph(ch); + date_styles[dpos] = muted_style; + dpos += 1; + } + } + + try lines.append(arena, .{ + .text = "", + .style = .{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(bg) }, + .graphemes = date_graphemes[0..dpos], + .cell_styles = date_styles[0..dpos], + }); + } } /// Load a watchlist from an SRF file.