ai: bring cli and tui to near parity
This commit is contained in:
parent
4d093b86bf
commit
529143cf49
8 changed files with 2186 additions and 630 deletions
136
src/analytics/indicators.zig
Normal file
136
src/analytics/indicators.zig
Normal 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);
|
||||
}
|
||||
815
src/cli/main.zig
815
src/cli/main.zig
File diff suppressed because it is too large
Load diff
529
src/format.zig
Normal file
529
src/format.zig
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
529
src/tui/chart.zig
Normal file
529
src/tui/chart.zig
Normal 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);
|
||||
}
|
||||
691
src/tui/main.zig
691
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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue