add the period change to quote display on cli/tui
This commit is contained in:
parent
3e45393d93
commit
8e0288d437
4 changed files with 202 additions and 42 deletions
|
|
@ -46,11 +46,18 @@ SPY $746.74 (close)
|
|||
High: $748.23
|
||||
Low: $743.86
|
||||
Volume: 80,875,657
|
||||
Change: +$5.78 (+0.78%)
|
||||
Change (1D): +$5.78 (+0.78%)
|
||||
Change (3M): +$31.40 (+4.39%)
|
||||
|
||||
... (price chart over the selected window -- inline Kitty image, or braille)
|
||||
```
|
||||
|
||||
Two change rows are shown: `Change (1D)` is the day-over-day move,
|
||||
and `Change (<span>)` is the move across the chart window (from the
|
||||
first visible candle's close to the current price). The span label
|
||||
reflects `--since` (`3M` by default; `1Y`, `YTD`, or a date when set).
|
||||
In the TUI it's the selected chart timeframe instead (e.g. `Change (1Y)`).
|
||||
|
||||
## See also
|
||||
|
||||
- [`perf`](perf.md) -- trailing returns instead of a spot price.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ pub const ParsedArgs = struct {
|
|||
/// means the default window (last ~3 months). Accepts the same
|
||||
/// grammar as `history --since`: `YYYY-MM-DD`, `N[WMQY]`, or `ytd`.
|
||||
since: ?zfin.Date = null,
|
||||
/// The raw `--since` token as the user typed it (e.g. "1Y", "ytd",
|
||||
/// "2025-01-15"), kept only to label the "Change (<span>)" detail
|
||||
/// row. Null when `--since` was omitted.
|
||||
since_raw: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const meta: framework.Meta = .{
|
||||
|
|
@ -79,6 +83,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
var symbol: ?[]const u8 = null;
|
||||
var export_chart: ?[]const u8 = null;
|
||||
var since: ?zfin.Date = null;
|
||||
var since_raw: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < cmd_args.len) : (i += 1) {
|
||||
|
|
@ -88,6 +93,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
} else if (std.mem.eql(u8, a, "--since")) {
|
||||
const value = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
since = cli.parseRequiredDateOrStderr(ctx.io, value, ctx.today, "--since") catch return error.InvalidDate;
|
||||
since_raw = value;
|
||||
} else if (a.len > 0 and a[0] == '-') {
|
||||
// Reject ANY leading-dash token we don't recognize,
|
||||
// including single-dash ones like `-x`. Previously only
|
||||
|
|
@ -110,7 +116,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since };
|
||||
return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since, .since_raw = since_raw };
|
||||
}
|
||||
|
||||
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||
|
|
@ -201,7 +207,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.kitty => .{ .kitty = k },
|
||||
.auto => if (ctx.graphics_caps.kitty) .{ .kitty = k } else .braille,
|
||||
};
|
||||
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out, display_count, chart_render);
|
||||
|
||||
// Short label for the "Change (<span>)" row: echo the --since token
|
||||
// (upper-cased so "1y" -> "1Y", "ytd" -> "YTD") or "3M" for the
|
||||
// default window. Backed by a stack buffer that outlives `display`.
|
||||
var wl_buf: [16]u8 = undefined;
|
||||
const window_label: []const u8 = if (parsed.since_raw) |raw| blk: {
|
||||
const len = @min(raw.len, wl_buf.len);
|
||||
break :blk std.ascii.upperString(wl_buf[0..len], raw[0..len]);
|
||||
} else "3M";
|
||||
|
||||
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out, display_count, window_label, chart_render);
|
||||
}
|
||||
|
||||
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
|
||||
|
|
@ -287,7 +303,7 @@ fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []
|
|||
try term_graphics.placeInline(out, allocator, rgb, dims.width, dims.height, cols, rows);
|
||||
}
|
||||
|
||||
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer, display_count: usize, chart_render: ChartRender) !void {
|
||||
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer, display_count: usize, window_label: []const u8, chart_render: ChartRender) !void {
|
||||
const has_quote = quote != null;
|
||||
|
||||
// Header. The security name (when resolved) renders between the
|
||||
|
|
@ -325,11 +341,20 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
|
|||
try out.print(" Low: ${d:.2}\n", .{low_val});
|
||||
try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)});
|
||||
|
||||
if (prev_close > 0) {
|
||||
const change = price - prev_close;
|
||||
const pct = (change / prev_close) * 100.0;
|
||||
if (fmt.pctChange(price, prev_close)) |dc| {
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
try cli.printGainLoss(out, color, change, " Change: {s}\n", .{fmt.fmtPriceChange(&chg_buf, change, pct)});
|
||||
try cli.printGainLoss(out, color, dc.change, " {s:<14} {s}\n", .{ "Change (1D):", fmt.fmtPriceChange(&chg_buf, dc.change, dc.pct) });
|
||||
}
|
||||
|
||||
// Change over the chart window: the first visible candle's close
|
||||
// (the chart's left edge) vs the current price. The `(<span>)`
|
||||
// mirrors the --since window, disambiguating it from the 1-day
|
||||
// change above.
|
||||
if (fmt.windowChange(candles, display_count, price)) |wc| {
|
||||
var wbuf: [64]u8 = undefined;
|
||||
var lbl_buf: [32]u8 = undefined;
|
||||
const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{window_label}) catch "Change:";
|
||||
try cli.printGainLoss(out, color, wc.change, " {s:<14} {s}\n", .{ lbl, fmt.fmtPriceChange(&wbuf, wc.change, wc.pct) });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -470,7 +495,7 @@ test "display with candles only" {
|
|||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
.{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
|
||||
|
|
@ -478,6 +503,24 @@ test "display with candles only" {
|
|||
try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days shown") != null);
|
||||
}
|
||||
|
||||
test "display renders both the 1D and window change rows" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 100.0, .adj_close = 100.0, .volume = 1_000_000 },
|
||||
.{ .date = .{ .days = 20001 }, .open = 100.0, .high = 112.0, .low = 100.0, .close = 110.0, .adj_close = 110.0, .volume = 1_200_000 },
|
||||
.{ .date = .{ .days = 20002 }, .open = 110.0, .high = 130.0, .low = 108.0, .close = 125.0, .adj_close = 125.0, .volume = 1_500_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "1Y", .braille);
|
||||
const out = w.buffered();
|
||||
// 1-day change is the last close (125) vs the prior close (110).
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change (1D):") != null);
|
||||
// Window change carries the supplied span label and measures from
|
||||
// the first visible candle's close (100) to the current price (125).
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change (1Y):") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "+$25.00 (+25.00%)") != null);
|
||||
}
|
||||
|
||||
test "display with quote data" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -491,7 +534,7 @@ test "display with quote data" {
|
|||
.prev_close = 172.00,
|
||||
.date = .{ .days = 20001 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille);
|
||||
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
|
||||
|
|
@ -505,7 +548,7 @@ test "display renders the security name when provided" {
|
|||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
// Name appears between the symbol and the price.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null);
|
||||
|
|
@ -517,7 +560,7 @@ test "display omits an empty name" {
|
|||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
// No double-space orphan where the name would have gone.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null);
|
||||
|
|
@ -529,7 +572,7 @@ test "display no ANSI without color" {
|
|||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille);
|
||||
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -512,6 +512,37 @@ pub fn filterCandlesFrom(candles: []const Candle, from: Date) []const Candle {
|
|||
return candles[lo..];
|
||||
}
|
||||
|
||||
/// An absolute and percentage price change.
|
||||
pub const PctChange = struct {
|
||||
/// `price - base`, in price units.
|
||||
change: f64,
|
||||
/// `(price - base) / base * 100`, a percentage.
|
||||
pct: f64,
|
||||
};
|
||||
|
||||
/// Absolute and percentage change from `base` to `price`. Returns null
|
||||
/// when `base <= 0`, where a percentage isn't meaningful (e.g. a missing
|
||||
/// previous close). The single source of truth for the day-over-day and
|
||||
/// chart-window change figures shared by the `quote` CLI command and the
|
||||
/// TUI quote tab.
|
||||
pub fn pctChange(price: f64, base: f64) ?PctChange {
|
||||
if (base <= 0) return null;
|
||||
const change = price - base;
|
||||
return .{ .change = change, .pct = (change / base) * 100.0 };
|
||||
}
|
||||
|
||||
/// Change from the first candle of the most-recent `window_count` candles
|
||||
/// (the chart's left edge) to `price` (the current price). Used to label
|
||||
/// the "Change (<span>)" row in both the `quote` CLI command and the TUI
|
||||
/// quote tab. `window_count` is clamped to the available candles. Returns
|
||||
/// null when the window holds fewer than 2 candles or its first close is
|
||||
/// non-positive - i.e. there's no meaningful window to measure.
|
||||
pub fn windowChange(candles: []const Candle, window_count: usize, price: f64) ?PctChange {
|
||||
const win_n = @min(candles.len, window_count);
|
||||
if (win_n < 2) return null;
|
||||
return pctChange(price, candles[candles.len - win_n].close);
|
||||
}
|
||||
|
||||
// ── Options helpers ──────────────────────────────────────────
|
||||
|
||||
/// Filter options contracts to +/- N strikes from ATM.
|
||||
|
|
@ -1305,6 +1336,64 @@ test "filterCandlesFrom" {
|
|||
try std.testing.expectEqual(@as(usize, 0), from_empty.len);
|
||||
}
|
||||
|
||||
fn testCandle(close: f64) Candle {
|
||||
return .{ .date = Date.fromYmd(2024, 1, 2), .open = close, .high = close, .low = close, .close = close, .adj_close = close, .volume = 1000 };
|
||||
}
|
||||
|
||||
test "pctChange: up and down moves" {
|
||||
const up = pctChange(110.0, 100.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10.0), up.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10.0), up.pct, 1e-9);
|
||||
|
||||
const down = pctChange(90.0, 100.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, -10.0), down.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, -10.0), down.pct, 1e-9);
|
||||
}
|
||||
|
||||
test "pctChange: non-positive base returns null" {
|
||||
try std.testing.expect(pctChange(100.0, 0.0) == null);
|
||||
try std.testing.expect(pctChange(100.0, -5.0) == null);
|
||||
}
|
||||
|
||||
test "windowChange: measures from the first candle in the window" {
|
||||
const candles = [_]Candle{ testCandle(100.0), testCandle(110.0), testCandle(125.0) };
|
||||
// Full window: base = first close (100), price 125 -> +25 (+25%).
|
||||
const full = windowChange(&candles, 3, 125.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 25.0), full.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 25.0), full.pct, 1e-9);
|
||||
}
|
||||
|
||||
test "windowChange: window_count clamps to available candles" {
|
||||
const candles = [_]Candle{ testCandle(100.0), testCandle(110.0) };
|
||||
// Asking for 60 but only 2 exist -> base is the earliest (100).
|
||||
const wc = windowChange(&candles, 60, 121.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 21.0), wc.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 21.0), wc.pct, 1e-9);
|
||||
}
|
||||
|
||||
test "windowChange: sub-window picks candles[len-window_count] as base" {
|
||||
const candles = [_]Candle{ testCandle(50.0), testCandle(100.0), testCandle(120.0) };
|
||||
// window_count=2 -> base is candles[len-2] = 100, not 50.
|
||||
const wc = windowChange(&candles, 2, 120.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 20.0), wc.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 20.0), wc.pct, 1e-9);
|
||||
}
|
||||
|
||||
test "windowChange: fewer than 2 candles in the window returns null" {
|
||||
const one = [_]Candle{testCandle(100.0)};
|
||||
try std.testing.expect(windowChange(&one, 60, 110.0) == null);
|
||||
const empty: []const Candle = &.{};
|
||||
try std.testing.expect(windowChange(empty, 60, 110.0) == null);
|
||||
// window_count < 2 yields null even with enough candles.
|
||||
const two = [_]Candle{ testCandle(100.0), testCandle(110.0) };
|
||||
try std.testing.expect(windowChange(&two, 1, 110.0) == null);
|
||||
}
|
||||
|
||||
test "windowChange: non-positive base close returns null" {
|
||||
const candles = [_]Candle{ testCandle(0.0), testCandle(110.0) };
|
||||
try std.testing.expect(windowChange(&candles, 2, 110.0) == null);
|
||||
}
|
||||
|
||||
test "filterNearMoney" {
|
||||
const exp = Date.fromYmd(2024, 3, 15);
|
||||
const contracts = [_]OptionContract{
|
||||
|
|
|
|||
|
|
@ -189,14 +189,14 @@ pub const tab = struct {
|
|||
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
|
||||
if (content_row != tf_row) return false;
|
||||
|
||||
// Layout: " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
|
||||
// Layout: " Chart: [3M] 6M YTD 1Y 3Y 5Y ([ ] to change)"
|
||||
// Prefix " Chart: " is 9 chars. Each timeframe label takes
|
||||
// `label_len + 2` (brackets/spaces around the label) + 1 (gap).
|
||||
const col: usize = @intCast(mouse.col);
|
||||
const prefix_len: usize = 9;
|
||||
if (col < prefix_len) return false;
|
||||
|
||||
const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||
const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||
var x: usize = prefix_len;
|
||||
for (timeframes) |tf| {
|
||||
const lbl_len = tf.label().len;
|
||||
|
|
@ -273,28 +273,28 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
else
|
||||
app.symbol;
|
||||
|
||||
// Symbol + price header
|
||||
// Symbol + price header. The headline "Change" reflects the
|
||||
// selected chart period (the chart's left edge -> now); the less
|
||||
// relevant 1-day change lives in the detail section below the chart.
|
||||
const cur_tf = app.states.quote.chart.timeframe;
|
||||
const cur_price: ?f64 = if (app.states.quote.live) |q| q.close else if (c.len > 0) c[c.len - 1].close else null;
|
||||
|
||||
if (app.states.quote.live) |q| {
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ sym_label, q.close });
|
||||
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||
if (q.previous_close > 0) {
|
||||
const change = q.close - q.previous_close;
|
||||
const pct = (change / q.previous_close) * 100.0;
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||
}
|
||||
} else if (c.len > 0) {
|
||||
const last = c[c.len - 1];
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ sym_label, last.close });
|
||||
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||
if (c.len >= 2) {
|
||||
const prev_close = c[c.len - 2].close;
|
||||
const change = last.close - prev_close;
|
||||
const pct = (change / prev_close) * 100.0;
|
||||
}
|
||||
|
||||
if (cur_price) |price| {
|
||||
if (fmt.windowChange(c, cur_tf.tradingDays(), price)) |wc| {
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||
var lbl_buf: [24]u8 = undefined;
|
||||
const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{cur_tf.label()}) catch "Change:";
|
||||
const change_style = if (wc.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} {s}", .{ lbl, fmt.fmtPriceChange(&chg_buf, wc.change, wc.pct) }), .style = change_style });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,7 +305,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
const prefix = " Chart: ";
|
||||
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
|
||||
tf_pos += prefix.len;
|
||||
const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||
const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||
for (timeframes) |tf| {
|
||||
const lbl = tf.label();
|
||||
if (tf == app.states.quote.chart.timeframe) {
|
||||
|
|
@ -388,11 +388,15 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
// Free old cache if it exists
|
||||
app.states.quote.chart.freeCache(app.allocator);
|
||||
|
||||
// Compute and cache new indicators
|
||||
const new_cache = chart.computeIndicators(
|
||||
// Compute and cache new indicators. Use the warmup variant so
|
||||
// the Bollinger bands / RSI are computed over an extra lookback
|
||||
// beyond the timeframe window and stay valid from the first
|
||||
// *visible* candle - no warm-up gap at the chart's left edge.
|
||||
const new_cache = chart.computeIndicatorsWarmup(
|
||||
app.allocator,
|
||||
c,
|
||||
app.states.quote.chart.timeframe,
|
||||
app.states.quote.chart.timeframe.tradingDays(),
|
||||
20,
|
||||
) catch |err| {
|
||||
app.states.quote.chart.dirty = false;
|
||||
var err_buf: [128]u8 = undefined;
|
||||
|
|
@ -570,7 +574,10 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
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);
|
||||
|
||||
try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close);
|
||||
// `cur_tf` (the selected timeframe) is computed once at the
|
||||
// top of this function; the window change reuses it.
|
||||
const period = fmt.windowChange(c, cur_tf.tradingDays(), price);
|
||||
try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close, period, cur_tf.label());
|
||||
|
||||
// Write detail lines into the buffer below the image
|
||||
const detail_buf_start = detail_start_row * @as(usize, width);
|
||||
|
|
@ -664,7 +671,7 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|||
{
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change (1D): {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
|
||||
}
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
|
@ -681,7 +688,11 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|||
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];
|
||||
|
||||
try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close);
|
||||
// The braille fallback charts the last 60 candles; the window
|
||||
// change measures against that same span.
|
||||
const period = fmt.windowChange(c, 60, price);
|
||||
|
||||
try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close, period, "60D");
|
||||
|
||||
// Braille sparkline chart of recent 60 trading days
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
|
@ -737,25 +748,35 @@ fn buildDetailColumns(
|
|||
quote_data: ?zfin.Quote,
|
||||
price: f64,
|
||||
prev_close: f64,
|
||||
period: ?fmt.PctChange,
|
||||
period_label: []const u8,
|
||||
) !void {
|
||||
const th = app.theme;
|
||||
var vol_buf: [32]u8 = undefined;
|
||||
|
||||
// Column 1: Price/OHLCV
|
||||
var col1 = Column.init();
|
||||
col1.width = 30;
|
||||
col1.width = 36;
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {f}", .{latest.date}), th.contentStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(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;
|
||||
if (fmt.pctChange(price, prev_close)) |dc| {
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style);
|
||||
const change_style = if (dc.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " {s:<14}{s}", .{ "Change (1D):", fmt.fmtPriceChange(&chg_buf, dc.change, dc.pct) }), change_style);
|
||||
}
|
||||
// Change over the displayed chart window (the chart's left edge ->
|
||||
// now), labeled with the window span so it reads distinctly from the
|
||||
// 1-day change above. Null when there's no meaningful window.
|
||||
if (period) |pc| {
|
||||
var wchg_buf: [64]u8 = undefined;
|
||||
var lbl_buf: [24]u8 = undefined;
|
||||
const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{period_label}) catch "Change:";
|
||||
const wstyle = if (pc.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " {s:<14}{s}", .{ lbl, fmt.fmtPriceChange(&wchg_buf, pc.change, pc.pct) }), wstyle);
|
||||
}
|
||||
|
||||
// Columns 2-4: ETF profile (only for actual ETFs)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue