correctly color history when open == close (mutual funds)
This commit is contained in:
parent
a137d3c65a
commit
6bd063d51f
8 changed files with 508 additions and 24 deletions
32
AGENTS.md
32
AGENTS.md
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`) ─────────
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue