correctly color history when open == close (mutual funds)
All checks were successful
Generic zig build / build (push) Successful in 5m23s
Generic zig build / publish-macos (push) Successful in 13s
Generic zig build / deploy (push) Successful in 20s

This commit is contained in:
Emil Lerch 2026-09-01 15:15:08 -07:00
parent a137d3c65a
commit 6bd063d51f
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 508 additions and 24 deletions

View file

@ -127,6 +127,34 @@ already exist and have caught me out:
trailing-returns and gain/loss displays. (Lives in
`analytics/performance.zig` because returns are a
performance-domain concept; reusing it from elsewhere is fine.)
- `Candle.direction(prev)` -> `Candle.Direction` - **the** answer to
"was this day green or red." Read its doc comment before touching
any per-day colouring; the rule is deliberately hybrid and both
branches are load-bearing. Render it via `cli.printDirection`
(CLI) or `theme.directionStyle` (TUI) - never map the enum by
hand at a call site.
**Never open-code a per-day up/down comparison.** `close >= open` is
unconditionally true for anything priced once per day at NAV - mutual
funds, unitized 401(k)/529 trusts - because every provider reports
`open == high == low == close` for them (verified: Tiingo and Yahoo
both do). Three separate surfaces each open-coded that test, and 16 of
40 symbols in a real cache rendered permanently green for their entire
history, including runs of consecutive down days. The inverse mistake
is just as bad: a uniform day-over-day rule repaints ~17% of a normal
stock's rows (AAPL 16.1%, SPY 18.7%) red while their own Open and
Close columns say they rose. Call `Candle.direction` and move on.
Surfaces that legitimately answer a *different* question, and must NOT
be "fixed" to use `Candle.direction`:
- `charts/braille.zig` colours by vertical position within the price
range, not by direction.
- `charts/chart.zig` price line/fill and `charts/line_chart.zig`
colour the *whole window* by first-vs-last close.
- The `Change (1D)` summary rows in `commands/quote.zig` and
`tui/quote_tab.zig` compare the live price to `prev_close`, which is
a headline figure rather than a per-row candle direction.
**Search recipes that catch the most cases:**
@ -145,6 +173,10 @@ grep -rn "month() <\|day() <\|on\\.year() -" src/ # ad-hoc age math
# Time / now
grep -rn "Timestamp.now\|fromEpoch\|toEpoch" src/
# Per-day up/down direction - there must be exactly one rule.
# Any hit outside models/candle.zig is a bug:
grep -rn "close >= .*open\|close > .*open" src/
```
If the search turns up an existing helper that does what you need,

View file

@ -402,15 +402,10 @@ pub fn renderToSurface(
const vol_h_px = (vols[ci] / vol_max) * vol_panel_h;
const bar_top = vol_bottom_y - vol_h_px;
// Up/down coloring: compare today's chart-close to
// yesterday's chart-close (both split-adjusted when
// available). Comparing close-vs-open here would render
// a spurious "down" day on every split date because
// `Candle.open` is not split-adjusted but `chartClose`
// is - see `Candle.chartClose` for context.
const cc = candle.chartClose();
const prev_cc = if (ci > 0) data[ci - 1].chartClose() else cc;
const is_up = cc >= prev_cc;
// Up/down coloring comes from `Candle.direction`, the single
// source of truth shared with the CLI and TUI candle tables.
const prev: ?zfin.Candle = if (ci > 0) data[ci - 1] else null;
const is_up = candle.direction(prev) == .up;
const col = if (is_up) blendColor(th.positive, 50, bg) else blendColor(th.negative, 50, bg);
ctx.setSourceToPixel(col);
ctx.resetPath();

View file

@ -136,6 +136,22 @@ pub fn printGainLoss(
try reset(out, c);
}
/// Print a per-day row colored by a candle's direction.
///
/// The one CLI adapter for `Candle.Direction`; `theme.directionStyle` is
/// its TUI counterpart. Both exist so the up/down *rule* lives in
/// exactly one place - see `Candle.direction`, which is the single
/// source of truth. Never open-code the comparison at a call site.
pub fn printDirection(
out: *std.Io.Writer,
c: bool,
dir: zfin.Candle.Direction,
comptime fmt_str: []const u8,
args: anytype,
) !void {
try printGainLoss(out, c, if (dir == .up) @as(f64, 1) else -1, fmt_str, args);
}
// Stderr helpers
// stderr writers (re-exports of `src/stderr.zig`)

View file

@ -270,10 +270,19 @@ fn runSymbol(
const c = fmt.filterCandlesFrom(all, one_month_ago);
if (c.len == 0) return cli.stderrPrint(io, "No data available.\n");
try displaySymbol(c, symbol, color, out);
// `filterCandlesFrom` returns a suffix of `all`, so the window starts
// at `all.len - c.len`. Hand `displaySymbol` the candle just before
// it so the first displayed row has a real prior close to compare
// against rather than defaulting to a gain.
const before: ?zfin.Candle = if (c.len < all.len) all[all.len - c.len - 1] else null;
try displaySymbol(c, before, symbol, color, out);
}
pub fn displaySymbol(candles: []const zfin.Candle, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
/// Render a candle table. `before` is the candle immediately preceding
/// `candles` when one exists, used only to colour the first row; pass
/// null when `candles` starts the series.
pub fn displaySymbol(candles: []const zfin.Candle, before: ?zfin.Candle, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
try cli.printBold(out, color, "\nPrice History for {s} (last 30 days)\n", .{symbol});
try out.print("========================================\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
@ -285,9 +294,10 @@ pub fn displaySymbol(candles: []const zfin.Candle, symbol: []const u8, color: bo
});
try cli.reset(out, color);
for (candles) |candle| {
for (candles, 0..) |candle, i| {
var vb: [32]u8 = undefined;
try cli.printGainLoss(out, color, if (candle.close >= candle.open) 1.0 else -1.0, "{f} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{
const prev: ?zfin.Candle = if (i > 0) candles[i - 1] else before;
try cli.printDirection(out, color, candle.direction(prev), "{f} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{
candle.date.padLeft(12), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
});
}
@ -1125,7 +1135,7 @@ test "displaySymbol shows header and candle data" {
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_500_000 },
.{ .date = .{ .days = 20001 }, .open = 103.0, .high = 107.0, .low = 102.0, .close = 101.0, .adj_close = 101.0, .volume = 2_000_000 },
};
try displaySymbol(&candles, "AAPL", false, &w);
try displaySymbol(&candles, null, "AAPL", false, &w);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try testing.expect(std.mem.indexOf(u8, out, "Date") != null);
@ -1138,12 +1148,56 @@ test "displaySymbol empty candles" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{};
try displaySymbol(&candles, "XYZ", false, &w);
try displaySymbol(&candles, null, "XYZ", false, &w);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "XYZ") != null);
try testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null);
}
test "displaySymbol colours a NAV-priced fund's down day as a loss" {
// Regression: open == high == low == close for once-daily-priced
// instruments, so the old `close >= open` test made every row green.
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 9.50, .high = 9.50, .low = 9.50, .close = 9.50, .adj_close = 9.50, .volume = 0 },
.{ .date = .{ .days = 20001 }, .open = 9.49, .high = 9.49, .low = 9.49, .close = 9.49, .adj_close = 9.49, .volume = 0 },
};
try displaySymbol(&candles, null, "NAVFUND", true, &w);
const out = w.buffered();
var neg_buf: [32]u8 = undefined;
const negative = try std.fmt.bufPrint(&neg_buf, "\x1b[38;2;{d};{d};{d}m", .{
cli.CLR_NEGATIVE[0], cli.CLR_NEGATIVE[1], cli.CLR_NEGATIVE[2],
});
try testing.expect(std.mem.indexOf(u8, out, negative) != null);
}
test "displaySymbol uses `before` to colour its first row" {
// A single-row window whose only candle is NAV-shaped has no
// in-window predecessor; `before` supplies the prior close.
const window = [_]zfin.Candle{
.{ .date = .{ .days = 20001 }, .open = 9.49, .high = 9.49, .low = 9.49, .close = 9.49, .adj_close = 9.49, .volume = 0 },
};
const before = zfin.Candle{ .date = .{ .days = 20000 }, .open = 9.50, .high = 9.50, .low = 9.50, .close = 9.50, .adj_close = 9.50, .volume = 0 };
var neg_buf: [32]u8 = undefined;
const negative = try std.fmt.bufPrint(&neg_buf, "\x1b[38;2;{d};{d};{d}m", .{
cli.CLR_NEGATIVE[0], cli.CLR_NEGATIVE[1], cli.CLR_NEGATIVE[2],
});
// With `before`: a real prior close, so the drop reads as a loss.
var with_buf: [4096]u8 = undefined;
var with_w: std.Io.Writer = .fixed(&with_buf);
try displaySymbol(&window, before, "NAVFUND", true, &with_w);
try testing.expect(std.mem.indexOf(u8, with_w.buffered(), negative) != null);
// Without it: nothing to compare against, so the row reads as a gain.
var without_buf: [4096]u8 = undefined;
var without_w: std.Io.Writer = .fixed(&without_buf);
try displaySymbol(&window, null, "NAVFUND", true, &without_w);
try testing.expect(std.mem.indexOf(u8, without_w.buffered(), negative) == null);
}
// chart export
test "metricLinePoints converts MetricPoints preserving order and values" {

View file

@ -461,11 +461,15 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
"Date", "Open", "High", "Low", "Close", "Volume",
});
// The window is the last 20 candles, but the direction of its
// first row is measured against the candle *before* the window
// (via the absolute index `i`), so the top row gets a real prior
// close instead of defaulting to a gain.
const start_idx = if (candles.len > 20) candles.len - 20 else 0;
for (candles[start_idx..]) |candle| {
for (candles[start_idx..], start_idx..) |candle, i| {
var row_buf: [128]u8 = undefined;
const day_gain = candle.close >= candle.open;
try cli.printGainLoss(out, color, if (day_gain) 1.0 else -1.0, "{s}\n", .{fmt.fmtCandleRow(&row_buf, candle)});
const prev: ?zfin.Candle = if (i > 0) candles[i - 1] else null;
try cli.printDirection(out, color, candle.direction(prev), "{s}\n", .{fmt.fmtCandleRow(&row_buf, candle)});
}
try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len});
}
@ -675,3 +679,54 @@ test "display no ANSI without color" {
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
}
test "display: a NAV-priced fund's down day is coloured as a loss" {
// Regression: open == high == low == close for once-daily-priced
// instruments (mutual funds, unitized trusts), so the old
// `close >= open` test rendered every row in the Recent History
// table green - even a run of consecutive down days.
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 9.55, .high = 9.55, .low = 9.55, .close = 9.55, .adj_close = 9.55, .volume = 0 },
.{ .date = .{ .days = 20001 }, .open = 9.54, .high = 9.54, .low = 9.54, .close = 9.54, .adj_close = 9.54, .volume = 0 },
.{ .date = .{ .days = 20002 }, .open = 9.49, .high = 9.49, .low = 9.49, .close = 9.49, .adj_close = 9.49, .volume = 0 },
};
try display(std.testing.allocator, &candles, null, "NAVFUND", null, zfin.Date.fromYmd(2026, 5, 8), true, &w, 60, "3M", .braille);
const out = w.buffered();
var neg_buf: [32]u8 = undefined;
const negative = try std.fmt.bufPrint(&neg_buf, "\x1b[38;2;{d};{d};{d}m", .{
cli.CLR_NEGATIVE[0], cli.CLR_NEGATIVE[1], cli.CLR_NEGATIVE[2],
});
try std.testing.expect(std.mem.indexOf(u8, out, negative) != null);
}
test "display: a real green candle inside a downtrend stays green" {
// Guards against "simplifying" the rule to uniform day-over-day,
// which would paint this row red while its own Open and Close
// columns say it rose.
var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 157.0, .high = 158.0, .low = 154.0, .close = 155.0, .adj_close = 155.0, .volume = 1_000_000 },
.{ .date = .{ .days = 20001 }, .open = 150.0, .high = 154.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 1_000_000 },
};
try display(std.testing.allocator, &candles, null, "STOCK", null, zfin.Date.fromYmd(2026, 5, 8), true, &w, 60, "3M", .braille);
const out = w.buffered();
var neg_buf: [32]u8 = undefined;
const negative = try std.fmt.bufPrint(&neg_buf, "\x1b[38;2;{d};{d};{d}m", .{
cli.CLR_NEGATIVE[0], cli.CLR_NEGATIVE[1], cli.CLR_NEGATIVE[2],
});
// Exactly one red row in the table: the first candle has no
// predecessor and closed below its own open (157 -> 155). The
// 150 -> 153 row must stay green despite closing below 155.
const rows_start = std.mem.indexOf(u8, out, "Recent History") orelse 0;
var reds: usize = 0;
var it = std.mem.splitScalar(u8, out[rows_start..], '\n');
while (it.next()) |line| {
if (std.mem.indexOf(u8, line, negative) != null) reds += 1;
}
try std.testing.expectEqual(@as(usize, 1), reds);
}

View file

@ -32,6 +32,59 @@ pub const Candle = struct {
pub fn chartClose(self: Candle) f64 {
return if (self.adj_close != 0) self.adj_close else self.close;
}
/// Whether a trading day renders as a gain or a loss.
///
/// A dead-flat day folds into `.up` (green), matching zfin's
/// `value >= 0` colour convention everywhere else.
pub const Direction = enum { up, down };
/// THE single source of truth for "was this day green or red".
///
/// Every CLI table, TUI table, and chart that colours a per-day row
/// or bar MUST call this. Do not re-derive the comparison at a call
/// site - three surfaces did, they drifted, and 40% of a typical
/// cache rendered permanently green as a result.
///
/// The rule is hybrid, because the two branches answer the only
/// questions the data can support:
///
/// 1. **The candle has a real intraday range (`open != close`).**
/// Use the candlestick convention: green when it closed at or
/// above its own open. That is the universal definition of a
/// bullish candle body, and it keeps the colour explicable from
/// the Open/Close columns printed in the same row.
///
/// 2. **`open == close`.** The candle is a doji and carries no
/// within-day signal whatsoever, so rule 1 degenerates to
/// "always green". Every instrument priced once per day at NAV
/// - mutual funds, unitized 401(k)/529 trusts - is reported
/// this way by every provider: Tiingo and Yahoo both return
/// `open == high == low == close` for a fund like VBTLX. Fall
/// back to day-over-day, which is also what Yahoo itself shows
/// for a fund (`chartPreviousClose` vs the current price).
///
/// Do NOT "simplify" this to a single uniform day-over-day rule.
/// That repaints ~17% of rows for a normal stock (measured: AAPL
/// 16.1%, SPY 18.7%, NVDA 15.3%, VTI 17.8%) as days that closed
/// above their own open but below yesterday's close - producing a
/// red row whose own Open and Close columns say it rose.
///
/// The day-over-day branch compares `chartClose`, not raw `close`,
/// so it is split- and distribution-aware. VBTLX's raw NAV fell
/// 10.44 -> 9.49 across 25 years while its adjusted close rose
/// 4.23 -> 9.49; an ex-dividend NAV drop covered by the
/// distribution must not read as a loss.
///
/// `prev` is null only at the very start of a series, where there is
/// no prior close to compare against; that day reads `.up`.
pub fn direction(self: Candle, prev: ?Candle) Direction {
if (self.open != self.close) {
return if (self.close >= self.open) .up else .down;
}
const p = prev orelse return .up;
return if (self.chartClose() >= p.chartClose()) .up else .down;
}
};
/// Return the prefix of `candles` whose dates are `<= as_of`.
@ -114,6 +167,158 @@ test "chartClose synthetic split has continuous chart values" {
try std.testing.expectEqual(pre.chartClose(), post.chartClose());
}
// direction tests
//
// These pin the hybrid rule documented on `Candle.direction`. The
// NAV-shaped cases are the regression tests for the bug where every
// once-daily-priced instrument (14 Tiingo mutual funds plus 2 unitized
// trusts in a typical cache) rendered permanently green because
// `close >= open` is unconditionally true when open == close.
/// A candle with a real intraday range, as a normal stock reports.
fn makeRangeCandle(open: f64, close: f64, adj_close: f64) Candle {
return .{
.date = Date.fromYmd(2024, 6, 10),
.open = open,
.high = @max(open, close),
.low = @min(open, close),
.close = close,
.adj_close = adj_close,
.volume = 1000,
};
}
test "direction: NAV-priced fund down day is a loss" {
// The reported bug: VBTLX 9.50 -> 9.49, open == close on both days.
const prev = makeTestCandle(2026, 8, 28, 9.50);
const today = makeTestCandle(2026, 8, 31, 9.49);
try testing.expectEqual(Candle.Direction.down, today.direction(prev));
}
test "direction: NAV-priced fund up day is a gain" {
const prev = makeTestCandle(2026, 8, 24, 9.51);
const today = makeTestCandle(2026, 8, 25, 9.55);
try testing.expectEqual(Candle.Direction.up, today.direction(prev));
}
test "direction: NAV-priced fund unchanged day reads as a gain" {
// Flat folds into .up by design; see Candle.Direction.
const prev = makeTestCandle(2026, 8, 27, 9.50);
const today = makeTestCandle(2026, 8, 28, 9.50);
try testing.expectEqual(Candle.Direction.up, today.direction(prev));
}
test "direction: no previous candle reads as a gain" {
// First bar of a series: nothing to compare against.
const today = makeTestCandle(2026, 8, 31, 9.49);
try testing.expectEqual(Candle.Direction.up, today.direction(null));
}
test "direction: a real intraday range uses close vs open" {
const up = makeRangeCandle(150.0, 153.0, 153.0);
try testing.expectEqual(Candle.Direction.up, up.direction(null));
const down = makeRangeCandle(153.0, 150.0, 150.0);
try testing.expectEqual(Candle.Direction.down, down.direction(null));
}
test "direction: a real green candle stays green inside a downtrend" {
// Closed above its own open (150 -> 153) but below yesterday's
// close (155). The candlestick convention wins, so the colour
// agrees with the Open/Close columns printed beside it. A uniform
// day-over-day rule would paint this red and repaint ~17% of every
// normal stock's history; that is the regression this test blocks.
const prev = makeRangeCandle(157.0, 155.0, 155.0);
const today = makeRangeCandle(150.0, 153.0, 153.0);
try testing.expectEqual(Candle.Direction.up, today.direction(prev));
}
test "direction: a real red candle stays red inside an uptrend" {
const prev = makeRangeCandle(140.0, 145.0, 145.0);
const today = makeRangeCandle(153.0, 150.0, 150.0);
try testing.expectEqual(Candle.Direction.down, today.direction(prev));
}
test "direction: a split date is not a false loss" {
// NVDA 10-for-1 on 2024-06-10. The raw close collapses 1180 -> 122,
// but close-vs-open is a same-basis comparison within one row, so
// the split cannot manufacture a red day.
const pre = makeRangeCandle(1170.0, 1180.0, 118.0);
const post = makeRangeCandle(120.0, 122.0, 122.0);
try testing.expectEqual(Candle.Direction.up, post.direction(pre));
}
test "direction: a NAV-shaped split date is not a false loss either" {
// Degenerate row on a split date takes the day-over-day branch,
// which compares chartClose - continuous across the split.
const pre = Candle{
.date = Date.fromYmd(2024, 3, 6),
.open = 300,
.high = 300,
.low = 300,
.close = 300,
.adj_close = 100,
.volume = 0,
};
const post = Candle{
.date = Date.fromYmd(2024, 3, 7),
.open = 100,
.high = 100,
.low = 100,
.close = 100,
.adj_close = 100,
.volume = 0,
};
try testing.expectEqual(Candle.Direction.up, post.direction(pre));
}
test "direction: an ex-dividend NAV drop covered by the distribution is a gain" {
// Raw NAV falls 9.55 -> 9.50, but the adjusted close rises because
// the distribution more than covers the drop. A holder made money.
const prev = Candle{
.date = Date.fromYmd(2026, 8, 27),
.open = 9.55,
.high = 9.55,
.low = 9.55,
.close = 9.55,
.adj_close = 9.55,
.volume = 0,
};
const today = Candle{
.date = Date.fromYmd(2026, 8, 28),
.open = 9.50,
.high = 9.50,
.low = 9.50,
.close = 9.50,
.adj_close = 9.58,
.volume = 0,
};
try testing.expectEqual(Candle.Direction.up, today.direction(prev));
}
test "direction: falls back to raw close when adj_close is unusable" {
// Yahoo's parser yields 0 for a JSON null; chartClose covers it.
const prev = Candle{
.date = Date.fromYmd(2024, 1, 1),
.open = 100,
.high = 100,
.low = 100,
.close = 100,
.adj_close = 0,
.volume = 0,
};
const today = Candle{
.date = Date.fromYmd(2024, 1, 2),
.open = 99,
.high = 99,
.low = 99,
.close = 99,
.adj_close = 0,
.volume = 0,
};
try testing.expectEqual(Candle.Direction.down, today.direction(prev));
}
// sliceCandlesAsOf tests
fn makeTestCandle(y: i16, m: u8, d: u8, close: f64) Candle {

View file

@ -871,20 +871,43 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
try tui.renderBrailleToStyledLines(arena, &lines, chart_data, th);
// Recent history table
try appendRecentHistoryRows(arena, &lines, c, th);
return lines.toOwnedSlice(arena);
}
/// Append the "Recent History" table - header plus the last 20 candles,
/// newest first - to `lines`.
///
/// Split out of `buildStyledLines` so it is reachable from a test
/// without an interactive vaxis context: it takes only candles and a
/// theme, both plain data.
fn appendRecentHistoryRows(
arena: std.mem.Allocator,
lines: *std.ArrayList(StyledLine),
c: []const zfin.Candle,
th: theme.Theme,
) !void {
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });
// Newest first. Decrement-first rather than a `curr >= end_idx`
// continue-expression: `curr` is a usize, so when `end_idx` is 0 the
// old form ran the body at curr == 0 and then overflowed on the way
// out, panicking for every symbol with 20 or fewer cached candles.
const end_idx = if (c.len > 20) c.len - 20 else 0;
var curr = c.len - 1;
while (curr >= end_idx) : (curr -= 1) {
var curr = c.len;
while (curr > end_idx) {
curr -= 1;
const candle = c[curr];
var row_buf: [128]u8 = undefined;
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });
const prev: ?zfin.Candle = if (curr > 0) c[curr - 1] else null;
try lines.append(arena, .{
.text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)),
.style = th.directionStyle(candle.direction(prev)),
});
}
return lines.toOwnedSlice(arena);
}
// Quote detail columns (price/OHLCV | ETF stats | sectors | holdings)
@ -1204,3 +1227,96 @@ test "formatQuoteHeader: streaming source shows a real-time price and (live) tag
try formatQuoteHeader(arena, "AAPL", "Apple Inc.", .{ .streaming = 282.01 }),
);
}
// appendRecentHistoryRows tests
/// A NAV-shaped candle: open == high == low == close == adj_close, which
/// is how every provider reports an instrument priced once per day.
fn navCandle(days: i32, nav: f64) zfin.Candle {
return .{ .date = .{ .days = days }, .open = nav, .high = nav, .low = nav, .close = nav, .adj_close = nav, .volume = 0 };
}
test "appendRecentHistoryRows: a short series does not overflow its index" {
// Regression: `end_idx` is 0 whenever there are 20 or fewer candles,
// and the old `while (curr >= end_idx) : (curr -= 1)` form wrapped a
// usize past zero on the way out. 11 candles reproduces it.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var candles: [11]zfin.Candle = undefined;
for (&candles, 0..) |*candle, i| candle.* = navCandle(20000 + @as(i32, @intCast(i)), 10.0 + @as(f64, @floatFromInt(i)));
var lines: std.ArrayList(StyledLine) = .empty;
try appendRecentHistoryRows(arena, &lines, &candles, theme.default_theme);
// A blank spacer, the "Recent History:" label, the column header,
// then one row per candle.
try testing.expectEqual(@as(usize, 3 + 11), lines.items.len);
}
test "appendRecentHistoryRows: a single candle does not overflow either" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const candles = [_]zfin.Candle{navCandle(20000, 10.0)};
var lines: std.ArrayList(StyledLine) = .empty;
try appendRecentHistoryRows(arena, &lines, &candles, theme.default_theme);
try testing.expectEqual(@as(usize, 3 + 1), lines.items.len);
}
test "appendRecentHistoryRows: caps the window at 20 rows" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var candles: [40]zfin.Candle = undefined;
for (&candles, 0..) |*candle, i| candle.* = navCandle(20000 + @as(i32, @intCast(i)), 10.0 + @as(f64, @floatFromInt(i)));
var lines: std.ArrayList(StyledLine) = .empty;
try appendRecentHistoryRows(arena, &lines, &candles, theme.default_theme);
try testing.expectEqual(@as(usize, 3 + 20), lines.items.len);
}
test "appendRecentHistoryRows: a NAV-priced fund's down day is styled as a loss" {
// The reported bug: these rows all rendered green because
// `close >= open` is unconditionally true when open == close.
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme.default_theme;
const candles = [_]zfin.Candle{
navCandle(20000, 9.55),
navCandle(20001, 9.54), // down
navCandle(20002, 9.60), // up
};
var lines: std.ArrayList(StyledLine) = .empty;
try appendRecentHistoryRows(arena, &lines, &candles, th);
try testing.expectEqual(@as(usize, 3 + 3), lines.items.len);
// Rows are newest-first, so items[3] is 9.60 (up), [4] is 9.54
// (down), and [5] is 9.55 (no predecessor, so a gain).
try testing.expectEqual(th.positiveStyle(), lines.items[3].style);
try testing.expectEqual(th.negativeStyle(), lines.items[4].style);
try testing.expectEqual(th.positiveStyle(), lines.items[5].style);
}
test "appendRecentHistoryRows: a real green candle in a downtrend stays green" {
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const th = theme.default_theme;
const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 157, .high = 158, .low = 154, .close = 155, .adj_close = 155, .volume = 1 },
// Closed above its own open but below yesterday's close.
.{ .date = .{ .days = 20001 }, .open = 150, .high = 154, .low = 149, .close = 153, .adj_close = 153, .volume = 1 },
};
var lines: std.ArrayList(StyledLine) = .empty;
try appendRecentHistoryRows(arena, &lines, &candles, th);
try testing.expectEqual(th.positiveStyle(), lines.items[3].style);
}

View file

@ -2,6 +2,7 @@ const std = @import("std");
const vaxis = @import("vaxis");
const srf = @import("srf");
const fmt = @import("../format.zig");
const Candle = @import("../models/candle.zig").Candle;
pub const Color = [3]u8;
@ -104,6 +105,16 @@ pub const Theme = struct {
return .{ .fg = vcolor(self.negative), .bg = vcolor(self.bg) };
}
/// Style a per-day row or bar from a candle's direction.
///
/// The one TUI adapter for `Candle.Direction`; `cli.printDirection`
/// is its CLI counterpart. Both exist so the up/down *rule* lives in
/// exactly one place - see `Candle.direction`, which is the single
/// source of truth. Never open-code the comparison in a tab.
pub fn directionStyle(self: Theme, dir: Candle.Direction) vaxis.Style {
return if (dir == .up) self.positiveStyle() else self.negativeStyle();
}
pub fn borderStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.border), .bg = vcolor(self.bg) };
}