add --since flag to quote
This commit is contained in:
parent
3f48a5b38c
commit
3e45393d93
5 changed files with 220 additions and 90 deletions
|
|
@ -4,12 +4,13 @@ Show the latest quote for a symbol, with a price chart and recent
|
|||
history.
|
||||
|
||||
```
|
||||
Usage: zfin quote <SYMBOL>
|
||||
Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>]
|
||||
```
|
||||
|
||||
Prints the last price, the day's open/high/low, volume, and the
|
||||
day-over-day change, followed by a price chart of the last 60 candles
|
||||
and a table of the last 20 trading days. Quotes come from Yahoo
|
||||
day-over-day change, followed by a price chart over a recent window
|
||||
(the last ~3 months by default) and a table of the last 20 trading
|
||||
days. Quotes come from Yahoo
|
||||
(TwelveData fallback) and are **never cached** -- so this command needs
|
||||
network access and does nothing useful in `--refresh-data=never` mode.
|
||||
|
||||
|
|
@ -18,6 +19,12 @@ volume + RSI) when your terminal supports it, falling back to a braille
|
|||
price line otherwise. Force a mode with the global
|
||||
[`--chart`](index.md) flag (`auto` / `braille` / `WxH`).
|
||||
|
||||
Use `--since <WHEN>` to change how far back the chart reaches. `WHEN`
|
||||
accepts an absolute `YYYY-MM-DD`, a relative shortcut (`1W`, `1M`,
|
||||
`1Q`, `1Y`), or `ytd`. It governs both the inline chart and the
|
||||
`--export-chart` PNG; the 20-day history table is always the last 20
|
||||
trading days regardless.
|
||||
|
||||
Supports `--export-chart <PATH>` to render the chart as a 1920x1080
|
||||
PNG instead of text (see [export charts](../../guides/offline-and-refresh.md)
|
||||
and the projections page).
|
||||
|
|
@ -26,6 +33,9 @@ and the projections page).
|
|||
|
||||
```bash
|
||||
ZFIN_HOME=examples/pre-retirement-both zfin quote SPY
|
||||
|
||||
# A one-year chart window instead of the default ~3 months:
|
||||
ZFIN_HOME=examples/pre-retirement-both zfin quote SPY --since 1Y
|
||||
```
|
||||
|
||||
```
|
||||
|
|
@ -38,7 +48,7 @@ SPY $746.74 (close)
|
|||
Volume: 80,875,657
|
||||
Change: +$5.78 (+0.78%)
|
||||
|
||||
... (60-candle price chart -- inline Kitty image, or braille)
|
||||
... (price chart over the selected window -- inline Kitty image, or braille)
|
||||
```
|
||||
|
||||
## See also
|
||||
|
|
|
|||
|
|
@ -38,24 +38,34 @@ const theme = @import("tui/theme.zig");
|
|||
pub const default_width: u32 = 1920;
|
||||
pub const default_height: u32 = 1080;
|
||||
|
||||
/// Export a price+Bollinger+RSI chart for a single symbol.
|
||||
/// Wraps `chart.renderToSurface` + `writeToPNGFile`.
|
||||
/// Export a price+Bollinger+RSI chart for a single symbol, showing the
|
||||
/// most recent `display_count` candles. The overlays are computed with a
|
||||
/// warmup lookback (via `chart.computeIndicatorsWarmup`) so they're valid
|
||||
/// from the first displayed candle. Wraps `renderToSurface` + `writeToPNGFile`.
|
||||
pub fn exportSymbolChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
timeframe: chart.Timeframe,
|
||||
display_count: usize,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
};
|
||||
defer cached.deinit(alloc);
|
||||
const n = @min(candles.len, display_count);
|
||||
const display = candles[candles.len - n ..];
|
||||
|
||||
var rendered = chart.renderToSurface(
|
||||
io,
|
||||
alloc,
|
||||
candles,
|
||||
timeframe,
|
||||
display,
|
||||
null,
|
||||
default_width,
|
||||
default_height,
|
||||
theme.default_theme,
|
||||
null,
|
||||
&cached,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
|
|
@ -151,7 +161,7 @@ test "exportSymbolChart writes a non-empty PNG file" {
|
|||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportSymbolChart(io, alloc, &candles, .@"6M", path);
|
||||
try exportSymbolChart(io, alloc, &candles, 60, path);
|
||||
|
||||
// Verify the file exists, starts with the PNG magic, and is
|
||||
// big enough to plausibly contain a chart (not just headers).
|
||||
|
|
@ -194,7 +204,7 @@ test "exportSymbolChart returns InsufficientData on too-few candles" {
|
|||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportSymbolChart(io, alloc, &candles, .@"6M", path),
|
||||
exportSymbolChart(io, alloc, &candles, 60, path),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const Pixel = z2d.Pixel;
|
|||
|
||||
/// Chart timeframe selection.
|
||||
pub const Timeframe = enum {
|
||||
@"3M",
|
||||
@"6M",
|
||||
ytd,
|
||||
@"1Y",
|
||||
|
|
@ -59,6 +60,7 @@ pub const Timeframe = enum {
|
|||
|
||||
pub fn label(self: Timeframe) []const u8 {
|
||||
return switch (self) {
|
||||
.@"3M" => "3M",
|
||||
.@"6M" => "6M",
|
||||
.ytd => "YTD",
|
||||
.@"1Y" => "1Y",
|
||||
|
|
@ -69,6 +71,7 @@ pub const Timeframe = enum {
|
|||
|
||||
pub fn tradingDays(self: Timeframe) usize {
|
||||
return switch (self) {
|
||||
.@"3M" => 63,
|
||||
.@"6M" => 126,
|
||||
.ytd => 252, // approximation, we'll clamp
|
||||
.@"1Y" => 252,
|
||||
|
|
@ -79,17 +82,19 @@ pub const Timeframe = enum {
|
|||
|
||||
pub fn next(self: Timeframe) Timeframe {
|
||||
return switch (self) {
|
||||
.@"3M" => .@"6M",
|
||||
.@"6M" => .ytd,
|
||||
.ytd => .@"1Y",
|
||||
.@"1Y" => .@"3Y",
|
||||
.@"3Y" => .@"5Y",
|
||||
.@"5Y" => .@"6M",
|
||||
.@"5Y" => .@"3M",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn prev(self: Timeframe) Timeframe {
|
||||
return switch (self) {
|
||||
.@"6M" => .@"5Y",
|
||||
.@"3M" => .@"5Y",
|
||||
.@"6M" => .@"3M",
|
||||
.ytd => .@"6M",
|
||||
.@"1Y" => .ytd,
|
||||
.@"3Y" => .@"1Y",
|
||||
|
|
@ -175,6 +180,51 @@ pub fn computeIndicators(
|
|||
};
|
||||
}
|
||||
|
||||
/// Like `computeIndicators`, but for a fixed *display count* of the most
|
||||
/// recent candles, computing the Bollinger/RSI series over an extra
|
||||
/// `warmup` candles of lookback so the overlays are valid from the first
|
||||
/// displayed candle (no warm-up gap). Returns owned arrays of length
|
||||
/// `min(candles.len, display_count)`, aligned with the last that many
|
||||
/// candles. Pair with `renderToSurface(candles[len-n..], null, ...)`.
|
||||
pub fn computeIndicatorsWarmup(
|
||||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
display_count: usize,
|
||||
warmup: usize,
|
||||
) !CachedIndicators {
|
||||
if (candles.len < 20) return error.InsufficientData;
|
||||
const n = @min(candles.len, display_count);
|
||||
const m = @min(candles.len, n + warmup);
|
||||
const window = candles[candles.len - m ..];
|
||||
|
||||
const closes_w = try zfin.indicators.closePrices(alloc, window);
|
||||
defer alloc.free(closes_w);
|
||||
const vols_w = try zfin.indicators.volumes(alloc, window);
|
||||
defer alloc.free(vols_w);
|
||||
const bb_w = try zfin.indicators.bollingerBands(alloc, closes_w, 20, 2.0);
|
||||
defer alloc.free(bb_w);
|
||||
const rsi_w = try zfin.indicators.rsi(alloc, closes_w, 14);
|
||||
defer alloc.free(rsi_w);
|
||||
|
||||
// Keep only the last `n` of each (the displayed candles); the leading
|
||||
// `off` were lookback for the overlays. Dupe into clean owned arrays
|
||||
// so the result has a normal `deinit` (no views into freed buffers).
|
||||
const off = m - n;
|
||||
const closes = try alloc.dupe(f64, closes_w[off..]);
|
||||
errdefer alloc.free(closes);
|
||||
const vols = try alloc.dupe(f64, vols_w[off..]);
|
||||
errdefer alloc.free(vols);
|
||||
const bb = try alloc.dupe(?zfin.indicators.BollingerBand, bb_w[off..]);
|
||||
errdefer alloc.free(bb);
|
||||
const rsi_vals = try alloc.dupe(?f64, rsi_w[off..]);
|
||||
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.
|
||||
|
|
@ -214,7 +264,7 @@ pub fn renderToSurface(
|
|||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
timeframe: Timeframe,
|
||||
timeframe: ?Timeframe,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
|
|
@ -223,9 +273,9 @@ pub fn renderToSurface(
|
|||
) !RenderedChart {
|
||||
if (candles.len < 20) return error.InsufficientData;
|
||||
|
||||
// Slice candles to timeframe
|
||||
const max_days = timeframe.tradingDays();
|
||||
const n = @min(candles.len, max_days);
|
||||
// Render the timeframe's slice, or all of `candles` when the caller
|
||||
// already sliced (timeframe == null - the CLI's date-based path).
|
||||
const n = if (timeframe) |tf| @min(candles.len, tf.tradingDays()) else candles.len;
|
||||
const data = candles[candles.len - n ..];
|
||||
|
||||
// Use cached indicators or compute fresh ones
|
||||
|
|
@ -626,22 +676,38 @@ test "ChartConfig.parse" {
|
|||
}
|
||||
|
||||
test "Timeframe next/prev cycle" {
|
||||
// next cycles through all values
|
||||
// next cycles: 3M -> 6M -> ytd -> 1Y -> 3Y -> 5Y -> 3M
|
||||
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"3M".next());
|
||||
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
|
||||
try std.testing.expectEqual(Timeframe.@"3M", Timeframe.@"5Y".next()); // wraps
|
||||
|
||||
// prev is the reverse
|
||||
try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"6M".prev()); // wraps
|
||||
try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"3M".prev()); // wraps
|
||||
try std.testing.expectEqual(Timeframe.@"3M", Timeframe.@"6M".prev());
|
||||
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev());
|
||||
}
|
||||
|
||||
test "Timeframe tradingDays" {
|
||||
try std.testing.expectEqual(@as(usize, 63), Timeframe.@"3M".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());
|
||||
}
|
||||
|
||||
test "computeIndicatorsWarmup: overlays valid from the first displayed candle" {
|
||||
var candles: [80]zfin.Candle = undefined;
|
||||
buildLinearCandles(&candles, 100.0);
|
||||
var cached = try computeIndicatorsWarmup(test_alloc, &candles, 60, 20);
|
||||
defer cached.deinit(test_alloc);
|
||||
// Displays the last 60; the 20-candle lookback means the first
|
||||
// displayed point already carries a Bollinger band and an RSI value.
|
||||
try std.testing.expectEqual(@as(usize, 60), cached.closes.len);
|
||||
try std.testing.expectEqual(@as(usize, 60), cached.bb.len);
|
||||
try std.testing.expect(cached.bb[0] != null);
|
||||
try std.testing.expect(cached.rsi_vals[0] != null);
|
||||
}
|
||||
|
||||
// ── renderToSurface tests ─────────────────────────────────────────────
|
||||
//
|
||||
// These exercise the actual chart rendering pipeline (z2d surface +
|
||||
|
|
|
|||
|
|
@ -443,6 +443,10 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D
|
|||
if (std.ascii.eqlIgnoreCase(s, "live") or std.ascii.eqlIgnoreCase(s, "now")) {
|
||||
return null;
|
||||
}
|
||||
// Year-to-date: Jan 1 of the reference year.
|
||||
if (std.ascii.eqlIgnoreCase(s, "ytd")) {
|
||||
return zfin.Date.fromYmd(as_of.year(), 1, 1);
|
||||
}
|
||||
|
||||
// Explicit YYYY-MM-DD.
|
||||
if (s.len == 10 and s[4] == '-' and s[7] == '-') {
|
||||
|
|
@ -477,7 +481,7 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D
|
|||
/// caller is responsible for formatting the surrounding message.
|
||||
pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 {
|
||||
return switch (err) {
|
||||
error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), or 'live'.", .{input}) catch input,
|
||||
error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), 'ytd', or 'live'.", .{input}) catch input,
|
||||
error.EmptyUnit => std.fmt.bufPrint(buf, "As-of value {s} is missing a unit. Expected one of W, M, Q, Y.", .{input}) catch input,
|
||||
error.UnknownUnit => std.fmt.bufPrint(buf, "As-of value {s} has an unknown unit. Expected one of W (weeks), M (months), Q (quarters), Y (years).", .{input}) catch input,
|
||||
error.ZeroQuantity => std.fmt.bufPrint(buf, "As-of quantity must be at least 1 (got {s}).", .{input}) catch input,
|
||||
|
|
@ -1032,6 +1036,12 @@ test "parseAsOfDate: literal 'live' and 'now' (case-insensitive)" {
|
|||
try std.testing.expect((try parseAsOfDate("Now", today)) == null);
|
||||
}
|
||||
|
||||
test "parseAsOfDate: 'ytd' is Jan 1 of the reference year (case-insensitive)" {
|
||||
const today = zfin.Date.fromYmd(2026, 4, 2);
|
||||
try std.testing.expect((try parseAsOfDate("ytd", today)).?.eql(zfin.Date.fromYmd(2026, 1, 1)));
|
||||
try std.testing.expect((try parseAsOfDate("YTD", today)).?.eql(zfin.Date.fromYmd(2026, 1, 1)));
|
||||
}
|
||||
|
||||
test "parseAsOfDate: explicit YYYY-MM-DD" {
|
||||
const today = zfin.Date.fromYmd(2026, 4, 2);
|
||||
const r = try parseAsOfDate("2026-03-13", today);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ pub const ParsedArgs = struct {
|
|||
/// z2d-rendered pixel buffers for the TUI; this flag just lands
|
||||
/// the same pixels in a file via z2d's PNG exporter.
|
||||
export_chart: ?[]const u8 = null,
|
||||
/// Chart start date from `--since` (resolved at parse time). Null
|
||||
/// 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,
|
||||
};
|
||||
|
||||
pub const meta: framework.Meta = .{
|
||||
|
|
@ -25,18 +29,25 @@ pub const meta: framework.Meta = .{
|
|||
.synopsis = "Show latest quote with chart and 20-day history",
|
||||
.uppercase_first_arg = true,
|
||||
.help =
|
||||
\\Usage: zfin quote <SYMBOL> [--export-chart <PATH>]
|
||||
\\Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>]
|
||||
\\
|
||||
\\Show the latest real-time quote for a symbol (Yahoo / TwelveData)
|
||||
\\plus a price chart of the last 60 candles (an inline Kitty image
|
||||
\\plus a price chart over a recent window (an inline Kitty image
|
||||
\\when the terminal supports it, braille otherwise) and a table
|
||||
\\of the last 20 trading days.
|
||||
\\of the last 20 trading days. The chart spans the last ~3 months
|
||||
\\by default; use --since to widen or narrow it.
|
||||
\\
|
||||
\\If real-time fetch fails, falls back to the cached close. The
|
||||
\\Yahoo path is free and unauthenticated; TwelveData requires
|
||||
\\TWELVEDATA_API_KEY.
|
||||
\\
|
||||
\\Options:
|
||||
\\ --since <WHEN> Start the price chart at WHEN instead of
|
||||
\\ the default last ~3 months. Applies to
|
||||
\\ both the inline chart and --export-chart.
|
||||
\\ Accepts YYYY-MM-DD, a relative shortcut
|
||||
\\ (1W/1M/1Q/1Y), or 'ytd'. The 20-day
|
||||
\\ history table is unaffected.
|
||||
\\ --export-chart <PATH> Render the price+Bollinger+RSI chart
|
||||
\\ to a PNG file at the given path
|
||||
\\ (1920x1080) and exit. No text output
|
||||
|
|
@ -46,10 +57,11 @@ pub const meta: framework.Meta = .{
|
|||
\\Examples:
|
||||
\\ zfin quote AAPL
|
||||
\\ zfin quote spy # symbols are case-insensitive
|
||||
\\ zfin quote AAPL --since 1Y
|
||||
\\ zfin quote AAPL --export-chart aapl.png
|
||||
\\
|
||||
,
|
||||
.user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue },
|
||||
.user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue, InvalidDate },
|
||||
};
|
||||
|
||||
/// Quote data extracted from the real-time API (or synthesized from candles).
|
||||
|
|
@ -66,12 +78,16 @@ pub const QuoteData = struct {
|
|||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
var symbol: ?[]const u8 = null;
|
||||
var export_chart: ?[]const u8 = null;
|
||||
var since: ?zfin.Date = null;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < cmd_args.len) : (i += 1) {
|
||||
const a = cmd_args[i];
|
||||
if (std.mem.eql(u8, a, "--export-chart")) {
|
||||
export_chart = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
} 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;
|
||||
} 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
|
||||
|
|
@ -94,7 +110,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 };
|
||||
return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since };
|
||||
}
|
||||
|
||||
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||
|
|
@ -114,13 +130,15 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer candle_result.deinit();
|
||||
const candles = candle_result.data;
|
||||
|
||||
// PNG export short-circuits all text rendering. Use the
|
||||
// longest timeframe the candle history can support - falling
|
||||
// back to shorter ones until one fits - so the user gets the
|
||||
// most chart context without having to think about it.
|
||||
// Chart window: candles on/after the `--since` date (default: the
|
||||
// last ~3 months). `display_count` is how many recent candles get
|
||||
// drawn; the overlays warm up over extra lookback (see emitQuoteKitty).
|
||||
const since_date = parsed.since orelse ctx.today.subtractMonths(3);
|
||||
const display_count = fmt.filterCandlesFrom(candles, since_date).len;
|
||||
|
||||
// PNG export short-circuits all text rendering.
|
||||
if (parsed.export_chart) |path| {
|
||||
const tf: tui_chart.Timeframe = pickTimeframe(candles);
|
||||
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) {
|
||||
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
|
||||
return;
|
||||
|
|
@ -183,7 +201,7 @@ 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, chart_render);
|
||||
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out, display_count, chart_render);
|
||||
}
|
||||
|
||||
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
|
||||
|
|
@ -237,66 +255,39 @@ const KittyChart = struct {
|
|||
caps: term_query.Caps,
|
||||
};
|
||||
|
||||
/// Pick the longest timeframe the candle history can fill, falling back
|
||||
/// to 6M. Shared by `--export-chart` and the inline kitty chart.
|
||||
fn pickTimeframe(candles: []const zfin.Candle) tui_chart.Timeframe {
|
||||
const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" };
|
||||
for (candidates) |c| {
|
||||
if (candles.len >= c.tradingDays()) return c;
|
||||
}
|
||||
return .@"6M";
|
||||
}
|
||||
|
||||
/// Braille price chart of the last 60 candles (the fallback path).
|
||||
fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle) !void {
|
||||
const chart_days: usize = @min(candles.len, 60);
|
||||
const chart_data = candles[candles.len - chart_days ..];
|
||||
var ch = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||
/// Braille price chart of the most recent `display_count` candles (the
|
||||
/// fallback path, used when kitty graphics aren't available).
|
||||
fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle, display_count: usize) !void {
|
||||
const n = @min(candles.len, display_count);
|
||||
const data = candles[candles.len - n ..];
|
||||
var ch = fmt.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||
defer ch.deinit(allocator);
|
||||
try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
|
||||
}
|
||||
|
||||
/// Render the price+Bollinger+volume+RSI chart as kitty graphics at
|
||||
/// `term_graphics.quote_cols` wide and emit it inline. Displays the same
|
||||
/// last-60-candle window as the braille fallback, but computes the
|
||||
/// Bollinger/RSI overlays over an extra `warmup` candles of lookback and
|
||||
/// slices them to the window - so the bands are valid from the first
|
||||
/// *displayed* candle instead of warming up a third of the way in. The
|
||||
/// standalone `--export-chart` PNG still shows the longest history.
|
||||
/// Returns `error.InsufficientData` when there's too little history
|
||||
/// (< 20 candles) so the caller can fall back to braille.
|
||||
fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []const zfin.Candle, k: KittyChart) !void {
|
||||
const display_n: usize = @min(candles.len, 60);
|
||||
const warmup: usize = 20; // >= the BB(20) / RSI(14) warmup periods
|
||||
const window_n: usize = @min(candles.len, display_n + warmup);
|
||||
const window = candles[candles.len - window_n ..];
|
||||
|
||||
// Compute indicators over the (display + warmup) window, then view
|
||||
// the last `display_n` of each so they align with the displayed
|
||||
// candles and carry no warmup gap. `full` owns the backing arrays;
|
||||
// `cached` is a non-owning slice into them.
|
||||
var full = try tui_chart.computeIndicators(allocator, window, .@"6M");
|
||||
defer full.deinit(allocator);
|
||||
const off = window_n - display_n;
|
||||
const cached: tui_chart.CachedIndicators = .{
|
||||
.closes = full.closes[off..],
|
||||
.volumes = full.volumes[off..],
|
||||
.bb = full.bb[off..],
|
||||
.rsi_vals = full.rsi_vals[off..],
|
||||
};
|
||||
|
||||
const display_data = candles[candles.len - display_n ..];
|
||||
/// Render the price+Bollinger+volume+RSI chart for the most recent
|
||||
/// `display_count` candles as kitty graphics at `term_graphics.quote_cols`
|
||||
/// wide and emit it inline. The overlays are computed with a warmup
|
||||
/// lookback (`chart.computeIndicatorsWarmup`) so they're valid from the
|
||||
/// first displayed candle at any window size. Returns
|
||||
/// `error.InsufficientData` when there's too little history (< 20 candles)
|
||||
/// so the caller can fall back to braille.
|
||||
fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []const zfin.Candle, display_count: usize, k: KittyChart) !void {
|
||||
var cached = try tui_chart.computeIndicatorsWarmup(allocator, candles, display_count, 20);
|
||||
defer cached.deinit(allocator);
|
||||
const n = @min(candles.len, display_count);
|
||||
const display_data = candles[candles.len - n ..];
|
||||
const cols = term_graphics.quote_cols;
|
||||
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h);
|
||||
const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h);
|
||||
var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, .@"6M", dims.width, dims.height, theme.default_theme, &cached, true);
|
||||
var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, null, dims.width, dims.height, theme.default_theme, &cached, true);
|
||||
defer rendered.deinit(allocator);
|
||||
const rgb = try rendered.extractRgb(allocator);
|
||||
defer allocator.free(rgb);
|
||||
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, 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, chart_render: ChartRender) !void {
|
||||
const has_quote = quote != null;
|
||||
|
||||
// Header. The security name (when resolved) renders between the
|
||||
|
|
@ -343,13 +334,13 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
|
|||
}
|
||||
|
||||
// Chart: inline kitty graphics when supported, else a braille price
|
||||
// chart of the last 60 candles.
|
||||
// chart over the selected window (`display_count` recent candles).
|
||||
if (candles.len >= 2) {
|
||||
try out.print("\n", .{});
|
||||
switch (chart_render) {
|
||||
.braille => try renderBrailleCandles(allocator, out, color, candles),
|
||||
.kitty => |k| emitQuoteKitty(allocator, out, candles, k) catch |err| switch (err) {
|
||||
error.InsufficientData => try renderBrailleCandles(allocator, out, color, candles),
|
||||
.braille => try renderBrailleCandles(allocator, out, color, candles, display_count),
|
||||
.kitty => |k| emitQuoteKitty(allocator, out, candles, display_count, k) catch |err| switch (err) {
|
||||
error.InsufficientData => try renderBrailleCandles(allocator, out, color, candles, display_count),
|
||||
else => return err,
|
||||
},
|
||||
}
|
||||
|
|
@ -429,6 +420,49 @@ test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
|
|||
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --since accepts an explicit YYYY-MM-DD" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
|
||||
const args = [_][]const u8{ "AAPL", "--since", "2025-01-15" };
|
||||
const parsed = try parseArgs(&ctx, &args);
|
||||
try std.testing.expect(parsed.since.?.eql(zfin.Date.fromYmd(2025, 1, 15)));
|
||||
}
|
||||
|
||||
test "parseArgs: --since accepts a relative shortcut" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
|
||||
const args = [_][]const u8{ "AAPL", "--since", "1Y" };
|
||||
const parsed = try parseArgs(&ctx, &args);
|
||||
// 1Y back from 2026-05-08 is 2025-05-08 (calendar-year subtraction).
|
||||
try std.testing.expect(parsed.since.?.eql(zfin.Date.fromYmd(2025, 5, 8)));
|
||||
}
|
||||
|
||||
test "parseArgs: --since defaults to null when omitted" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
const args = [_][]const u8{"AAPL"};
|
||||
const parsed = try parseArgs(&ctx, &args);
|
||||
try std.testing.expect(parsed.since == null);
|
||||
}
|
||||
|
||||
test "parseArgs: --since without a value is rejected" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
|
||||
const args = [_][]const u8{ "AAPL", "--since" };
|
||||
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --since with an invalid value is rejected" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
|
||||
const args = [_][]const u8{ "AAPL", "--since", "garbage" };
|
||||
try std.testing.expectError(error.InvalidDate, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "display with candles only" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -436,7 +470,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, .braille);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .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);
|
||||
|
|
@ -457,7 +491,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, .braille);
|
||||
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .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);
|
||||
|
|
@ -471,7 +505,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, .braille);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .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);
|
||||
|
|
@ -483,7 +517,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, .braille);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .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);
|
||||
|
|
@ -495,7 +529,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, .braille);
|
||||
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, .braille);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue