IO-as-an-interface refactor across the codebase. The big shifts: - std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run. - Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena, environ_map up front. main.zig + the build/ scripts use it directly. - Threading io through everywhere that touches the outside world (HTTP, files, stderr, sleep, terminal detection). Functions taking `io` now announce side effects at the call site — the smell is the feature. - date math takes `as_of: Date`, not `today: Date`. Caller resolves `--as-of` flag vs wall-clock at the boundary; the function operates on whatever date it's given. Every "today" parameter renamed and the as_of: ?Date + today: Date pattern collapsed. - now_s: i64 (or before_s/after_s pairs) for sub-second metadata fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo. Also pure and testable. - legitimate Timestamp.now callers (cache TTL math, FetchResult timestamps, rate limiter, per-frame TUI "now" captures) gain `// wall-clock required: ...` comments justifying the read. Test discovery: replaced the local refAllDeclsRecursive with bare std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level decls reaches every test file transitively through the import graph; no explicit _ = @import(...) lines needed. Cleanup along the way: - Dropped DataService.allocator()/io() accessor methods; renamed the fields to drop the base_ prefix. Callers use self.allocator and self.io directly. - Dropped now-vestigial io parameters from buildSnapshot, analyzePortfolio, compareSchwabSummary, compareAccounts, buildPortfolioData, divs.display, quote.display, parsePortfolioOpts, aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator, aggregateDripLots, printLotRow, portfolio.display, printSnapNote. - Dropped the unused contributions.computeAttribution date-form wrapper (only computeAttributionSpec is called). - formatAge/fmtTimeAgo take (before_s, after_s) instead of io and reading the clock internally. - parseProjectionsConfig uses an internal stack-buffer FixedBufferAllocator instead of an allocator parameter. - ThreadSafeAllocator wrappers in cache concurrency tests dropped (0.16's DebugAllocator is thread-safe by default). - analyzePortfolio bug surfaced by the rename: snapshot.zig was passing wall-clock today instead of as_of, mis-valuing cash/CDs for historical backfills. 83 new unit tests added due to removal of IO, bringing coverage from 58% -> 64%
669 lines
23 KiB
Zig
669 lines
23 KiB
Zig
//! 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("../root.zig");
|
|
const theme = @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,
|
|
};
|
|
|
|
/// Cached indicator data to avoid recomputation across frames.
|
|
/// All slices are owned and must be freed with deinit().
|
|
pub const CachedIndicators = struct {
|
|
closes: []f64,
|
|
volumes: []f64,
|
|
bb: []?zfin.indicators.BollingerBand,
|
|
rsi_vals: []?f64,
|
|
|
|
/// Free all allocated memory.
|
|
pub fn deinit(self: *CachedIndicators, alloc: std.mem.Allocator) void {
|
|
alloc.free(self.closes);
|
|
alloc.free(self.volumes);
|
|
alloc.free(self.bb);
|
|
alloc.free(self.rsi_vals);
|
|
self.* = undefined;
|
|
}
|
|
};
|
|
|
|
/// Compute indicators for the given candle data.
|
|
/// Returns owned CachedIndicators that must be freed with deinit().
|
|
pub fn computeIndicators(
|
|
alloc: std.mem.Allocator,
|
|
candles: []const zfin.Candle,
|
|
timeframe: Timeframe,
|
|
) !CachedIndicators {
|
|
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);
|
|
errdefer alloc.free(closes);
|
|
|
|
const vols = try zfin.indicators.volumes(alloc, data);
|
|
errdefer alloc.free(vols);
|
|
|
|
// Compute indicators
|
|
const bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0);
|
|
errdefer alloc.free(bb);
|
|
|
|
const rsi_vals = try zfin.indicators.rsi(alloc, closes, 14);
|
|
|
|
return .{
|
|
.closes = closes,
|
|
.volumes = vols,
|
|
.bb = bb,
|
|
.rsi_vals = rsi_vals,
|
|
};
|
|
}
|
|
|
|
/// Render a complete financial chart to raw RGB pixel data.
|
|
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
|
|
/// If `cached` is provided, uses pre-computed indicators instead of recomputing.
|
|
pub fn renderChart(
|
|
io: std.Io,
|
|
alloc: std.mem.Allocator,
|
|
candles: []const zfin.Candle,
|
|
timeframe: Timeframe,
|
|
width_px: u32,
|
|
height_px: u32,
|
|
th: theme.Theme,
|
|
cached: ?*const CachedIndicators,
|
|
) !ChartResult {
|
|
if (candles.len < 20) return error.InsufficientData;
|
|
|
|
// Slice candles to timeframe
|
|
const max_days = timeframe.tradingDays();
|
|
const n = @min(candles.len, max_days);
|
|
const data = candles[candles.len - n ..];
|
|
|
|
// Use cached indicators or compute fresh ones
|
|
var local_closes: ?[]f64 = null;
|
|
var local_vols: ?[]f64 = null;
|
|
var local_bb: ?[]?zfin.indicators.BollingerBand = null;
|
|
var local_rsi: ?[]?f64 = null;
|
|
defer {
|
|
if (local_closes) |c| alloc.free(c);
|
|
if (local_vols) |v| alloc.free(v);
|
|
if (local_bb) |b| alloc.free(b);
|
|
if (local_rsi) |r| alloc.free(r);
|
|
}
|
|
|
|
const closes: []const f64 = if (cached) |c| c.closes else blk: {
|
|
local_closes = try zfin.indicators.closePrices(alloc, data);
|
|
break :blk local_closes.?;
|
|
};
|
|
const vols: []const f64 = if (cached) |c| c.volumes else blk: {
|
|
local_vols = try zfin.indicators.volumes(alloc, data);
|
|
break :blk local_vols.?;
|
|
};
|
|
const bb: []const ?zfin.indicators.BollingerBand = if (cached) |c| c.bb else blk: {
|
|
local_bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0);
|
|
break :blk local_bb.?;
|
|
};
|
|
const rsi_vals: []const ?f64 = if (cached) |c| c.rsi_vals else blk: {
|
|
local_rsi = try zfin.indicators.rsi(alloc, closes, 14);
|
|
break :blk local_rsi.?;
|
|
};
|
|
|
|
// Create z2d surface — use RGB (not RGBA) since we're rendering onto a solid
|
|
// background. This avoids integer overflow in z2d's RGBA compositor when
|
|
// 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(io, 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, chart_left, chart_right, price_top, price_bottom, 5, grid_color);
|
|
try drawHorizontalGridLines(&ctx, chart_left, chart_right, rsi_top, rsi_bottom, 4, grid_color);
|
|
|
|
// ── Volume bars (overlaid on price panel bottom 25%) ─────────────
|
|
{
|
|
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, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .upper);
|
|
try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .lower);
|
|
// SMA (middle)
|
|
try drawLineSeries(&ctx, 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, chart_left, chart_right, mapY(70, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
|
|
try drawHLine(&ctx, chart_left, chart_right, mapY(30, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
|
|
try drawHLine(&ctx, chart_left, chart_right, mapY(50, 0, 100, rsi_top, rsi_bottom), blendColor(th.text_muted, 50, bg), 1.0);
|
|
|
|
const rsi_color = blendColor(th.info, 220, bg);
|
|
ctx.setSourceToPixel(rsi_color);
|
|
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, chart_left, price_top, chart_right, price_bottom, border_color, 1.0);
|
|
try drawRect(&ctx, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0);
|
|
}
|
|
|
|
// Get latest RSI
|
|
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,
|
|
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 {
|
|
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,
|
|
left: f64,
|
|
right: f64,
|
|
top: f64,
|
|
bottom: f64,
|
|
n_lines: usize,
|
|
col: Pixel,
|
|
) !void {
|
|
ctx.setSourceToPixel(col);
|
|
ctx.setLineWidth(0.5);
|
|
for (1..n_lines) |i| {
|
|
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
|
|
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, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void {
|
|
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, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, w: f64) !void {
|
|
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);
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────
|
|
|
|
test "mapY maps value to pixel coordinate" {
|
|
// value at min → bottom
|
|
try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
|
|
// value at max → top
|
|
try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
|
|
// value at midpoint → midpoint
|
|
try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
|
|
// flat range → midpoint
|
|
try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
|
|
}
|
|
|
|
test "blendColor alpha blending" {
|
|
const white = [3]u8{ 255, 255, 255 };
|
|
const black = [3]u8{ 0, 0, 0 };
|
|
|
|
// Full alpha → foreground
|
|
const full = blendColor(white, 255, black);
|
|
try std.testing.expectEqual(@as(u8, 255), full.rgb.r);
|
|
try std.testing.expectEqual(@as(u8, 255), full.rgb.g);
|
|
|
|
// Zero alpha → background
|
|
const zero = blendColor(white, 0, black);
|
|
try std.testing.expectEqual(@as(u8, 0), zero.rgb.r);
|
|
|
|
// Half alpha → midpoint
|
|
const half = blendColor(white, 128, black);
|
|
// 255 * (128/255) + 0 * (127/255) ≈ 128
|
|
try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
|
|
}
|
|
|
|
test "opaqueColor wraps theme color" {
|
|
const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f });
|
|
try std.testing.expectEqual(@as(u8, 0x7f), px.rgb.r);
|
|
try std.testing.expectEqual(@as(u8, 0xd8), px.rgb.g);
|
|
try std.testing.expectEqual(@as(u8, 0x8f), px.rgb.b);
|
|
}
|
|
|
|
test "ChartConfig.parse" {
|
|
// Named modes
|
|
const auto = ChartConfig.parse("auto").?;
|
|
try std.testing.expectEqual(ChartMode.auto, auto.mode);
|
|
|
|
const braille = ChartConfig.parse("braille").?;
|
|
try std.testing.expectEqual(ChartMode.braille, braille.mode);
|
|
|
|
// WxH format
|
|
const custom = ChartConfig.parse("800x600").?;
|
|
try std.testing.expectEqual(ChartMode.kitty, custom.mode);
|
|
try std.testing.expectEqual(@as(u32, 800), custom.max_width);
|
|
try std.testing.expectEqual(@as(u32, 600), custom.max_height);
|
|
|
|
// Too small
|
|
try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("50x50"));
|
|
|
|
// Invalid
|
|
try std.testing.expectEqual(@as(?ChartConfig, null), ChartConfig.parse("garbage"));
|
|
}
|
|
|
|
test "Timeframe next/prev cycle" {
|
|
// next cycles through all values
|
|
try std.testing.expectEqual(Timeframe.ytd, Timeframe.@"6M".next());
|
|
try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.next());
|
|
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"5Y".next()); // wraps
|
|
|
|
// prev is the reverse
|
|
try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"6M".prev()); // wraps
|
|
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev());
|
|
}
|
|
|
|
test "Timeframe tradingDays" {
|
|
try std.testing.expectEqual(@as(usize, 126), Timeframe.@"6M".tradingDays());
|
|
try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays());
|
|
try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays());
|
|
}
|