ai: bring cli and tui to near parity

This commit is contained in:
Emil Lerch 2026-02-26 07:40:51 -08:00
parent 4d093b86bf
commit 529143cf49
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 2186 additions and 630 deletions

View file

@ -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);
}

File diff suppressed because it is too large Load diff

529
src/format.zig Normal file
View file

@ -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));
}

View file

@ -40,4 +40,14 @@ pub const EtfProfile = struct {
inception_date: ?Date = null, inception_date: ?Date = null,
/// Whether the fund is leveraged /// Whether the fund is leveraged
leveraged: bool = false, 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;
}
}; };

View file

@ -135,10 +135,8 @@ fn parseResponse(
} }
// Convert to sorted OptionsChain slice // Convert to sorted OptionsChain slice
const owned_symbol = allocator.dupe(u8, symbol) catch return provider.ProviderError.OutOfMemory; // Each chain gets its own dupe of the symbol so callers can free them independently.
errdefer allocator.free(owned_symbol); return exp_map.toOwnedChains(allocator, symbol, underlying_price);
return exp_map.toOwnedChains(allocator, owned_symbol, underlying_price);
} }
// OCC symbol parsing // OCC symbol parsing
@ -212,10 +210,11 @@ const ExpMap = struct {
/// Convert to owned []OptionsChain, sorted by expiration ascending. /// Convert to owned []OptionsChain, sorted by expiration ascending.
/// Frees internal structures; caller owns the returned chains. /// Frees internal structures; caller owns the returned chains.
/// Each chain gets its own dupe of `symbol` so callers can free them independently.
fn toOwnedChains( fn toOwnedChains(
self: *ExpMap, self: *ExpMap,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
owned_symbol: []const u8, symbol: []const u8,
underlying_price: ?f64, underlying_price: ?f64,
) provider.ProviderError![]OptionsChain { ) provider.ProviderError![]OptionsChain {
// Sort entries by expiration // Sort entries by expiration
@ -230,6 +229,8 @@ const ExpMap = struct {
errdefer allocator.free(chains); errdefer allocator.free(chains);
for (self.entries.items, 0..) |*entry, i| { 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 const calls = entry.calls.toOwnedSlice(allocator) catch
return provider.ProviderError.OutOfMemory; return provider.ProviderError.OutOfMemory;
const puts = entry.puts.toOwnedSlice(allocator) catch { const puts = entry.puts.toOwnedSlice(allocator) catch {

View file

@ -38,6 +38,9 @@ pub const performance = @import("analytics/performance.zig");
pub const risk = @import("analytics/risk.zig"); pub const risk = @import("analytics/risk.zig");
pub const indicators = @import("analytics/indicators.zig"); pub const indicators = @import("analytics/indicators.zig");
// -- Formatting (shared between CLI and TUI) --
pub const format = @import("format.zig");
// -- Service layer -- // -- Service layer --
pub const DataService = @import("service.zig").DataService; pub const DataService = @import("service.zig").DataService;
pub const DataError = @import("service.zig").DataError; pub const DataError = @import("service.zig").DataError;

529
src/tui/chart.zig Normal file
View file

@ -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);
}

View file

@ -1,6 +1,7 @@
const std = @import("std"); const std = @import("std");
const vaxis = @import("vaxis"); const vaxis = @import("vaxis");
const zfin = @import("zfin"); const zfin = @import("zfin");
const fmt = zfin.format;
const keybinds = @import("keybinds.zig"); const keybinds = @import("keybinds.zig");
const theme_mod = @import("theme.zig"); const theme_mod = @import("theme.zig");
const chart_mod = @import("chart.zig"); const chart_mod = @import("chart.zig");
@ -21,6 +22,13 @@ fn glyph(ch: u8) []const u8 {
return " "; 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 { const Tab = enum {
portfolio, portfolio,
quote, quote,
@ -155,6 +163,9 @@ const App = struct {
quote_timestamp: i64 = 0, quote_timestamp: i64 = 0,
// Track whether earnings tab should be disabled (ETF, no data) // Track whether earnings tab should be disabled (ETF, no data)
earnings_disabled: bool = false, earnings_disabled: bool = false,
// 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 // Signal to the run loop to launch $EDITOR then restart
wants_edit: bool = false, wants_edit: bool = false,
@ -625,7 +636,7 @@ const App = struct {
// Calls contracts (only if not collapsed) // Calls contracts (only if not collapsed)
if (!(ci < self.options_calls_collapsed.len and self.options_calls_collapsed[ci])) { 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| { for (filtered_calls) |cc| {
self.options_rows.append(self.allocator, .{ self.options_rows.append(self.allocator, .{
.kind = .call, .kind = .call,
@ -643,7 +654,7 @@ const App = struct {
// Puts contracts (only if not collapsed) // Puts contracts (only if not collapsed)
if (!(ci < self.options_puts_collapsed.len and self.options_puts_collapsed[ci])) { 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| { for (filtered_puts) |p| {
self.options_rows.append(self.allocator, .{ self.options_rows.append(self.allocator, .{
.kind = .put, .kind = .put,
@ -681,6 +692,7 @@ const App = struct {
self.earnings_loaded = false; self.earnings_loaded = false;
self.earnings_disabled = false; self.earnings_disabled = false;
self.options_loaded = false; self.options_loaded = false;
self.etf_loaded = false;
self.options_cursor = 0; self.options_cursor = 0;
self.options_expanded = [_]bool{false} ** 64; self.options_expanded = [_]bool{false} ** 64;
self.options_calls_collapsed = [_]bool{false} ** 64; self.options_calls_collapsed = [_]bool{false} ** 64;
@ -695,6 +707,7 @@ const App = struct {
self.freeDividends(); self.freeDividends();
self.freeEarnings(); self.freeEarnings();
self.freeOptions(); self.freeOptions();
self.freeEtfProfile();
self.trailing_price = null; self.trailing_price = null;
self.trailing_total = null; self.trailing_total = null;
self.trailing_me_price = null; self.trailing_me_price = null;
@ -878,7 +891,7 @@ const App = struct {
matching.append(self.allocator, lot) catch continue; 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| { for (matching.items) |lot| {
self.portfolio_rows.append(self.allocator, .{ self.portfolio_rows.append(self.allocator, .{
.kind = .lot, .kind = .lot,
@ -945,7 +958,7 @@ const App = struct {
self.candle_first_date = c[0].date; self.candle_first_date = c[0].date;
self.candle_last_date = c[c.len - 1].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_price = zfin.performance.trailingReturns(c);
self.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today); self.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today);
@ -956,6 +969,17 @@ const App = struct {
} else |_| {} } else |_| {}
self.risk_metrics = zfin.risk.computeRisk(c); 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"); 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; 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 { fn freePortfolioSummary(self: *App) void {
if (self.portfolio_summary) |*s| s.deinit(self.allocator); if (self.portfolio_summary) |*s| s.deinit(self.allocator);
self.portfolio_summary = null; self.portfolio_summary = null;
@ -1054,6 +1096,7 @@ const App = struct {
self.freeDividends(); self.freeDividends();
self.freeEarnings(); self.freeEarnings();
self.freeOptions(); self.freeOptions();
self.freeEtfProfile();
self.freePortfolioSummary(); self.freePortfolioSummary();
self.portfolio_rows.deinit(self.allocator); self.portfolio_rows.deinit(self.allocator);
self.options_rows.deinit(self.allocator); self.options_rows.deinit(self.allocator);
@ -1273,10 +1316,10 @@ const App = struct {
var val_buf: [24]u8 = undefined; var val_buf: [24]u8 = undefined;
var cost_buf: [24]u8 = undefined; var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined; var gl_buf: [24]u8 = undefined;
const val_str = fmtMoney(&val_buf, s.total_value); const val_str = fmt.fmtMoney(&val_buf, s.total_value);
const cost_str = fmtMoney(&cost_buf, s.total_cost); 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_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}%)", .{ 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, 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); 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; var gl_val_buf: [24]u8 = undefined;
const gl_abs = if (a.unrealized_pnl >= 0) a.unrealized_pnl else -a.unrealized_pnl; 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; var pnl_buf: [20]u8 = undefined;
const pnl_str = if (a.unrealized_pnl >= 0) const pnl_str = if (a.unrealized_pnl >= 0)
std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?" std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
else else
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?"; std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
var mv_buf: [24]u8 = undefined; 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; 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; 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 // Date + ST/LT: show for single-lot, blank for multi-lot
var pos_date_buf: [10]u8 = undefined; var pos_date_buf: [10]u8 = undefined;
@ -1343,7 +1386,7 @@ const App = struct {
for (pf.lots) |lot| { for (pf.lots) |lot| {
if (std.mem.eql(u8, lot.symbol, a.symbol)) { if (std.mem.eql(u8, lot.symbol, a.symbol)) {
const ds = lot.open_date.format(&pos_date_buf); 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; 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); const gl = lot.shares * (use_price - lot.open_price);
lot_positive = gl >= 0; lot_positive = gl >= 0;
var lot_gl_money_buf: [24]u8 = undefined; 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}", .{ lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), lot_gl_money, 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; 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 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 lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
const acct_col: []const u8 = lot.account orelse ""; 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}", .{ 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: { const ps = if (self.svc.getCachedCandles(row.symbol)) |candles_slice| blk: {
defer self.allocator.free(candles_slice); defer self.allocator.free(candles_slice);
if (candles_slice.len > 0) 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 else
break :blk @as([]const u8, "--"); break :blk @as([]const u8, "--");
} else "--"; } else "--";
@ -1719,7 +1762,7 @@ const App = struct {
if (row >= height) continue; if (row >= height) continue;
var lbl_buf: [16]u8 = undefined; 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; const start_idx = row * @as(usize, width) + label_col;
for (lbl, 0..) |ch, ci| { for (lbl, 0..) |ch, ci| {
const idx = start_idx + 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 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); 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; try self.buildDetailColumns(arena, &detail_lines, latest, quote_data, price, prev_close);
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 });
}
}
// Write detail lines into the buffer below the image // Write detail lines into the buffer below the image
const detail_buf_start = detail_start_row * @as(usize, width); const detail_buf_start = detail_start_row * @as(usize, width);
@ -1816,7 +1840,7 @@ const App = struct {
var ago_buf: [16]u8 = undefined; var ago_buf: [16]u8 = undefined;
if (self.quote != null and self.quote_timestamp > 0) { 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() }); 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| { } else if (self.candle_last_date) |d| {
var cdate_buf: [10]u8 = undefined; var cdate_buf: [10]u8 = undefined;
@ -1835,7 +1859,7 @@ const App = struct {
if (quote_data) |q| { if (quote_data) |q| {
// No candle data but have a quote - show it // No candle data but have a quote - show it
var qclose_buf: [24]u8 = undefined; 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) { 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() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ q.change, q.percent_change }), .style = th.positiveStyle() });
} else { } else {
@ -1855,32 +1879,14 @@ const App = struct {
const price = if (quote_data) |q| q.close else c[c.len - 1].close; 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 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]; 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) { try self.buildDetailColumns(arena, &lines, latest, quote_data, price, prev_close);
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 });
}
}
// Braille sparkline chart of recent 60 trading days // Braille sparkline chart of recent 60 trading days
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const chart_days: usize = @min(c.len, 60); const chart_days: usize = @min(c.len, 60);
const chart_data = c[c.len - chart_days ..]; 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 // Recent history table
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -1893,13 +1899,192 @@ const App = struct {
var vb: [32]u8 = undefined; var vb: [32]u8 = undefined;
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle(); 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}", .{ 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 }); }), .style = day_change });
} }
return lines.toOwnedSlice(arena); 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 // Performance tab
fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine {
@ -1943,7 +2128,7 @@ const App = struct {
if (self.candles) |cc| { if (self.candles) |cc| {
if (cc.len > 0) { if (cc.len > 0) {
var close_buf: [24]u8 = undefined; 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); 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(); const month_end = today.lastDayOfPriorMonth();
var db: [10]u8 = undefined; var db: [10]u8 = undefined;
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -2062,7 +2247,7 @@ const App = struct {
} }
var opt_ago_buf: [16]u8 = undefined; 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) { 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() }); 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 { } else {
@ -2071,7 +2256,7 @@ const App = struct {
if (chains[0].underlying_price) |price| { if (chains[0].underlying_price) |price| {
var price_buf: [24]u8 = undefined; 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() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -2089,7 +2274,7 @@ const App = struct {
const chain = chains[row.exp_idx]; const chain = chains[row.exp_idx];
var db: [10]u8 = undefined; var db: [10]u8 = undefined;
const is_expanded = row.exp_idx < self.options_expanded.len and self.options_expanded[row.exp_idx]; 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 arrow: []const u8 = if (is_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{ const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{
arrow, arrow,
@ -2123,7 +2308,7 @@ const App = struct {
const atm_price = chains[0].underlying_price orelse 0; const atm_price = chains[0].underlying_price orelse 0;
const itm = cc.strike <= atm_price; const itm = cc.strike <= atm_price;
const prefix: []const u8 = if (itm) " |" else " "; 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(); const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style }); 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 atm_price = chains[0].underlying_price orelse 0;
const itm = p.strike >= atm_price; const itm = p.strike >= atm_price;
const prefix: []const u8 = if (itm) " |" else " "; 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(); const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style }); try lines.append(arena, .{ .text = text, .style = style });
} }
@ -2162,7 +2347,7 @@ const App = struct {
} }
var earn_ago_buf: [16]u8 = undefined; 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) { 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() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ self.symbol, earn_ago }), .style = th.headerStyle() });
} else { } else {
@ -2315,296 +2500,16 @@ const App = struct {
// Utility functions // Utility functions
fn todayDate() zfin.Date { /// Render a braille sparkline chart from candle close prices into StyledLines.
const ts = std.time.timestamp(); /// Uses the shared BrailleChart computation, then wraps results in vaxis styles.
const days: i32 = @intCast(@divFloor(ts, 86400)); fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme_mod.Theme) !void {
return .{ .days = days }; 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; const bg = th.bg;
for (0..chart_height) |row| { for (0..chart.chart_height) |row| {
const graphemes = try arena.alloc([]const u8, padded_width + 10); // extra for label const graphemes = try arena.alloc([]const u8, chart.n_cols + 12); // chart + padding + label
const styles = try arena.alloc(vaxis.Style, padded_width + 10); const styles = try arena.alloc(vaxis.Style, chart.n_cols + 12);
var gpos: usize = 0; var gpos: usize = 0;
// 2 leading spaces // 2 leading spaces
@ -2616,12 +2521,11 @@ fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine)
gpos += 1; gpos += 1;
// Chart columns // Chart columns
for (0..n_cols) |col| { for (0..chart.n_cols) |col| {
const pattern = patterns[row * padded_width + col + 2]; const pattern = chart.pattern(row, col);
graphemes[gpos] = brailleGlyph(pattern); graphemes[gpos] = fmt.brailleGlyph(pattern);
// Use the column's color for non-empty cells, dim for empty
if (pattern != 0) { 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 { } else {
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(bg), .bg = theme_mod.Theme.vcolor(bg) }; 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 // Right-side price labels
if (row == 0) { 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| { for (lbl) |ch| {
if (gpos < graphemes.len) { if (gpos < graphemes.len) {
graphemes[gpos] = glyph(ch); graphemes[gpos] = glyph(ch);
@ -2638,8 +2542,8 @@ fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine)
gpos += 1; gpos += 1;
} }
} }
} else if (row == chart_height - 1) { } else if (row == chart.chart_height - 1) {
const lbl = try std.fmt.allocPrint(arena, " {s}", .{min_label}); const lbl = try std.fmt.allocPrint(arena, " {s}", .{chart.minLabel()});
for (lbl) |ch| { for (lbl) |ch| {
if (gpos < graphemes.len) { if (gpos < graphemes.len) {
graphemes[gpos] = glyph(ch); graphemes[gpos] = glyph(ch);
@ -2656,6 +2560,65 @@ fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine)
.cell_styles = styles[0..gpos], .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. /// Load a watchlist from an SRF file.