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.
|
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
|
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
|
day-over-day change, followed by a price chart over a recent window
|
||||||
and a table of the last 20 trading days. Quotes come from Yahoo
|
(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
|
(TwelveData fallback) and are **never cached** -- so this command needs
|
||||||
network access and does nothing useful in `--refresh-data=never` mode.
|
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
|
price line otherwise. Force a mode with the global
|
||||||
[`--chart`](index.md) flag (`auto` / `braille` / `WxH`).
|
[`--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
|
Supports `--export-chart <PATH>` to render the chart as a 1920x1080
|
||||||
PNG instead of text (see [export charts](../../guides/offline-and-refresh.md)
|
PNG instead of text (see [export charts](../../guides/offline-and-refresh.md)
|
||||||
and the projections page).
|
and the projections page).
|
||||||
|
|
@ -26,6 +33,9 @@ and the projections page).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ZFIN_HOME=examples/pre-retirement-both zfin quote SPY
|
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
|
Volume: 80,875,657
|
||||||
Change: +$5.78 (+0.78%)
|
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
|
## See also
|
||||||
|
|
|
||||||
|
|
@ -38,24 +38,34 @@ const theme = @import("tui/theme.zig");
|
||||||
pub const default_width: u32 = 1920;
|
pub const default_width: u32 = 1920;
|
||||||
pub const default_height: u32 = 1080;
|
pub const default_height: u32 = 1080;
|
||||||
|
|
||||||
/// Export a price+Bollinger+RSI chart for a single symbol.
|
/// Export a price+Bollinger+RSI chart for a single symbol, showing the
|
||||||
/// Wraps `chart.renderToSurface` + `writeToPNGFile`.
|
/// 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(
|
pub fn exportSymbolChart(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
candles: []const zfin.Candle,
|
candles: []const zfin.Candle,
|
||||||
timeframe: chart.Timeframe,
|
display_count: usize,
|
||||||
path: []const u8,
|
path: []const u8,
|
||||||
) !void {
|
) !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(
|
var rendered = chart.renderToSurface(
|
||||||
io,
|
io,
|
||||||
alloc,
|
alloc,
|
||||||
candles,
|
display,
|
||||||
timeframe,
|
null,
|
||||||
default_width,
|
default_width,
|
||||||
default_height,
|
default_height,
|
||||||
theme.default_theme,
|
theme.default_theme,
|
||||||
null,
|
&cached,
|
||||||
true,
|
true,
|
||||||
) catch |err| switch (err) {
|
) catch |err| switch (err) {
|
||||||
error.InsufficientData => return error.InsufficientData,
|
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" });
|
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" });
|
||||||
defer alloc.free(path);
|
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
|
// Verify the file exists, starts with the PNG magic, and is
|
||||||
// big enough to plausibly contain a chart (not just headers).
|
// 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(
|
try std.testing.expectError(
|
||||||
error.InsufficientData,
|
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.
|
/// Chart timeframe selection.
|
||||||
pub const Timeframe = enum {
|
pub const Timeframe = enum {
|
||||||
|
@"3M",
|
||||||
@"6M",
|
@"6M",
|
||||||
ytd,
|
ytd,
|
||||||
@"1Y",
|
@"1Y",
|
||||||
|
|
@ -59,6 +60,7 @@ pub const Timeframe = enum {
|
||||||
|
|
||||||
pub fn label(self: Timeframe) []const u8 {
|
pub fn label(self: Timeframe) []const u8 {
|
||||||
return switch (self) {
|
return switch (self) {
|
||||||
|
.@"3M" => "3M",
|
||||||
.@"6M" => "6M",
|
.@"6M" => "6M",
|
||||||
.ytd => "YTD",
|
.ytd => "YTD",
|
||||||
.@"1Y" => "1Y",
|
.@"1Y" => "1Y",
|
||||||
|
|
@ -69,6 +71,7 @@ pub const Timeframe = enum {
|
||||||
|
|
||||||
pub fn tradingDays(self: Timeframe) usize {
|
pub fn tradingDays(self: Timeframe) usize {
|
||||||
return switch (self) {
|
return switch (self) {
|
||||||
|
.@"3M" => 63,
|
||||||
.@"6M" => 126,
|
.@"6M" => 126,
|
||||||
.ytd => 252, // approximation, we'll clamp
|
.ytd => 252, // approximation, we'll clamp
|
||||||
.@"1Y" => 252,
|
.@"1Y" => 252,
|
||||||
|
|
@ -79,17 +82,19 @@ pub const Timeframe = enum {
|
||||||
|
|
||||||
pub fn next(self: Timeframe) Timeframe {
|
pub fn next(self: Timeframe) Timeframe {
|
||||||
return switch (self) {
|
return switch (self) {
|
||||||
|
.@"3M" => .@"6M",
|
||||||
.@"6M" => .ytd,
|
.@"6M" => .ytd,
|
||||||
.ytd => .@"1Y",
|
.ytd => .@"1Y",
|
||||||
.@"1Y" => .@"3Y",
|
.@"1Y" => .@"3Y",
|
||||||
.@"3Y" => .@"5Y",
|
.@"3Y" => .@"5Y",
|
||||||
.@"5Y" => .@"6M",
|
.@"5Y" => .@"3M",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn prev(self: Timeframe) Timeframe {
|
pub fn prev(self: Timeframe) Timeframe {
|
||||||
return switch (self) {
|
return switch (self) {
|
||||||
.@"6M" => .@"5Y",
|
.@"3M" => .@"5Y",
|
||||||
|
.@"6M" => .@"3M",
|
||||||
.ytd => .@"6M",
|
.ytd => .@"6M",
|
||||||
.@"1Y" => .ytd,
|
.@"1Y" => .ytd,
|
||||||
.@"3Y" => .@"1Y",
|
.@"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.
|
/// Render a complete financial chart to raw RGB pixel data.
|
||||||
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
|
/// 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.
|
/// If `cached` is provided, uses pre-computed indicators instead of recomputing.
|
||||||
|
|
@ -214,7 +264,7 @@ pub fn renderToSurface(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
candles: []const zfin.Candle,
|
candles: []const zfin.Candle,
|
||||||
timeframe: Timeframe,
|
timeframe: ?Timeframe,
|
||||||
width_px: u32,
|
width_px: u32,
|
||||||
height_px: u32,
|
height_px: u32,
|
||||||
th: theme.Theme,
|
th: theme.Theme,
|
||||||
|
|
@ -223,9 +273,9 @@ pub fn renderToSurface(
|
||||||
) !RenderedChart {
|
) !RenderedChart {
|
||||||
if (candles.len < 20) return error.InsufficientData;
|
if (candles.len < 20) return error.InsufficientData;
|
||||||
|
|
||||||
// Slice candles to timeframe
|
// Render the timeframe's slice, or all of `candles` when the caller
|
||||||
const max_days = timeframe.tradingDays();
|
// already sliced (timeframe == null - the CLI's date-based path).
|
||||||
const n = @min(candles.len, max_days);
|
const n = if (timeframe) |tf| @min(candles.len, tf.tradingDays()) else candles.len;
|
||||||
const data = candles[candles.len - n ..];
|
const data = candles[candles.len - n ..];
|
||||||
|
|
||||||
// Use cached indicators or compute fresh ones
|
// Use cached indicators or compute fresh ones
|
||||||
|
|
@ -626,22 +676,38 @@ test "ChartConfig.parse" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "Timeframe next/prev cycle" {
|
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.ytd, Timeframe.@"6M".next());
|
||||||
try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.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
|
// 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());
|
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev());
|
||||||
}
|
}
|
||||||
|
|
||||||
test "Timeframe tradingDays" {
|
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, 126), Timeframe.@"6M".tradingDays());
|
||||||
try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays());
|
try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays());
|
||||||
try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".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 ─────────────────────────────────────────────
|
// ── renderToSurface tests ─────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// These exercise the actual chart rendering pipeline (z2d surface +
|
// 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")) {
|
if (std.ascii.eqlIgnoreCase(s, "live") or std.ascii.eqlIgnoreCase(s, "now")) {
|
||||||
return null;
|
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.
|
// Explicit YYYY-MM-DD.
|
||||||
if (s.len == 10 and s[4] == '-' and s[7] == '-') {
|
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.
|
/// caller is responsible for formatting the surrounding message.
|
||||||
pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 {
|
pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 {
|
||||||
return switch (err) {
|
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.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.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,
|
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);
|
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" {
|
test "parseAsOfDate: explicit YYYY-MM-DD" {
|
||||||
const today = zfin.Date.fromYmd(2026, 4, 2);
|
const today = zfin.Date.fromYmd(2026, 4, 2);
|
||||||
const r = try parseAsOfDate("2026-03-13", today);
|
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
|
/// z2d-rendered pixel buffers for the TUI; this flag just lands
|
||||||
/// the same pixels in a file via z2d's PNG exporter.
|
/// the same pixels in a file via z2d's PNG exporter.
|
||||||
export_chart: ?[]const u8 = null,
|
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 = .{
|
pub const meta: framework.Meta = .{
|
||||||
|
|
@ -25,18 +29,25 @@ pub const meta: framework.Meta = .{
|
||||||
.synopsis = "Show latest quote with chart and 20-day history",
|
.synopsis = "Show latest quote with chart and 20-day history",
|
||||||
.uppercase_first_arg = true,
|
.uppercase_first_arg = true,
|
||||||
.help =
|
.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)
|
\\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
|
\\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
|
\\If real-time fetch fails, falls back to the cached close. The
|
||||||
\\Yahoo path is free and unauthenticated; TwelveData requires
|
\\Yahoo path is free and unauthenticated; TwelveData requires
|
||||||
\\TWELVEDATA_API_KEY.
|
\\TWELVEDATA_API_KEY.
|
||||||
\\
|
\\
|
||||||
\\Options:
|
\\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
|
\\ --export-chart <PATH> Render the price+Bollinger+RSI chart
|
||||||
\\ to a PNG file at the given path
|
\\ to a PNG file at the given path
|
||||||
\\ (1920x1080) and exit. No text output
|
\\ (1920x1080) and exit. No text output
|
||||||
|
|
@ -46,10 +57,11 @@ pub const meta: framework.Meta = .{
|
||||||
\\Examples:
|
\\Examples:
|
||||||
\\ zfin quote AAPL
|
\\ zfin quote AAPL
|
||||||
\\ zfin quote spy # symbols are case-insensitive
|
\\ zfin quote spy # symbols are case-insensitive
|
||||||
|
\\ zfin quote AAPL --since 1Y
|
||||||
\\ zfin quote AAPL --export-chart aapl.png
|
\\ 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).
|
/// 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 {
|
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||||
var symbol: ?[]const u8 = null;
|
var symbol: ?[]const u8 = null;
|
||||||
var export_chart: ?[]const u8 = null;
|
var export_chart: ?[]const u8 = null;
|
||||||
|
var since: ?zfin.Date = null;
|
||||||
|
|
||||||
var i: usize = 0;
|
var i: usize = 0;
|
||||||
while (i < cmd_args.len) : (i += 1) {
|
while (i < cmd_args.len) : (i += 1) {
|
||||||
const a = cmd_args[i];
|
const a = cmd_args[i];
|
||||||
if (std.mem.eql(u8, a, "--export-chart")) {
|
if (std.mem.eql(u8, a, "--export-chart")) {
|
||||||
export_chart = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
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] == '-') {
|
} else if (a.len > 0 and a[0] == '-') {
|
||||||
// Reject ANY leading-dash token we don't recognize,
|
// Reject ANY leading-dash token we don't recognize,
|
||||||
// including single-dash ones like `-x`. Previously only
|
// 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");
|
cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
|
||||||
return error.MissingSymbol;
|
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 {
|
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();
|
defer candle_result.deinit();
|
||||||
const candles = candle_result.data;
|
const candles = candle_result.data;
|
||||||
|
|
||||||
// PNG export short-circuits all text rendering. Use the
|
// Chart window: candles on/after the `--since` date (default: the
|
||||||
// longest timeframe the candle history can support - falling
|
// last ~3 months). `display_count` is how many recent candles get
|
||||||
// back to shorter ones until one fits - so the user gets the
|
// drawn; the overlays warm up over extra lookback (see emitQuoteKitty).
|
||||||
// most chart context without having to think about it.
|
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| {
|
if (parsed.export_chart) |path| {
|
||||||
const tf: tui_chart.Timeframe = pickTimeframe(candles);
|
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, path) catch |err| switch (err) {
|
||||||
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) {
|
|
||||||
error.InsufficientData => {
|
error.InsufficientData => {
|
||||||
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
|
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
|
||||||
return;
|
return;
|
||||||
|
|
@ -183,7 +201,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||||
.kitty => .{ .kitty = k },
|
.kitty => .{ .kitty = k },
|
||||||
.auto => if (ctx.graphics_caps.kitty) .{ .kitty = k } else .braille,
|
.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
|
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
|
||||||
|
|
@ -237,66 +255,39 @@ const KittyChart = struct {
|
||||||
caps: term_query.Caps,
|
caps: term_query.Caps,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Pick the longest timeframe the candle history can fill, falling back
|
/// Braille price chart of the most recent `display_count` candles (the
|
||||||
/// to 6M. Shared by `--export-chart` and the inline kitty chart.
|
/// fallback path, used when kitty graphics aren't available).
|
||||||
fn pickTimeframe(candles: []const zfin.Candle) tui_chart.Timeframe {
|
fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle, display_count: usize) !void {
|
||||||
const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" };
|
const n = @min(candles.len, display_count);
|
||||||
for (candidates) |c| {
|
const data = candles[candles.len - n ..];
|
||||||
if (candles.len >= c.tradingDays()) return c;
|
var ch = fmt.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||||
}
|
|
||||||
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;
|
|
||||||
defer ch.deinit(allocator);
|
defer ch.deinit(allocator);
|
||||||
try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
|
try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the price+Bollinger+volume+RSI chart as kitty graphics at
|
/// Render the price+Bollinger+volume+RSI chart for the most recent
|
||||||
/// `term_graphics.quote_cols` wide and emit it inline. Displays the same
|
/// `display_count` candles as kitty graphics at `term_graphics.quote_cols`
|
||||||
/// last-60-candle window as the braille fallback, but computes the
|
/// wide and emit it inline. The overlays are computed with a warmup
|
||||||
/// Bollinger/RSI overlays over an extra `warmup` candles of lookback and
|
/// lookback (`chart.computeIndicatorsWarmup`) so they're valid from the
|
||||||
/// slices them to the window - so the bands are valid from the first
|
/// first displayed candle at any window size. Returns
|
||||||
/// *displayed* candle instead of warming up a third of the way in. The
|
/// `error.InsufficientData` when there's too little history (< 20 candles)
|
||||||
/// standalone `--export-chart` PNG still shows the longest history.
|
/// so the caller can fall back to braille.
|
||||||
/// Returns `error.InsufficientData` when there's too little history
|
fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []const zfin.Candle, display_count: usize, k: KittyChart) !void {
|
||||||
/// (< 20 candles) so the caller can fall back to braille.
|
var cached = try tui_chart.computeIndicatorsWarmup(allocator, candles, display_count, 20);
|
||||||
fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []const zfin.Candle, k: KittyChart) !void {
|
defer cached.deinit(allocator);
|
||||||
const display_n: usize = @min(candles.len, 60);
|
const n = @min(candles.len, display_count);
|
||||||
const warmup: usize = 20; // >= the BB(20) / RSI(14) warmup periods
|
const display_data = candles[candles.len - n ..];
|
||||||
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 ..];
|
|
||||||
const cols = term_graphics.quote_cols;
|
const cols = term_graphics.quote_cols;
|
||||||
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h);
|
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);
|
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);
|
defer rendered.deinit(allocator);
|
||||||
const rgb = try rendered.extractRgb(allocator);
|
const rgb = try rendered.extractRgb(allocator);
|
||||||
defer allocator.free(rgb);
|
defer allocator.free(rgb);
|
||||||
try term_graphics.placeInline(out, allocator, rgb, dims.width, dims.height, cols, rows);
|
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;
|
const has_quote = quote != null;
|
||||||
|
|
||||||
// Header. The security name (when resolved) renders between the
|
// 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: 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) {
|
if (candles.len >= 2) {
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
switch (chart_render) {
|
switch (chart_render) {
|
||||||
.braille => try renderBrailleCandles(allocator, out, color, candles),
|
.braille => try renderBrailleCandles(allocator, out, color, candles, display_count),
|
||||||
.kitty => |k| emitQuoteKitty(allocator, out, candles, k) catch |err| switch (err) {
|
.kitty => |k| emitQuoteKitty(allocator, out, candles, display_count, k) catch |err| switch (err) {
|
||||||
error.InsufficientData => try renderBrailleCandles(allocator, out, color, candles),
|
error.InsufficientData => try renderBrailleCandles(allocator, out, color, candles, display_count),
|
||||||
else => return err,
|
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));
|
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" {
|
test "display with candles only" {
|
||||||
var buf: [8192]u8 = undefined;
|
var buf: [8192]u8 = undefined;
|
||||||
var w: std.Io.Writer = .fixed(&buf);
|
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 = 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 },
|
.{ .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();
|
const out = w.buffered();
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != 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,
|
.prev_close = 172.00,
|
||||||
.date = .{ .days = 20001 },
|
.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();
|
const out = w.buffered();
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != 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{
|
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 },
|
.{ .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();
|
const out = w.buffered();
|
||||||
// Name appears between the symbol and the price.
|
// Name appears between the symbol and the price.
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null);
|
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{
|
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 },
|
.{ .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();
|
const out = w.buffered();
|
||||||
// No double-space orphan where the name would have gone.
|
// No double-space orphan where the name would have gone.
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null);
|
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{
|
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 },
|
.{ .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();
|
const out = w.buffered();
|
||||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue