Compare commits

...

16 commits

Author SHA1 Message Date
1d89b68da4
tui has 9 tabs
All checks were successful
Generic zig build / build (push) Successful in 5m56s
Generic zig build / deploy (push) Successful in 19s
Generic zig build / publish-macos (push) Successful in 56s
2026-06-26 10:25:17 -07:00
0e43e09f13
document "R" will upgrade tui from quote->portfolio operations 2026-06-26 10:25:16 -07:00
ce5d787083
disable portfolio tab in symbol mode 2026-06-26 10:25:16 -07:00
128085eefc
add name when loading quote without a portfolio 2026-06-26 10:25:15 -07:00
8e0288d437
add the period change to quote display on cli/tui 2026-06-26 10:25:14 -07:00
3e45393d93
add --since flag to quote 2026-06-26 10:25:13 -07:00
3f48a5b38c
update docs for chart changes 2026-06-26 10:25:12 -07:00
25f074f622
add kitty chart for quote command 2026-06-26 10:25:12 -07:00
dd550a85d9
add ADDITIONAL kitty-based projections chart to the top of the projections command if terminal supports 2026-06-26 10:25:10 -07:00
692a28aed7
add placeInline for term_graphics, to be used for kitty charts 2026-06-26 10:25:09 -07:00
78076b5319
wire kitty chart into history cli command 2026-06-26 10:25:08 -07:00
6d47b46a5a
add kitty cli graphics infrastructure 2026-06-26 10:25:08 -07:00
889cfc215a
move y-axis labels to the right 2026-06-26 10:25:07 -07:00
6170dc1ac1
chart refactor + history gains --export-chart 2026-06-26 10:25:06 -07:00
e6ec5fdac1
centralize charting/extract shared helpers, prepare for kitty-chart history 2026-06-26 10:25:05 -07:00
5a2b29fdd4
show better timing on refresh 2026-06-26 10:11:49 -07:00
32 changed files with 2965 additions and 652 deletions

56
TODO.md
View file

@ -81,28 +81,10 @@ ranking; unlabeled items are "someday, if the mood strikes."
## `--export-chart` follow-ups - priority LOW
V1 of `--export-chart <PATH>` shipped for `quote` and `projections`
(default bands mode only). Several adjacent surfaces still don't
have PNG export and were deferred:
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
(default bands mode only), and `history`. Several adjacent surfaces
still don't have PNG export and were deferred:
- **`history --export-chart`.** The `history` command renders a
single-series braille chart of portfolio value over time
(synthesized into `Candle` records and fed to
`format.computeBrailleChart`). It doesn't share the z2d
pipeline that `quote` (`tui/chart.zig`) and `projections`
(`tui/projection_chart.zig`) use. To export, options:
- **A.** Pipe the synthesized candles through
`tui/chart.zig`'s `renderChart` - but that draws Bollinger
Bands and an RSI panel, both meaningless on a portfolio-
value series.
- **B.** Add a minimal "single-series line chart" z2d
renderer (a slimmed-down `projection_chart.zig` without
bands). ~150 lines. Same renderToSurface shape so PNG
export is trivial after.
- **C.** Skip it permanently; the braille chart is fine for
what `history` is. Document as "not exportable".
B is the right answer if PNG export of the history chart is
ever requested.
- **`projections --convergence` / `--return-backtest`.** Both
render forecast-evaluation charts via `tui/forecast_chart.zig`.
Not refactored to expose a `renderToSurface` seam yet -
@ -260,39 +242,9 @@ Implementation notes:
(`RateLimiter.perHour`, wired into the provider). A batched quote
call is 1 request, but heavy `r` use plus candle refreshes draw from
the same hourly budget, so watch for contention.
- Tiingo websocket streaming would be the natural follow-on for true
- Tiingo websocket streaming would be the natural follow-on for true
push-based real-time, replacing poll-on-`r` entirely.
## Precise "as of <clock time>" via a datetime/timezone lib (zeit) - priority LOW
The portfolio tab's live-price footer is deliberately vague static
text: "(as of intraday quote today)" after a live refresh, falling
back to "(as of close on YYYY-MM-DD)" otherwise. We can't do better
today because the codebase has no wall-clock-to-local-time machinery -
`Date` is days-only, and every time display is either a date or a
relative "X ago" (`fmt.fmtTimeAgo`). There's no way to render an
absolute local clock time like "2:34 PM ET".
Pulling in a datetime/timezone library (e.g. [zeit](https://github.com/rockorager/zeit),
already by the libvaxis author) would let us:
- Show a precise, honest stamp: "(as of 2:34 PM ET)" / "refreshed
2:34 PM" instead of "today" / "Xs ago".
- Fix the current label's weekend/after-hours imprecision. Right now a
refresh when the market is closed flips the footer to "intraday quote
today" even though Yahoo returned the last close (which on a Saturday
is Friday's). With real clock + market-calendar awareness, the label
could say "as of Fri close" or "(market closed, last quote Fri 4:00
PM ET)" instead of implying live intraday data.
- Replace `last_refresh_s` "refreshed Xs ago" in the TUI status bar
(and the quote/earnings/options "data Xs ago" readouts) with absolute
times where that reads better.
Scope is a judgment call: a new dependency for what's currently a
cosmetic label. Worth it once we want trustworthy timestamps (e.g. for
screenshots, or to stop conflating "live" with "last close"); not
before.
## Analysis: dividend equity / income-shaped equity - think about it
Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.)

View file

@ -83,8 +83,9 @@ there.)
## 3. Review the timeline with `history`
Run [`zfin history`](../reference/cli/history.md) with no symbol for the
portfolio-value timeline: rolling-window changes, a braille chart, and
a recent-snapshots table.
portfolio-value timeline: rolling-window changes, a chart (an inline
Kitty image when your terminal supports it, else braille), and a
recent-snapshots table.
```bash
ZFIN_HOME=examples/post-retirement zfin history

View file

@ -28,9 +28,13 @@ Price History for VTI (last 30 days)
## Portfolio mode
With no symbol, reads your `history/*-portfolio.srf` snapshots and
renders rolling-window returns, a braille chart, and a recent-snapshots
renders rolling-window returns, a chart, and a recent-snapshots
table. (Build that history with [`snapshot`](snapshot.md).)
The chart renders as an inline Kitty image when your terminal supports
it, falling back to braille otherwise. Force a mode with the global
[`--chart`](index.md) flag (`auto` / `braille` / `WxH`).
| Flag | Effect |
|-----------------------|-------------------------------------------------|
| `--since <DATE>` | Earliest as-of date (inclusive). |

View file

@ -73,6 +73,7 @@ subcommand:
| `--refresh-data=<auto\|force\|never>` | Cache freshness policy. `auto` (default) respects TTLs; `force` re-fetches everything; `never` is offline. See [offline guide](../../guides/offline-and-refresh.md). |
| `-p, --portfolio <PATTERN>` | Portfolio file or glob (repeatable; default `portfolio*.srf`). Resolved against `ZFIN_HOME` when set, else the current directory. Quote globs to prevent shell expansion. |
| `-w, --watchlist <FILE>` | Watchlist file (default `watchlist.srf`). |
| `--chart <auto\|braille\|WxH>` | Inline chart graphics for `history` / `quote` / `projections`. `auto` (default) renders a Kitty image when the terminal supports it, else braille; `WxH` forces Kitty at that resolution. |
```bash
zfin --no-color --refresh-data=never -p 'portfolio_*.srf' analysis

View file

@ -4,14 +4,27 @@ 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 20-day chart. 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.
The inline chart renders as a Kitty image (price + Bollinger bands +
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).
@ -20,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
```
```
@ -30,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%)
... (20-day braille chart)
... (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.

View file

@ -29,6 +29,12 @@ currently-selected symbol.
The symbol-scoped tabs follow a single "current symbol"; change it from
the Portfolio tab (select a holding) or by launching with `-s`.
When you launch on a symbol (`zfin i -s AAPL`), no portfolio is loaded,
so the portfolio-scoped tabs (Portfolio, Analysis, Review, Projections,
History) are greyed out and unselectable - you start on the Quote tab.
Press `R` to load your portfolio on demand: this "upgrades" the session
into the full portfolio view and enables those tabs.
## Charts
Tabs with charts render high-fidelity **Kitty graphics** when your
@ -46,9 +52,10 @@ current bindings). The global defaults:
|----------------------------------------------|--------------------------------|
| `q`, `Ctrl-C` | Quit |
| `r`, `F5` | Refresh the current tab's data |
| `R` | Reload the portfolio from disk |
| `l` / `right` / `tab` | Next tab |
| `h` / `left` / `shift+tab` | Previous tab |
| `1`-`8` | Jump to a tab by number |
| `1`-`9` | Jump to a tab by number |
| `j` / `down`, `k` / `up` | Move the selection |
| `g` / `G` | Jump to top / bottom |
| `Ctrl-d` / `Ctrl-u` | Half-page down / up |

View file

@ -5,6 +5,19 @@
//! and use it as the type directly - no `.Date` field
//! extraction.
//!
//! ## Calendar engine
//!
//! `zeit.Days` is `i32` days-since-epoch - the exact same
//! representation as this type's `days` field - so the civil
//! conversions (`epochDaysToYmd` / `ymdToEpochDays`), leap-year
//! test, day-of-week, and month tables all delegate to zeit
//! rather than carrying a parallel hand-rolled implementation.
//! The compact `i32` representation, the SRF hooks, and the
//! zfin-specific domain helpers (`ageOn`, `yearMonth`,
//! `monthsBetween`, the 365.25-day `yearsBetween`, the Feb-29
//! clamp on `addYears`/`subtractYears`) stay here - zeit has no
//! equivalent.
//!
//! ## Format methods
//!
//! - `Date.format(self, *std.Io.Writer) !void` - Zig 0.15+
@ -27,6 +40,7 @@
const std = @import("std");
const srf = @import("srf");
const zeit = @import("zeit");
/// Days since 1970-01-01.
days: i32,
@ -107,9 +121,10 @@ pub fn padLeft(self: Date, width: usize) Padded(Date) {
/// Day of week: 0=Monday, 1=Tuesday, ..., 4=Friday, 5=Saturday, 6=Sunday.
pub fn dayOfWeek(self: Date) u8 {
// 1970-01-01 was a Thursday (day 3 in 0=Mon scheme)
const d = @mod(self.days + 3, @as(i32, 7));
return @intCast(if (d < 0) d + 7 else d);
// zeit's Weekday is sun=0..sat=6; remap to this type's
// mon=0..sun=6 scheme with (w + 6) % 7.
const w: u8 = @intFromEnum(zeit.weekdayFromDays(self.days));
return (w + 6) % 7;
}
pub fn eql(a: Date, b: Date) bool {
@ -138,7 +153,7 @@ pub fn fromEpoch(epoch_secs: i64) Date {
pub fn subtractYears(self: Date, n: u16) Date {
const ymd = epochDaysToYmd(self.days);
const new_year: i16 = ymd.year - @as(i16, @intCast(n));
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day;
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !zeit.isLeapYear(new_year)) 28 else ymd.day;
return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
}
@ -149,7 +164,7 @@ pub fn subtractYears(self: Date, n: u16) Date {
pub fn addYears(self: Date, n: u16) Date {
const ymd = epochDaysToYmd(self.days);
const new_year: i16 = ymd.year + @as(i16, @intCast(n));
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day;
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !zeit.isLeapYear(new_year)) 28 else ymd.day;
return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
}
@ -187,9 +202,8 @@ pub fn lastDayOfMonth(y: i16, m: u8) Date {
}
fn daysInMonth(y: i16, m: u8) u8 {
const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (m == 2 and isLeapYear(y)) return 29;
return table[m - 1];
const mon: zeit.Month = @enumFromInt(m);
return mon.lastDay(y);
}
/// Three-letter English abbreviation of a month number
@ -197,12 +211,12 @@ fn daysInMonth(y: i16, m: u8) u8 {
/// out-of-range input rather than panicking - display
/// helpers prefer a placeholder over a crash.
pub fn monthShort(m: u8) []const u8 {
const table = [_][]const u8{
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
};
// Range-guard before the enum cast: zeit.Month is enum(u4)
// jan=1..dec=12, so @enumFromInt on an out-of-range value
// would be illegal behavior. Display callers want "???".
if (m < 1 or m > 12) return "???";
return table[m - 1];
const mon: zeit.Month = @enumFromInt(m);
return mon.shortName();
}
/// Returns approximate number of years between two dates.
@ -280,39 +294,29 @@ pub fn ageOn(self: Date, on: Date) u16 {
return @intCast(years);
}
fn isLeapYear(y: i16) bool {
const yu: u16 = @bitCast(y);
return (yu % 4 == 0 and yu % 100 != 0) or (yu % 400 == 0);
}
const Ymd = struct { year: i16, month: u8, day: u8 };
/// Decompose epoch-days into civil year/month/day. `zeit.Days` is
/// also `i32` days-since-epoch, so this is a direct delegation to
/// `zeit.civilFromDays` (no parallel hand-rolled algorithm).
fn epochDaysToYmd(days: i32) Ymd {
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
// Using i64 throughout to avoid overflow on unsigned intermediate values.
const z: i64 = @as(i64, days) + 719468;
const era: i64 = @divFloor(if (z >= 0) z else z - 146096, 146097);
const doe_i: i64 = z - era * 146097; // [0, 146096]
const doe: u64 = @intCast(doe_i);
const yoe_val: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
const y: i64 = @as(i64, @intCast(yoe_val)) + era * 400;
const doy: u64 = doe - (365 * yoe_val + yoe_val / 4 - yoe_val / 100);
const mp: u64 = (5 * doy + 2) / 153;
const d: u8 = @intCast(doy - (153 * mp + 2) / 5 + 1);
const m_raw: u64 = if (mp < 10) mp + 3 else mp - 9;
const m: u8 = @intCast(m_raw);
const y_adj: i16 = @intCast(if (m <= 2) y + 1 else y);
return .{ .year = y_adj, .month = m, .day = d };
const zd = zeit.civilFromDays(days);
return .{
.year = @intCast(zd.year),
.month = @intFromEnum(zd.month),
.day = zd.day,
};
}
/// Compose epoch-days from civil year/month/day via
/// `zeit.daysFromCivil`. `m` must be 1..12 (callers within this
/// file guarantee it; `zeit.Month` is `enum(u4) { jan = 1, .. }`).
fn ymdToEpochDays(y: i16, m: u8, d: u8) i32 {
const y_adj: i64 = @as(i64, y) - @as(i64, if (m <= 2) @as(i64, 1) else @as(i64, 0));
const era: i64 = @divFloor(if (y_adj >= 0) y_adj else y_adj - 399, 400);
const yoe: u64 = @intCast(y_adj - era * 400);
const m_adj: u64 = if (m > 2) @as(u64, m) - 3 else @as(u64, m) + 9;
const doy: u64 = (153 * m_adj + 2) / 5 + @as(u64, d) - 1;
const doe: u64 = yoe * 365 + yoe / 4 -| yoe / 100 + doy;
return @intCast(era * 146097 + @as(i64, @intCast(doe)) - 719468);
return zeit.daysFromCivil(.{
.year = y,
.month = @enumFromInt(m),
.day = @intCast(d),
});
}
// Tests
@ -480,6 +484,10 @@ test "dayOfWeek" {
try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek());
// 2024-01-01 (Monday)
try std.testing.expectEqual(@as(u8, 0), Date.fromYmd(2024, 1, 1).dayOfWeek());
// 2024-01-16 (Tuesday)
try std.testing.expectEqual(@as(u8, 1), Date.fromYmd(2024, 1, 16).dayOfWeek());
// 2024-01-17 (Wednesday)
try std.testing.expectEqual(@as(u8, 2), Date.fromYmd(2024, 1, 17).dayOfWeek());
// 2024-01-19 (Friday)
try std.testing.expectEqual(@as(u8, 4), Date.fromYmd(2024, 1, 19).dayOfWeek());
// 2024-01-20 (Saturday)
@ -488,6 +496,16 @@ test "dayOfWeek" {
try std.testing.expectEqual(@as(u8, 6), Date.fromYmd(2024, 1, 21).dayOfWeek());
}
test "monthShort" {
try std.testing.expectEqualStrings("Jan", Date.monthShort(1));
try std.testing.expectEqualStrings("Feb", Date.monthShort(2));
try std.testing.expectEqualStrings("Dec", Date.monthShort(12));
// Out-of-range returns the placeholder rather than panicking on
// the enum cast.
try std.testing.expectEqualStrings("???", Date.monthShort(0));
try std.testing.expectEqualStrings("???", Date.monthShort(13));
}
test "eql and lessThan" {
const a = Date.fromYmd(2024, 6, 15);
const b = Date.fromYmd(2024, 6, 15);

View file

@ -191,6 +191,13 @@ pub const LoadOptions = struct {
/// portfolio with current intraday quotes rather than the prior
/// daily close. Borrowed; pd does not take ownership.
live_quotes: ?*const std.StringHashMap(f64) = null,
/// Wall-clock instant (Unix seconds) at which `live_quotes` were
/// fetched. Stored on pd as `live_quotes_at_s` when the overlay
/// actually re-prices a held position, so the renderer can show a
/// precise "(as of H:MM PM ET)" stamp. Ignored when `live_quotes`
/// is null. Caller captures it once via
/// `std.Io.Timestamp.now(io, .real).toSeconds()`.
live_quotes_at_s: ?i64 = null,
/// Per-worker start delays. Each background worker sleeps for
/// its delay before doing any work, letting the caller
/// deprioritize a specific worker (e.g. push it later so a
@ -276,6 +283,12 @@ latest_quote_date: ?Date = null,
/// Drives the portfolio "as of" label wording.
live_prices_applied: bool = false,
/// Unix-seconds instant the live intraday quotes were fetched, when
/// `live_prices_applied` is true (else null). Threaded in from the
/// TUI refresh path via `LoadOptions.live_quotes_at_s`; drives the
/// precise "(as of H:MM PM ET)" portfolio footer stamp.
live_quotes_at_s: ?i64 = null,
/// Cached prices for watchlist symbols (no live fetching during
/// render). Allocated in pd's arena.
watchlist_prices: ?std.StringHashMap(f64) = null,
@ -434,6 +447,38 @@ pub fn invalidateClassificationMap(self: *PortfolioData) void {
self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) });
}
/// Prime ONLY the `metadata.srf` classification map for `paths_in`,
/// without the full `load()` (no price fetch, no summary, no
/// candle/dividend/account workers). Spawns the same
/// `classificationMapWorker` that `load()` uses, so a subsequent
/// `classificationMap()` resolves curated security names identically.
///
/// Used by the TUI's explicit-symbol launch (`zfin AAPL`), which skips
/// the portfolio load entirely but still wants the quote tab (and the
/// 'K' overlay) to show the `metadata.srf` security name the way the CLI
/// `quote` command does. No-op when `paths_in` is empty or a portfolio
/// is already loaded (a full `load()` populates the map itself).
///
/// Intentional side effect: capturing `paths_in` here also lets the `R`
/// (reload_portfolio) key run a first full `load()` from symbol mode -
/// see `portfolio_tab.reloadPortfolioFile` - so the user can "upgrade" a
/// symbol-only session into the full portfolio view on demand.
pub fn primeClassificationMap(self: *PortfolioData, paths_in: []const []const u8) void {
if (paths_in.len == 0 or self.paths.len != 0) return;
// Dupe the anchor paths into the per-load arena so `anchorPath()`
// (and the worker's metadata.srf derivation) outlive `paths_in`.
const arena_alloc = self.allocator();
const paths_dup = arena_alloc.alloc([]const u8, paths_in.len) catch return;
for (paths_in, 0..) |p, i| {
paths_dup[i] = arena_alloc.dupe(u8, p) catch return;
}
self.paths = paths_dup;
if (self.classification_map_future) |*f| _ = f.cancel(self.io);
self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) });
}
/// Drain a worker future. Idempotent (Future.await is itself
/// idempotent); safe to call every time.
fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void {
@ -516,6 +561,7 @@ pub fn load(
self.summary = null;
self.latest_quote_date = null;
self.live_prices_applied = false;
self.live_quotes_at_s = null;
self.watchlist_prices = null;
self.snapshots_data = null;
self.dividends_data = null;
@ -663,6 +709,10 @@ pub fn load(
// so the summary reflects today's intraday prices rather than
// the candle close. Drives the portfolio "as of" label.
self.live_prices_applied = held_overrides > 0;
// Stamp the fetch instant only when the overlay actually took
// effect, so the footer's precise "(as of H:MM PM ET)" reflects
// a real re-price (not a no-op refresh).
self.live_quotes_at_s = if (held_overrides > 0) opts.live_quotes_at_s else null;
}
self.watchlist_prices = wp;
@ -966,6 +1016,43 @@ test "PortfolioData.cancelLoad: idempotent on idle state" {
try testing.expect(pd.classification_map_data == null);
}
test "PortfolioData.primeClassificationMap: spawns the classification worker without a full load" {
var svc: DataService = .{
.allocator = testing.allocator,
.io = testing.io,
.config = .{ .cache_dir = "./.tmp/zfin-pd-prime-cache" },
};
var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
defer pd.deinit();
// Explicit-symbol launch shape: nothing loaded yet, so the map is
// null - exactly the state that left the TUI quote tab nameless.
try testing.expectEqual(@as(usize, 0), pd.paths.len);
try testing.expect(pd.classification_map_future == null);
try testing.expect(pd.classificationMap() == null);
// Empty paths: no-op (still nothing loaded).
pd.primeClassificationMap(&.{});
try testing.expectEqual(@as(usize, 0), pd.paths.len);
try testing.expect(pd.classification_map_future == null);
// A real anchor sets the path and spawns the SAME worker `load()`
// uses, so a later `classificationMap()` reads metadata.srf and
// resolves the curated name the way the CLI does.
pd.primeClassificationMap(&.{"./.tmp/zfin-pd-prime-test/portfolio.srf"});
try testing.expectEqual(@as(usize, 1), pd.paths.len);
try testing.expect(pd.classification_map_future != null);
try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?);
// Already primed: a second call must not clobber the loaded paths.
pd.primeClassificationMap(&.{"./.tmp/other/portfolio.srf"});
try testing.expectEqual(@as(usize, 1), pd.paths.len);
try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?);
// Drain the spawned worker so teardown leaves no dangling future.
_ = pd.classificationMap();
}
test "PortfolioData.candles: returns null after cancelLoad with no data" {
var svc: DataService = .{
.allocator = testing.allocator,

View file

@ -147,7 +147,7 @@ pub fn rsi(
/// Extract chart-ready close prices from candles into a contiguous f64 slice.
/// Uses `Candle.chartClose()` (split-adjusted when available) so chart
/// renderers don't show false cliffs at split dates. The only callers
/// today are chart code paths in `tui/chart.zig`; if a future caller
/// today are chart code paths in `charts/chart.zig`; if a future caller
/// genuinely needs raw `close`, add a separate `rawClosePrices` helper
/// rather than re-purposing this one.
pub fn closePrices(alloc: std.mem.Allocator, candles: []const Candle) ![]f64 {

View file

@ -299,6 +299,33 @@ pub fn extractMetric(
return out;
}
/// Extract a single metric into `MetricPoint`s for charting, skipping
/// imported-only points for the derived metrics (`.illiquid` /
/// `.net_worth`) that `imported_values.srf` does not carry. Imported
/// rows record only `liquid`; their illiquid/net_worth read as zero, so
/// including them would yank a chart line down to zero across the
/// imported-only range. For `.liquid`, every point is kept.
///
/// Result is caller-owned and preserves the input's ascending-by-date
/// order. This is the single home for the "skip imported-only for
/// derived metrics" rule shared by the CLI braille chart, the CLI
/// `--export-chart` / inline path, and the TUI history chart.
pub fn extractChartSeries(
allocator: std.mem.Allocator,
points: []const TimelinePoint,
metric: Metric,
) ![]MetricPoint {
const skip_imported = (metric == .illiquid) or (metric == .net_worth);
var list: std.ArrayList(MetricPoint) = .empty;
errdefer list.deinit(allocator);
try list.ensureTotalCapacity(allocator, points.len);
for (points) |p| {
if (skip_imported and p.source == .imported) continue;
list.appendAssumeCapacity(.{ .date = p.as_of_date, .value = extractValue(p, metric) });
}
return list.toOwnedSlice(allocator);
}
/// Which collection of per-row named values on `TimelinePoint` to project.
pub const NamedSeriesSource = enum { accounts, tax_types };
@ -1707,6 +1734,47 @@ test "Metric.label: stable strings" {
try testing.expectEqualStrings("Illiquid", Metric.illiquid.label());
}
test "extractChartSeries: liquid keeps imported-only points" {
const points = [_]TimelinePoint{
.{ .as_of_date = Date.fromYmd(2026, 1, 1), .net_worth = 0, .liquid = 700, .illiquid = 0, .accounts = &.{}, .tax_types = &.{}, .source = .imported },
.{ .as_of_date = Date.fromYmd(2026, 2, 1), .net_worth = 1000, .liquid = 800, .illiquid = 200, .accounts = &.{}, .tax_types = &.{}, .source = .snapshot },
};
const out = try extractChartSeries(testing.allocator, &points, .liquid);
defer testing.allocator.free(out);
// Both rows kept; liquid is carried by imported rows.
try testing.expectEqual(@as(usize, 2), out.len);
try testing.expectEqual(@as(f64, 700), out[0].value);
try testing.expectEqual(@as(f64, 800), out[1].value);
try testing.expect(out[0].date.eql(Date.fromYmd(2026, 1, 1)));
}
test "extractChartSeries: illiquid / net_worth drop imported-only points" {
const points = [_]TimelinePoint{
.{ .as_of_date = Date.fromYmd(2026, 1, 1), .net_worth = 0, .liquid = 700, .illiquid = 0, .accounts = &.{}, .tax_types = &.{}, .source = .imported },
.{ .as_of_date = Date.fromYmd(2026, 2, 1), .net_worth = 1000, .liquid = 800, .illiquid = 200, .accounts = &.{}, .tax_types = &.{}, .source = .snapshot },
.{ .as_of_date = Date.fromYmd(2026, 3, 1), .net_worth = 1200, .liquid = 900, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, .source = .snapshot },
};
const ill = try extractChartSeries(testing.allocator, &points, .illiquid);
defer testing.allocator.free(ill);
// Imported row skipped; only the two snapshot rows remain.
try testing.expectEqual(@as(usize, 2), ill.len);
try testing.expectEqual(@as(f64, 200), ill[0].value);
try testing.expectEqual(@as(f64, 300), ill[1].value);
const nw = try extractChartSeries(testing.allocator, &points, .net_worth);
defer testing.allocator.free(nw);
try testing.expectEqual(@as(usize, 2), nw.len);
try testing.expectEqual(@as(f64, 1000), nw[0].value);
try testing.expectEqual(@as(f64, 1200), nw[1].value);
}
test "extractChartSeries: empty input returns empty slice" {
const out = try extractChartSeries(testing.allocator, &.{}, .liquid);
defer testing.allocator.free(out);
try testing.expectEqual(@as(usize, 0), out.len);
}
test "extractNamedSeries accounts: matches + absent days emit 0" {
// Build three snapshots: day1 has account A; day2 has account B; day3 has both.
// Extracting "A" should see value on day1, 0 on day2, value on day3.

View file

@ -4,8 +4,8 @@
//! module. Each chart-bearing command (`quote`, `projections`)
//! has a corresponding `export*` function here that:
//!
//! 1. Calls the relevant `renderToSurface` (in `tui/chart.zig` or
//! `tui/projection_chart.zig`) to draw the chart into a z2d
//! 1. Calls the relevant `renderToSurface` (in `charts/chart.zig` or
//! `charts/projection_chart.zig`) to draw the chart into a z2d
//! `Surface`.
//! 2. Calls `z2d.png_exporter.writeToPNGFile` to land the surface
//! as a PNG file at the user-supplied path.
@ -25,36 +25,48 @@
const std = @import("std");
const z2d = @import("z2d");
const zfin = @import("root.zig");
const chart = @import("tui/chart.zig");
const projection_chart = @import("tui/projection_chart.zig");
const chart = @import("charts/chart.zig");
const projection_chart = @import("charts/projection_chart.zig");
const line_chart = @import("charts/line_chart.zig");
const projections = @import("analytics/projections.zig");
const theme = @import("tui/theme.zig");
/// Default PNG export resolution. Matches `tui/chart.zig`'s
/// Default PNG export resolution. Matches `charts/chart.zig`'s
/// `ChartConfig.max_width/max_height` defaults so an exported
/// image carries the same fidelity as a maximally-sized TUI
/// chart.
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,
else => return err,
@ -82,6 +94,34 @@ pub fn exportProjectionChart(
default_height,
theme.default_theme,
actuals,
true,
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer rendered.deinit(alloc);
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
}
/// Export a single-series portfolio-value timeline as a PNG. Wraps
/// `line_chart.renderToSurface` + `writeToPNGFile`. `baseline` selects
/// whether the y-axis fits the data or anchors at zero.
pub fn exportTimelineChart(
io: std.Io,
alloc: std.mem.Allocator,
points: []const line_chart.LinePoint,
baseline: line_chart.Baseline,
path: []const u8,
) !void {
var rendered = line_chart.renderToSurface(
io,
alloc,
points,
default_width,
default_height,
theme.default_theme,
.{ .baseline = baseline, .axis_labels = true },
) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
@ -121,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).
@ -164,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),
);
}
@ -234,3 +274,58 @@ test "exportProjectionChart returns InsufficientData with single band" {
exportProjectionChart(io, alloc, &bands, null, path),
);
}
test "exportTimelineChart writes a non-empty PNG file" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
var points: [12]line_chart.LinePoint = undefined;
for (0..12) |i| {
points[i] = .{
.date = Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 30)),
.value = 1_000_000.0 + 25_000.0 * @as(f64, @floatFromInt(i)),
};
}
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline.png" });
defer alloc.free(path);
try exportTimelineChart(io, alloc, &points, .fit, path);
var file = try tmp.dir.openFile(io, "test_export_timeline.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try std.testing.expect(size > 1024);
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportTimelineChart returns InsufficientData with a single point" {
const Date = @import("Date.zig");
const alloc = std.testing.allocator;
const io = std.testing.io;
var points: [1]line_chart.LinePoint = .{.{ .date = Date.fromYmd(2025, 1, 1), .value = 1_000_000 }};
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir_path = path_buf[0..dir_len];
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline_insufficient.png" });
defer alloc.free(path);
try std.testing.expectError(
error.InsufficientData,
exportTimelineChart(io, alloc, &points, .fit, path),
);
}

160
src/charts/axis.zig Normal file
View file

@ -0,0 +1,160 @@
//! Shared axis-label helpers for the chart exports, layered on top of
//! `text.zig` (bitmap-glyph stamping) and `format.fmtLargeNum` (compact
//! dollar formatting).
//!
//! Each renderer reserves margins and calls these to draw y-axis dollar
//! ticks and x-axis endpoint labels straight into the surface buffer.
//! Kept here (rather than copy-pasted into each chart) so there's one
//! source of truth for label formatting, spacing, and tick math.
const std = @import("std");
const z2d = @import("z2d");
const text = @import("text.zig");
const draw = @import("draw.zig");
const fmt = @import("../format.zig");
const Surface = z2d.Surface;
/// Pick a glyph scale from the surface height so labels stay legible on
/// large exports (1080p -> 3) and shrink on small surfaces (>= 1).
pub fn labelScale(height_px: i32) i32 {
return @max(1, @divFloor(height_px, 360));
}
/// Glyph cell height in surface pixels at `scale`.
pub fn charHeight(scale: i32) f64 {
return @floatFromInt(text.glyph_h * scale);
}
/// Gap (pixels) between the plot edge and the nearest label.
pub fn labelGap(scale: i32) f64 {
return @floatFromInt(4 * scale);
}
/// Bottom margin to reserve so a full x-axis label row fits below the
/// plot without clipping: gap + glyph height + a little breathing room.
pub fn bottomMargin(scale: i32) f64 {
return labelGap(scale) + charHeight(scale) + @as(f64, @floatFromInt(2 * scale));
}
/// Left/right margin (in pixels) to reserve for a column of dollar
/// labels at `scale`. Sized for the widest label we emit - an 8-glyph
/// comma'd sub-million value like "$999,999" - plus the gap to the plot.
pub fn yAxisMargin(scale: i32) f64 {
return @as(f64, @floatFromInt(text.measureWidth("$999,999", scale))) + labelGap(scale) + @as(f64, @floatFromInt(2 * scale));
}
/// Format a dollar value for an axis tick. Below a million we use whole
/// dollars with thousands separators ("$887,889"); at or above a million
/// we switch to compact T/B/M suffixes via `format.fmtLargeNum`
/// ("$1.3M", "$370.2M") so large axes stay narrow. Negatives get a
/// leading "-". The sub-million path rounds to the nearest whole dollar.
pub fn fmtDollar(buf: []u8, value: f64) []const u8 {
const sign = if (value < 0) "-" else "";
const abs = @abs(value);
if (abs >= 1_000_000) {
const large = fmt.fmtLargeNum(abs);
const trimmed = std.mem.trimEnd(u8, &large, " ");
return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, trimmed }) catch "$?";
}
var nbuf: [20]u8 = undefined;
const whole: u64 = @intFromFloat(@round(abs));
const commas = fmt.fmtIntCommas(&nbuf, whole);
return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, commas }) catch "$?";
}
/// Draw `n + 1` left-aligned dollar labels evenly spaced from
/// `value_max` (at `top`) down to `value_min` (at `bottom`), placed in
/// the right margin a small pad to the right of `plot_right` (the plot's
/// right edge) and vertically centered on each level. Right-side y-axis
/// is the financial convention: the latest (rightmost) values sit next
/// to the scale as the series grows.
pub fn drawYDollarTicks(
sfc: *Surface,
scale: i32,
color: [3]u8,
plot_right: f64,
top: f64,
bottom: f64,
value_min: f64,
value_max: f64,
n: usize,
) void {
const range = value_max - value_min;
const span = bottom - top;
const half_h = @divFloor(text.glyph_h * scale, 2);
const pad: f64 = labelGap(scale);
const lx: i32 = @intFromFloat(plot_right + pad);
var i: usize = 0;
while (i <= n) : (i += 1) {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n));
const val = value_max - frac * range;
const y = top + frac * span;
var buf: [24]u8 = undefined;
const label = fmtDollar(&buf, val);
const ly = @as(i32, @intFromFloat(y)) - half_h;
text.drawText(sfc, lx, ly, scale, color, label);
}
}
/// Draw two endpoint labels on baseline `y`: `left_label` left-aligned
/// at `left`, `right_label` right-aligned ending at `right`. Used for
/// x-axis start/end (dates for time series, offsets for projections).
pub fn drawXEndpoints(
sfc: *Surface,
scale: i32,
color: [3]u8,
left: f64,
right: f64,
y: f64,
left_label: []const u8,
right_label: []const u8,
) void {
const yi: i32 = @intFromFloat(y);
text.drawText(sfc, @as(i32, @intFromFloat(left)), yi, scale, color, left_label);
const rw = text.measureWidth(right_label, scale);
text.drawText(sfc, @as(i32, @intFromFloat(right)) - rw, yi, scale, color, right_label);
}
// Tests
const testing = std.testing;
test "labelScale grows with height, floored at 1" {
try testing.expectEqual(@as(i32, 1), labelScale(100));
try testing.expectEqual(@as(i32, 1), labelScale(360));
try testing.expectEqual(@as(i32, 3), labelScale(1080));
}
test "fmtDollar: M/B suffixes at/above a million, commas below, sign for negatives" {
var buf: [24]u8 = undefined;
try testing.expectEqualStrings("$1.3M", fmtDollar(&buf, 1_250_000));
try testing.expectEqualStrings("$2.0B", fmtDollar(&buf, 2_000_000_000));
try testing.expectEqualStrings("$950,000", fmtDollar(&buf, 950_000));
try testing.expectEqualStrings("$887,889", fmtDollar(&buf, 887_889));
try testing.expectEqualStrings("$313", fmtDollar(&buf, 313));
try testing.expectEqualStrings("-$1.2M", fmtDollar(&buf, -1_200_000));
}
test "drawYDollarTicks stamps labels in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);
defer sfc.deinit(alloc);
const color = [3]u8{ 0xCC, 0xCC, 0xCC };
try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, color));
// plot_right=180 leaves room for the labels in the right margin of a
// 300px-wide surface.
drawYDollarTicks(&sfc, 2, color, 180, 10, 190, 1_000_000, 5_000_000, 5);
try testing.expect(draw.countColor(&sfc, color) > 0);
}
test "drawXEndpoints draws both endpoint labels" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 60);
defer sfc.deinit(alloc);
const color = [3]u8{ 0x40, 0x80, 0xC0 };
drawXEndpoints(&sfc, 2, color, 10, 290, 20, "2024-01-01", "2026-12-31");
// Both labels contribute pixels; expect a comfortably non-trivial count.
try testing.expect(draw.countColor(&sfc, color) > 20);
}

View file

@ -5,7 +5,9 @@
const std = @import("std");
const z2d = @import("z2d");
const zfin = @import("../root.zig");
const theme = @import("theme.zig");
const theme = @import("../tui/theme.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const Surface = z2d.Surface;
@ -49,6 +51,7 @@ const Pixel = z2d.Pixel;
/// Chart timeframe selection.
pub const Timeframe = enum {
@"3M",
@"6M",
ytd,
@"1Y",
@ -57,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",
@ -67,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,
@ -77,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",
@ -173,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.
@ -196,17 +248,7 @@ pub const RenderedChart = struct {
/// Caller owns the returned slice. The surface is left intact so
/// the caller can still call `deinit`.
pub fn extractRgb(self: *const RenderedChart, alloc: std.mem.Allocator) ![]u8 {
const rgb_buf = switch (self.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
raw[i * 3 + 0] = px.r;
raw[i * 3 + 1] = px.g;
raw[i * 3 + 2] = px.b;
}
return raw;
return draw.extractRgb(alloc, &self.surface);
}
};
@ -222,17 +264,18 @@ 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,
cached: ?*const CachedIndicators,
axis_labels: bool,
) !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
@ -287,21 +330,23 @@ pub fn renderToSurface(
const fheight: f64 = @floatFromInt(height_px);
// Background
ctx.setSourceToPixel(opaqueColor(bg));
ctx.resetPath();
try ctx.moveTo(0, 0);
try ctx.lineTo(fwidth, 0);
try ctx.lineTo(fwidth, fheight);
try ctx.lineTo(0, fheight);
try ctx.closePath();
try ctx.fill();
try draw.fillBackground(&ctx, fwidth, fheight, bg);
// Panel dimensions
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
// Panel dimensions. With axis labels we reserve a left margin
// (price dollar ticks) and a bottom margin (start/end dates),
// scaled to the surface so labels stay legible on large exports.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = margin_top;
const total_h = fheight - margin_top - margin_bottom;
const chart_top = m_top;
const total_h = fheight - m_top - m_bottom;
const price_h = total_h * price_frac;
const price_top = chart_top;
@ -504,6 +549,18 @@ pub fn renderToSurface(
}
}
// Axis labels (export only)
if (axis_labels) {
// Dollar ticks against the price panel; start/end dates below.
axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_right, price_top, price_bottom, price_min, price_max, 4);
var fbuf: [12]u8 = undefined;
var lbuf: [12]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{data[0].date}) catch "";
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{data[data.len - 1].date}) catch "";
const date_y = rsi_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
}
return .{
.surface = sfc,
.width = @intCast(width_px),
@ -527,7 +584,7 @@ pub fn renderChart(
th: theme.Theme,
cached: ?*const CachedIndicators,
) !ChartResult {
var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached);
var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached, false);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
return .{
@ -541,29 +598,14 @@ pub fn renderChart(
}
// Drawing helpers
//
// The stateless primitives below are shared with the other chart
// renderers and live in `draw.zig`; aliased here so the call sites in
// this file stay unchanged.
fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
if (max_val == min_val) return (top_px + bottom_px) / 2;
const norm = (value - min_val) / (max_val - min_val);
return bottom_px - norm * (bottom_px - top_px);
}
/// Pre-blend a foreground color with alpha against a background color.
/// Returns a fully opaque pixel. This avoids z2d's broken src_over compositor.
fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
const a = @as(f64, @floatFromInt(alpha)) / 255.0;
const inv_a = 1.0 - a;
return .{ .rgb = .{
.r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
.g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
.b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
} };
}
/// Opaque pixel from theme color.
fn opaqueColor(c: [3]u8) Pixel {
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
}
const mapY = draw.mapY;
const blendColor = draw.blendColor;
const opaqueColor = draw.opaqueColor;
const BandField = enum { upper, middle, lower };
@ -606,90 +648,12 @@ fn drawLineSeries(
ctx.setLineWidth(2.0);
}
fn drawHorizontalGridLines(
ctx: *Context,
left: f64,
right: f64,
top: f64,
bottom: f64,
n_lines: usize,
col: Pixel,
) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(0.5);
for (1..n_lines) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
const y = top + frac * (bottom - top);
ctx.resetPath();
try ctx.moveTo(left, y);
try ctx.lineTo(right, y);
try ctx.stroke();
}
ctx.setLineWidth(2.0);
}
fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(w);
ctx.resetPath();
try ctx.moveTo(x1, y);
try ctx.lineTo(x2, y);
try ctx.stroke();
ctx.setLineWidth(2.0);
}
fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(w);
ctx.resetPath();
try ctx.moveTo(x1, y1);
try ctx.lineTo(x2, y1);
try ctx.lineTo(x2, y2);
try ctx.lineTo(x1, y2);
try ctx.closePath();
try ctx.stroke();
ctx.setLineWidth(2.0);
}
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect;
// Tests
test "mapY maps value to pixel coordinate" {
// value at min -> bottom
try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
// value at max -> top
try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
// value at midpoint -> midpoint
try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
// flat range -> midpoint
try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
}
test "blendColor alpha blending" {
const white = [3]u8{ 255, 255, 255 };
const black = [3]u8{ 0, 0, 0 };
// Full alpha -> foreground
const full = blendColor(white, 255, black);
try std.testing.expectEqual(@as(u8, 255), full.rgb.r);
try std.testing.expectEqual(@as(u8, 255), full.rgb.g);
// Zero alpha -> background
const zero = blendColor(white, 0, black);
try std.testing.expectEqual(@as(u8, 0), zero.rgb.r);
// Half alpha -> midpoint
const half = blendColor(white, 128, black);
// 255 * (128/255) + 0 * (127/255) 128
try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
}
test "opaqueColor wraps theme color" {
const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f });
try std.testing.expectEqual(@as(u8, 0x7f), px.rgb.r);
try std.testing.expectEqual(@as(u8, 0xd8), px.rgb.g);
try std.testing.expectEqual(@as(u8, 0x8f), px.rgb.b);
}
test "ChartConfig.parse" {
// Named modes
const auto = ChartConfig.parse("auto").?;
@ -712,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 +
@ -758,7 +738,7 @@ fn buildLinearCandles(arr: []zfin.Candle, start_price: f64) void {
test "renderToSurface returns InsufficientData with < 20 candles" {
var candles: [10]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0);
const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
try std.testing.expectError(error.InsufficientData, result);
}
@ -766,7 +746,7 @@ test "renderToSurface produces a populated surface at requested dimensions" {
var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0);
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
defer rendered.deinit(test_alloc);
try std.testing.expectEqual(@as(u16, 200), rendered.width);
@ -782,7 +762,7 @@ test "renderToSurface price range covers the input close range" {
var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0); // closes: 100..129
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
defer rendered.deinit(test_alloc);
// 5% padding is applied inside renderToSurface, so the recorded
@ -812,7 +792,7 @@ test "renderToSurface uses chartClose so split-day cliffs don't widen the price
};
}
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
defer rendered.deinit(test_alloc);
// With chartClose, max should be near 100 - definitely not 250+.
@ -829,7 +809,7 @@ test "renderToSurface fills background with theme bg" {
var th = theme.default_theme;
th.bg = .{ 0x12, 0x34, 0x56 };
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null);
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null, false);
defer rendered.deinit(test_alloc);
// Pixel at (0, 0) is in the top-left margin - outside the chart
@ -849,9 +829,9 @@ test "renderToSurface is deterministic across two calls with same input" {
var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0);
var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
defer a.deinit(test_alloc);
var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
defer b.deinit(test_alloc);
const buf_a = switch (a.surface) {
@ -877,7 +857,7 @@ test "RenderedChart.extractRgb produces 3 bytes per pixel matching surface buffe
var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0);
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null);
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null, false);
defer rendered.deinit(test_alloc);
const raw = try rendered.extractRgb(test_alloc);

308
src/charts/draw.zig Normal file
View file

@ -0,0 +1,308 @@
//! Shared z2d drawing primitives for the chart renderers in
//! `src/charts/` (chart.zig, projection_chart.zig, forecast_chart.zig,
//! line_chart.zig).
//!
//! These helpers were copy-pasted verbatim across all four renderers
//! before this module existed; they're consolidated here so there is a
//! single source of truth. Everything here is a pure, stateless z2d
//! operation parameterized by pixel coordinates and pre-blended colors:
//! no theme, domain, or chart-shape knowledge lives here.
//!
//! The Surface/Context lifetime (the `Surface.init` + `errdefer`/`defer
//! deinit` dance and the AA/operator setup) deliberately stays with
//! each renderer - that ownership does not extract cleanly - so only
//! the stateless drawing belongs in this module.
const std = @import("std");
const z2d = @import("z2d");
const Surface = z2d.Surface;
const Context = z2d.Context;
const Pixel = z2d.Pixel;
/// Map a data value to a y pixel coordinate within `[top_px, bottom_px]`.
/// A larger value maps nearer `top_px` (screen space grows downward). A
/// degenerate (`min_val == max_val`) range maps to the vertical midpoint.
pub fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
if (max_val == min_val) return (top_px + bottom_px) / 2;
const norm = (value - min_val) / (max_val - min_val);
return bottom_px - norm * (bottom_px - top_px);
}
/// Pre-blend a foreground color with `alpha` against a background color
/// and return a fully opaque pixel. This sidesteps z2d's src_over
/// compositor (which overflows on semi-transparent fills); renderers
/// draw with the `.src` operator and pre-blend through here instead.
pub fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
const a = @as(f64, @floatFromInt(alpha)) / 255.0;
const inv_a = 1.0 - a;
return .{ .rgb = .{
.r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
.g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
.b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
} };
}
/// Opaque pixel from an RGB triple.
pub fn opaqueColor(c: [3]u8) Pixel {
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
}
/// Fill the whole `width` x `height` surface with an opaque background.
/// Mirrors the "Background" layer every renderer paints first.
pub fn fillBackground(ctx: *Context, width: f64, height: f64, bg: [3]u8) !void {
ctx.setSourceToPixel(opaqueColor(bg));
ctx.resetPath();
try ctx.moveTo(0, 0);
try ctx.lineTo(width, 0);
try ctx.lineTo(width, height);
try ctx.lineTo(0, height);
try ctx.closePath();
try ctx.fill();
}
/// Draw `n_lines - 1` evenly-spaced horizontal grid lines strictly
/// between `top` and `bottom` (the edges themselves are left to the
/// panel border). Restores the line width to 2.0 when done so callers
/// can keep drawing without re-setting it.
pub fn drawHorizontalGridLines(
ctx: *Context,
left: f64,
right: f64,
top: f64,
bottom: f64,
n_lines: usize,
col: Pixel,
) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(0.5);
for (1..n_lines) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
const y = top + frac * (bottom - top);
ctx.resetPath();
try ctx.moveTo(left, y);
try ctx.lineTo(right, y);
try ctx.stroke();
}
ctx.setLineWidth(2.0);
}
/// Draw a horizontal line at `y` from `x1` to `x2`. Restores the line
/// width to 2.0 afterward.
pub fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x1, y);
try ctx.lineTo(x2, y);
try ctx.stroke();
ctx.setLineWidth(2.0);
}
/// Draw a vertical line at `x` from `y1` to `y2`. Restores the line
/// width to 2.0 afterward.
pub fn drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x, y1);
try ctx.lineTo(x, y2);
try ctx.stroke();
ctx.setLineWidth(2.0);
}
/// Stroke an axis-aligned rectangle outline. Restores the line width to
/// 2.0 afterward.
pub fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x1, y1);
try ctx.lineTo(x2, y1);
try ctx.lineTo(x2, y2);
try ctx.lineTo(x1, y2);
try ctx.closePath();
try ctx.stroke();
ctx.setLineWidth(2.0);
}
/// Extract a flat `[]u8` of R,G,B triplets from an `image_surface_rgb`.
/// Caller owns the returned slice; the surface is left intact. Every
/// renderer transmits/encodes pixels through this same shape (Kitty
/// graphics RGB and z2d PNG export both want tightly-packed RGB bytes).
pub fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 {
const rgb_buf = switch (sfc.*) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
raw[i * 3 + 0] = px.r;
raw[i * 3 + 1] = px.g;
raw[i * 3 + 2] = px.b;
}
return raw;
}
// Tests
const testing = std.testing;
/// Build a fresh RGB drawing context backed by `sfc`, configured the way
/// every renderer configures it (AA off, `.src` operator). Caller owns
/// both and must `ctx.deinit()` / `sfc.deinit(alloc)`.
fn testContext(sfc: *Surface) Context {
var ctx = Context.init(testing.io, testing.allocator, sfc);
ctx.setAntiAliasingMode(.none);
ctx.setOperator(.src);
return ctx;
}
/// Count pixels in the surface that exactly match the given RGB. A
/// shared test helper for the chart modules in this directory (draw,
/// text, axis) that need to assert glyphs/lines actually landed in a
/// known color. `pub` only so the sibling test files can reuse it.
pub fn countColor(sfc: *const Surface, color: [3]u8) usize {
const buf = switch (sfc.*) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
var n: usize = 0;
for (buf) |px| {
if (px.r == color[0] and px.g == color[1] and px.b == color[2]) n += 1;
}
return n;
}
test "mapY maps value to pixel coordinate" {
// value at min -> bottom
try testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
// value at max -> top
try testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
// value at midpoint -> midpoint
try testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
// flat range -> midpoint
try testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
}
test "blendColor alpha blending" {
const white = [3]u8{ 255, 255, 255 };
const black = [3]u8{ 0, 0, 0 };
// Full alpha -> foreground.
const full = blendColor(white, 255, black);
try testing.expectEqual(@as(u8, 255), full.rgb.r);
try testing.expectEqual(@as(u8, 255), full.rgb.g);
try testing.expectEqual(@as(u8, 255), full.rgb.b);
// Zero alpha -> background.
const zero = blendColor(white, 0, black);
try testing.expectEqual(@as(u8, 0), zero.rgb.r);
// Half alpha -> midpoint (255 * 128/255 ~= 128).
const half = blendColor(white, 128, black);
try testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
// Zero alpha blends toward a non-black background, not just black.
const onto_gray = blendColor(white, 0, .{ 40, 50, 60 });
try testing.expectEqual(@as(u8, 40), onto_gray.rgb.r);
try testing.expectEqual(@as(u8, 50), onto_gray.rgb.g);
try testing.expectEqual(@as(u8, 60), onto_gray.rgb.b);
}
test "opaqueColor wraps an RGB triple" {
const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f });
try testing.expectEqual(@as(u8, 0x7f), px.rgb.r);
try testing.expectEqual(@as(u8, 0xd8), px.rgb.g);
try testing.expectEqual(@as(u8, 0x8f), px.rgb.b);
}
test "fillBackground paints every pixel the bg color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 8, 6);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 8, 6, .{ 0x11, 0x22, 0x33 });
const buf = switch (sfc) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
for (buf) |px| {
try testing.expectEqual(@as(u8, 0x11), px.r);
try testing.expectEqual(@as(u8, 0x22), px.g);
try testing.expectEqual(@as(u8, 0x33), px.b);
}
}
test "extractRgb yields 3 interleaved bytes per pixel" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 4, 4);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 4, 4, .{ 0xde, 0xad, 0xbe });
const raw = try extractRgb(alloc, &sfc);
defer alloc.free(raw);
const buf = switch (sfc) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try testing.expectEqual(buf.len * 3, raw.len);
// First pixel round-trips as (R, G, B) at indices 0, 1, 2.
try testing.expectEqual(@as(u8, 0xde), raw[0]);
try testing.expectEqual(@as(u8, 0xad), raw[1]);
try testing.expectEqual(@as(u8, 0xbe), raw[2]);
}
test "drawHLine strokes a line in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 });
try drawHLine(&ctx, 2, 38, 12, opaqueColor(.{ 0xff, 0x00, 0x00 }), 1.0);
try testing.expect(countColor(&sfc, .{ 0xff, 0x00, 0x00 }) > 0);
}
test "drawVLine strokes a line in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 });
try drawVLine(&ctx, 20, 2, 22, opaqueColor(.{ 0x00, 0xff, 0x00 }), 1.0);
try testing.expect(countColor(&sfc, .{ 0x00, 0xff, 0x00 }) > 0);
}
test "drawRect strokes a rectangle outline in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 });
try drawRect(&ctx, 4, 4, 36, 20, opaqueColor(.{ 0x00, 0x00, 0xff }), 1.0);
try testing.expect(countColor(&sfc, .{ 0x00, 0x00, 0xff }) > 0);
}
test "drawHorizontalGridLines strokes lines in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 40);
defer sfc.deinit(alloc);
var ctx = testContext(&sfc);
defer ctx.deinit();
try fillBackground(&ctx, 40, 40, .{ 0, 0, 0 });
try drawHorizontalGridLines(&ctx, 2, 38, 2, 38, 5, opaqueColor(.{ 0x33, 0x66, 0x99 }));
try testing.expect(countColor(&sfc, .{ 0x33, 0x66, 0x99 }) > 0);
}

View file

@ -27,9 +27,10 @@
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("theme.zig");
const theme = @import("../tui/theme.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const Date = @import("../Date.zig");
const draw = @import("draw.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
@ -92,14 +93,7 @@ pub fn renderConvergenceChart(
const fheight: f64 = @floatFromInt(height_px);
// Background
ctx.setSourceToPixel(opaqueColor(bg));
ctx.resetPath();
try ctx.moveTo(0, 0);
try ctx.lineTo(fwidth, 0);
try ctx.lineTo(fwidth, fheight);
try ctx.lineTo(0, fheight);
try ctx.closePath();
try ctx.fill();
try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
@ -244,14 +238,7 @@ pub fn renderBacktestChart(
const fheight: f64 = @floatFromInt(height_px);
// Background
ctx.setSourceToPixel(opaqueColor(bg));
ctx.resetPath();
try ctx.moveTo(0, 0);
try ctx.lineTo(fwidth, 0);
try ctx.lineTo(fwidth, fheight);
try ctx.lineTo(0, fheight);
try ctx.closePath();
try ctx.fill();
try draw.fillBackground(&ctx, fwidth, fheight, bg);
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
@ -492,90 +479,20 @@ fn fillCircle(ctx: *Context, cx: f64, cy: f64, r: f64) !void {
try ctx.fill();
}
// Shared helpers (mirrors of projection_chart's privates)
// Shared helpers
//
// The stateless primitives below are shared with the other chart
// renderers and live in `draw.zig`; aliased here so the call sites in
// this file stay unchanged. The chart-specific helpers above
// (drawSeries/strokeSegment/drawDashedLine/fillCircle) stay local.
fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
if (max_val == min_val) return (top_px + bottom_px) / 2;
const norm = (value - min_val) / (max_val - min_val);
return bottom_px - norm * (bottom_px - top_px);
}
fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
const a = @as(f64, @floatFromInt(alpha)) / 255.0;
const inv_a = 1.0 - a;
return .{ .rgb = .{
.r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
.g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
.b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
} };
}
fn opaqueColor(c: [3]u8) Pixel {
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
}
fn drawHorizontalGridLines(
ctx: *Context,
left: f64,
right: f64,
top: f64,
bottom: f64,
n_lines: usize,
col: Pixel,
) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(0.5);
for (1..n_lines) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
const y = top + frac * (bottom - top);
ctx.resetPath();
try ctx.moveTo(left, y);
try ctx.lineTo(right, y);
try ctx.stroke();
}
ctx.setLineWidth(2.0);
}
fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x1, y);
try ctx.lineTo(x2, y);
try ctx.stroke();
ctx.setLineWidth(2.0);
}
fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x1, y1);
try ctx.lineTo(x2, y1);
try ctx.lineTo(x2, y2);
try ctx.lineTo(x1, y2);
try ctx.closePath();
try ctx.stroke();
ctx.setLineWidth(2.0);
}
/// Extract raw RGB bytes from an `image_surface_rgb`. Mirrors the
/// inline pattern in `projection_chart.zig` so both renderers
/// produce the same on-the-wire shape for Kitty graphics
/// transmission. Caller owns the returned slice.
fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 {
const rgb_buf = switch (sfc.*) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const out = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
out[i * 3 + 0] = px.r;
out[i * 3 + 1] = px.g;
out[i * 3 + 2] = px.b;
}
return out;
}
const mapY = draw.mapY;
const blendColor = draw.blendColor;
const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect;
const extractRgb = draw.extractRgb;
// Tests

429
src/charts/line_chart.zig Normal file
View file

@ -0,0 +1,429 @@
//! Single-series line chart renderer using z2d.
//!
//! A deliberately slim sibling of `projection_chart.zig`: it draws one
//! value-over-time series (e.g. portfolio Liquid / Net Worth) as a
//! filled, trend-colored line - no percentile bands, no Bollinger, no
//! RSI, no volume. It exists so the `history` command can render its
//! portfolio-value timeline as a real bitmap chart (inline Kitty
//! graphics or a PNG via `--export-chart`) instead of only braille,
//! reusing the same `renderToSurface` -> RGB / PNG seam the other
//! charts use.
//!
//! Visual layers (bottom to top):
//! - Background
//! - Horizontal grid lines
//! - Filled area under the line (faint, trend-colored)
//! - Zero reference line (only when the y-range straddles zero)
//! - The value line (solid, trend-colored: green if the series ended
//! above where it started, red otherwise)
//! - Panel border
//!
//! X positions are date-proportional: each point sits at
//! `(date - first_date) / (last_date - first_date)` across the chart
//! width, so irregular snapshot spacing (daily recent, weekly/monthly
//! for older imported history) renders to scale rather than evenly by
//! index. If every point shares a date (degenerate input) the renderer
//! falls back to even index spacing.
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("../tui/theme.zig");
const Date = @import("../Date.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
/// Margins in pixels.
const margin_left: f64 = 4;
const margin_right: f64 = 4;
const margin_top: f64 = 4;
const margin_bottom: f64 = 4;
/// How the y-axis lower bound is chosen.
pub const Baseline = enum {
/// Fit the data: lower bound is the series minimum minus a small
/// pad. Best for reading week-to-week variation in a large,
/// slowly-moving balance.
fit,
/// Anchor the lower bound at zero (clamped to the data minimum if
/// the series itself dips below zero). Shows absolute scale at the
/// cost of flattening the visible variation.
zero,
};
/// One (date, value) point on the series. Leaf-level: the chart module
/// deliberately does not depend on `analytics/timeline.zig`. Callers
/// convert their domain points (e.g. `timeline.MetricPoint`) into this
/// shape - same fields, distinct type - the way `projection_chart`
/// keeps its own `ActualsPoint`.
pub const LinePoint = struct {
date: Date,
value: f64,
};
/// Render options.
pub const Options = struct {
baseline: Baseline = .fit,
/// Draw in-image axis labels (y-axis dollar ticks + x-axis start/end
/// dates) into reserved margins. Off by default: the TUI inline path
/// draws its own terminal-text labels beside the image and wants the
/// bare bitmap. The PNG export turns this on so the standalone file
/// is self-describing.
axis_labels: bool = false,
};
/// Line chart render result (raw RGB), produced by `renderLineChart`.
pub const LineChartResult = struct {
/// Raw RGB pixel data (3 bytes per pixel, row-major).
rgb_data: []const u8,
width: u16,
height: u16,
/// Value range for external label rendering.
value_min: f64,
value_max: f64,
};
/// Owned by the caller - call `result.deinit(alloc)` after using it.
/// The shared mid-stage between RGB extraction (Kitty graphics) and PNG
/// export (`--export-chart`). See `renderToSurface`.
pub const RenderedLineChart = struct {
surface: Surface,
width: u16,
height: u16,
value_min: f64,
value_max: f64,
pub fn deinit(self: *RenderedLineChart, alloc: std.mem.Allocator) void {
self.surface.deinit(alloc);
self.* = undefined;
}
/// Extract a flat []u8 of R,G,B triplets from the surface buffer.
/// Caller owns the returned slice. The surface is left intact.
pub fn extractRgb(self: *const RenderedLineChart, alloc: std.mem.Allocator) ![]u8 {
return draw.extractRgb(alloc, &self.surface);
}
};
/// Render a single-series line chart into a `Surface` and return both.
/// Caller owns the result and must call `deinit`.
///
/// Two consumers:
/// - `renderLineChart` wraps this for the CLI's inline Kitty graphics
/// path (extracts RGB, frees surface).
/// - `chart_export.exportTimelineChart` wraps this for PNG export via
/// `z2d.png_exporter.writeToPNGFile`.
pub fn renderToSurface(
io: std.Io,
alloc: std.mem.Allocator,
points: []const LinePoint,
width_px: u32,
height_px: u32,
th: theme.Theme,
opts: Options,
) !RenderedLineChart {
if (points.len < 2) return error.InsufficientData;
const w: i32 = @intCast(width_px);
const h: i32 = @intCast(height_px);
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
errdefer sfc.deinit(alloc);
var ctx = Context.init(io, alloc, &sfc);
defer ctx.deinit();
ctx.setAntiAliasingMode(.none);
ctx.setOperator(.src);
const bg = th.bg;
const fwidth: f64 = @floatFromInt(width_px);
const fheight: f64 = @floatFromInt(height_px);
// Background
try draw.fillBackground(&ctx, fwidth, fheight, bg);
// Chart area. When axis labels are enabled we reserve a left margin
// (y-axis dollar ticks) and a bottom margin (start/end dates), scaled
// with the surface so labels stay legible on large exports.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (opts.axis_labels) label_char_h else margin_left;
const m_right: f64 = if (opts.axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (opts.axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (opts.axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = m_top;
const chart_bottom = fheight - m_bottom;
// Value range
var data_min: f64 = points[0].value;
var data_max: f64 = points[0].value;
for (points) |p| {
if (p.value < data_min) data_min = p.value;
if (p.value > data_max) data_max = p.value;
}
const pad = (data_max - data_min) * 0.05;
const value_max: f64 = data_max + pad;
const value_min: f64 = switch (opts.baseline) {
.fit => data_min - pad,
// Anchor at zero, but never crop a series that genuinely dips
// below zero (e.g. a negative net worth).
.zero => @min(0, data_min),
};
// X mapping (date-proportional)
const first_days: f64 = @floatFromInt(points[0].date.days);
const last_days: f64 = @floatFromInt(points[points.len - 1].date.days);
const span_days: f64 = last_days - first_days;
// Degenerate input (all points share a date): fall back to even
// index spacing so we still draw something sensible.
const use_dates = span_days > 0;
const index_step = chart_w / @as(f64, @floatFromInt(points.len - 1));
const mapX = struct {
fn at(i: usize, p: LinePoint, left: f64, cw: f64, fd: f64, span: f64, by_date: bool, istep: f64) f64 {
if (by_date) {
const d: f64 = @floatFromInt(p.date.days);
return left + ((d - fd) / span) * cw;
}
return left + @as(f64, @floatFromInt(i)) * istep;
}
}.at;
// Grid lines
const grid_color = blendColor(th.text_muted, 40, bg);
try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color);
// Filled area under the line (faint, trend-colored)
const trend_up = points[points.len - 1].value >= points[0].value;
const trend_color = if (trend_up) th.positive else th.negative;
{
const fill_color = blendColor(trend_color, 30, bg);
ctx.setSourceToPixel(fill_color);
ctx.resetPath();
for (points, 0..) |p, i| {
const x = mapX(i, p, chart_left, chart_w, first_days, span_days, use_dates, index_step);
const y = mapY(p.value, value_min, value_max, chart_top, chart_bottom);
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
}
const last_x = mapX(points.len - 1, points[points.len - 1], chart_left, chart_w, first_days, span_days, use_dates, index_step);
try ctx.lineTo(last_x, chart_bottom);
try ctx.lineTo(chart_left, chart_bottom);
try ctx.closePath();
try ctx.fill();
}
// Zero reference line (only when the range straddles zero)
if (value_min < 0 and value_max > 0) {
const zero_y = mapY(0, value_min, value_max, chart_top, chart_bottom);
const zero_color = blendColor(th.text_muted, 100, bg);
try drawHLine(&ctx, chart_left, chart_right, zero_y, zero_color, 1.0);
}
// Value line (solid, trend-colored, on top)
{
ctx.setSourceToPixel(opaqueColor(trend_color));
ctx.setLineWidth(2.0);
ctx.resetPath();
for (points, 0..) |p, i| {
const x = mapX(i, p, chart_left, chart_w, first_days, span_days, use_dates, index_step);
const y = mapY(p.value, value_min, value_max, chart_top, chart_bottom);
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
}
try ctx.stroke();
}
// Panel border
{
const border_color = blendColor(th.border, 80, bg);
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0);
}
// Axis labels (export only; drawn directly into the buffer)
if (opts.axis_labels) {
axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, value_min, value_max, 5);
var fbuf: [12]u8 = undefined;
var lbuf: [12]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{points[0].date}) catch "";
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{points[points.len - 1].date}) catch "";
const date_y = chart_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
}
return .{
.surface = sfc,
.width = @intCast(width_px),
.height = @intCast(height_px),
.value_min = value_min,
.value_max = value_max,
};
}
/// Render a single-series line chart to raw RGB pixel data. The returned
/// `rgb_data` is allocated with `alloc` and must be freed by the caller.
pub fn renderLineChart(
io: std.Io,
alloc: std.mem.Allocator,
points: []const LinePoint,
width_px: u32,
height_px: u32,
th: theme.Theme,
opts: Options,
) !LineChartResult {
var rendered = try renderToSurface(io, alloc, points, width_px, height_px, th, opts);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
return .{
.rgb_data = raw,
.width = rendered.width,
.height = rendered.height,
.value_min = rendered.value_min,
.value_max = rendered.value_max,
};
}
// Drawing helpers
//
// The stateless primitives below are shared with the other chart
// renderers and live in `draw.zig`; aliased here so the call sites in
// this file stay unchanged.
const mapY = draw.mapY;
const blendColor = draw.blendColor;
const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawRect = draw.drawRect;
// Tests
const test_th = theme.default_theme;
fn pt(y: i16, m: u8, d: u8, v: f64) LinePoint {
return .{ .date = Date.fromYmd(y, m, d), .value = v };
}
test "renderToSurface returns InsufficientData with < 2 points" {
const alloc = std.testing.allocator;
const one = [_]LinePoint{pt(2026, 1, 1, 100)};
try std.testing.expectError(error.InsufficientData, renderToSurface(std.testing.io, alloc, &one, 200, 100, test_th, .{}));
const none = [_]LinePoint{};
try std.testing.expectError(error.InsufficientData, renderToSurface(std.testing.io, alloc, &none, 200, 100, test_th, .{}));
}
test "renderToSurface returns a populated RGB surface at requested dimensions" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120), pt(2026, 3, 1, 110) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{});
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 200), rendered.width);
try std.testing.expectEqual(@as(u16, 100), rendered.height);
switch (rendered.surface) {
.image_surface_rgb => {},
else => try std.testing.expect(false),
}
}
test "renderToSurface fills background with theme bg" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120) };
var th = theme.default_theme;
th.bg = .{ 0xab, 0xcd, 0xef };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 100, 50, th, .{});
defer rendered.deinit(alloc);
const buf = switch (rendered.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
// Pixel (0,0) is in the top-left margin - outside the plotted area.
try std.testing.expectEqual(@as(u8, 0xab), buf[0].r);
try std.testing.expectEqual(@as(u8, 0xcd), buf[0].g);
try std.testing.expectEqual(@as(u8, 0xef), buf[0].b);
}
test "renderToSurface fit baseline keeps value_min at or below the data minimum" {
const alloc = std.testing.allocator;
// All values well above zero; fit must NOT anchor at zero.
const points = [_]LinePoint{ pt(2026, 1, 1, 1_000_000), pt(2026, 2, 1, 1_050_000), pt(2026, 3, 1, 1_020_000) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .fit });
defer rendered.deinit(alloc);
try std.testing.expect(rendered.value_min <= 1_000_000);
try std.testing.expect(rendered.value_min > 0); // far from zero
try std.testing.expect(rendered.value_max >= 1_050_000);
}
test "renderToSurface zero baseline anchors value_min at zero for positive data" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 1_000_000), pt(2026, 2, 1, 1_050_000) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .zero });
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(f64, 0), rendered.value_min);
try std.testing.expect(rendered.value_max > 1_000_000);
}
test "renderToSurface zero baseline still includes a negative data minimum" {
const alloc = std.testing.allocator;
// Net worth dips below zero - zero baseline must not crop it.
const points = [_]LinePoint{ pt(2026, 1, 1, -50_000), pt(2026, 2, 1, 10_000) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .zero });
defer rendered.deinit(alloc);
try std.testing.expect(rendered.value_min <= -50_000);
}
test "renderToSurface is deterministic across calls with same input" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 140), pt(2026, 3, 1, 90) };
var a = try renderToSurface(std.testing.io, alloc, &points, 120, 60, test_th, .{});
defer a.deinit(alloc);
var b = try renderToSurface(std.testing.io, alloc, &points, 120, 60, test_th, .{});
defer b.deinit(alloc);
const buf_a = switch (a.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const buf_b = switch (b.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
try std.testing.expectEqual(buf_a.len, buf_b.len);
var i: usize = 0;
while (i < buf_a.len) : (i += 50) {
try std.testing.expectEqual(buf_a[i].r, buf_b[i].r);
try std.testing.expectEqual(buf_a[i].g, buf_b[i].g);
try std.testing.expectEqual(buf_a[i].b, buf_b[i].b);
}
}
test "renderToSurface handles degenerate single-date input without crashing" {
const alloc = std.testing.allocator;
// Both points share a date -> span is zero -> index-spacing fallback.
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 1, 1, 120) };
var rendered = try renderToSurface(std.testing.io, alloc, &points, 100, 50, test_th, .{});
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 100), rendered.width);
}
test "renderLineChart wraps renderToSurface and produces RGB triplets" {
const alloc = std.testing.allocator;
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120), pt(2026, 3, 1, 110) };
const result = try renderLineChart(std.testing.io, alloc, &points, 50, 40, test_th, .{});
defer alloc.free(result.rgb_data);
try std.testing.expectEqual(@as(u16, 50), result.width);
try std.testing.expectEqual(@as(u16, 40), result.height);
try std.testing.expectEqual(@as(usize, 50 * 40 * 3), result.rgb_data.len);
try std.testing.expect(result.value_max > result.value_min);
}

View file

@ -16,12 +16,13 @@
const std = @import("std");
const z2d = @import("z2d");
const theme = @import("theme.zig");
const theme = @import("../tui/theme.zig");
const projections = @import("../analytics/projections.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const Surface = z2d.Surface;
const Context = z2d.Context;
const Pixel = z2d.Pixel;
/// Margins in pixels.
const margin_left: f64 = 4;
@ -77,17 +78,7 @@ pub const RenderedProjection = struct {
/// Extract a flat []u8 of R,G,B triplets from the surface buffer.
/// Caller owns the returned slice. The surface is left intact.
pub fn extractRgb(self: *const RenderedProjection, alloc: std.mem.Allocator) ![]u8 {
const rgb_buf = switch (self.surface) {
.image_surface_rgb => |s| s.buf,
else => unreachable,
};
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
for (rgb_buf, 0..) |px, i| {
raw[i * 3 + 0] = px.r;
raw[i * 3 + 1] = px.g;
raw[i * 3 + 2] = px.b;
}
return raw;
return draw.extractRgb(alloc, &self.surface);
}
};
@ -107,6 +98,7 @@ pub fn renderToSurface(
height_px: u32,
th: theme.Theme,
actuals: ?ActualsOverlay,
axis_labels: bool,
) !RenderedProjection {
if (bands.len < 2) return error.InsufficientData;
@ -126,21 +118,21 @@ pub fn renderToSurface(
const fheight: f64 = @floatFromInt(height_px);
// Background
ctx.setSourceToPixel(opaqueColor(bg));
ctx.resetPath();
try ctx.moveTo(0, 0);
try ctx.lineTo(fwidth, 0);
try ctx.lineTo(fwidth, fheight);
try ctx.lineTo(0, fheight);
try ctx.closePath();
try ctx.fill();
try draw.fillBackground(&ctx, fwidth, fheight, bg);
// Chart area
const chart_left = margin_left;
const chart_right = fwidth - margin_right;
// Chart area. With axis labels we reserve a left margin (dollar
// ticks) and a bottom margin (year endpoints), scaled to the surface.
const label_scale: i32 = axis.labelScale(h);
const label_char_h: f64 = axis.charHeight(label_scale);
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
const chart_left = m_left;
const chart_right = fwidth - m_right;
const chart_w = chart_right - chart_left;
const chart_top = margin_top;
const chart_bottom = fheight - margin_bottom;
const chart_top = m_top;
const chart_bottom = fheight - m_bottom;
// Compute value range from all bands
var value_min: f64 = bands[0].p10;
@ -317,6 +309,17 @@ pub fn renderToSurface(
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0);
}
// Axis labels (export only)
if (axis_labels) {
axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, value_min, value_max, 5);
var fbuf: [8]u8 = undefined;
var lbuf: [8]u8 = undefined;
const first_s = std.fmt.bufPrint(&fbuf, "{d}", .{bands[0].year}) catch "";
const last_s = std.fmt.bufPrint(&lbuf, "{d}", .{bands[bands.len - 1].year}) catch "";
const yr_y = chart_bottom + axis.labelGap(label_scale);
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, yr_y, first_s, last_s);
}
return .{
.surface = sfc,
.width = @intCast(width_px),
@ -341,7 +344,7 @@ pub fn renderProjectionChart(
th: theme.Theme,
actuals: ?ActualsOverlay,
) !ProjectionChartResult {
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals);
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
return .{
@ -354,105 +357,21 @@ pub fn renderProjectionChart(
}
// Drawing helpers
//
// The stateless primitives below are shared with the other chart
// renderers and live in `draw.zig`; aliased here so the call sites in
// this file stay unchanged.
fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
if (max_val == min_val) return (top_px + bottom_px) / 2;
const norm = (value - min_val) / (max_val - min_val);
return bottom_px - norm * (bottom_px - top_px);
}
fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
const a = @as(f64, @floatFromInt(alpha)) / 255.0;
const inv_a = 1.0 - a;
return .{ .rgb = .{
.r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
.g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
.b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
} };
}
fn opaqueColor(c: [3]u8) Pixel {
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
}
fn drawHorizontalGridLines(
ctx: *Context,
left: f64,
right: f64,
top: f64,
bottom: f64,
n_lines: usize,
col: Pixel,
) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(0.5);
for (1..n_lines) |i| {
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
const y = top + frac * (bottom - top);
ctx.resetPath();
try ctx.moveTo(left, y);
try ctx.lineTo(right, y);
try ctx.stroke();
}
ctx.setLineWidth(2.0);
}
fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x1, y);
try ctx.lineTo(x2, y);
try ctx.stroke();
ctx.setLineWidth(2.0);
}
fn drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x, y1);
try ctx.lineTo(x, y2);
try ctx.stroke();
ctx.setLineWidth(2.0);
}
fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void {
ctx.setSourceToPixel(col);
ctx.setLineWidth(line_w);
ctx.resetPath();
try ctx.moveTo(x1, y1);
try ctx.lineTo(x2, y1);
try ctx.lineTo(x2, y2);
try ctx.lineTo(x1, y2);
try ctx.closePath();
try ctx.stroke();
ctx.setLineWidth(2.0);
}
const mapY = draw.mapY;
const blendColor = draw.blendColor;
const opaqueColor = draw.opaqueColor;
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
const drawHLine = draw.drawHLine;
const drawVLine = draw.drawVLine;
const drawRect = draw.drawRect;
// Tests
test "mapY maps value to pixel coordinate" {
try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
}
test "blendColor alpha blending" {
const white = [3]u8{ 255, 255, 255 };
const black = [3]u8{ 0, 0, 0 };
const full = blendColor(white, 255, black);
try std.testing.expectEqual(@as(u8, 255), full.rgb.r);
const zero = blendColor(white, 0, black);
try std.testing.expectEqual(@as(u8, 0), zero.rgb.r);
const half = blendColor(white, 128, black);
try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
}
test "renderProjectionChart produces valid output" {
const alloc = std.testing.allocator;
const bands = [_]projections.YearPercentiles{
@ -461,7 +380,7 @@ test "renderProjectionChart produces valid output" {
.{ .year = 20, .p10 = 3000000, .p25 = 9000000, .p50 = 18000000, .p75 = 30000000, .p90 = 50000000 },
};
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
defer alloc.free(result.rgb_data);
@ -477,7 +396,7 @@ test "renderProjectionChart insufficient data" {
.{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 },
};
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
try std.testing.expectError(error.InsufficientData, result);
}
@ -496,7 +415,7 @@ test "renderProjectionChart with overlay produces valid output" {
};
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
defer alloc.free(result.rgb_data);
@ -519,7 +438,7 @@ test "renderProjectionChart overlay expands y-range when actuals exceed bands" {
};
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
defer alloc.free(result.rgb_data);
@ -536,7 +455,7 @@ test "renderProjectionChart overlay with no points renders without crash" {
};
const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 };
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
defer alloc.free(result.rgb_data);
try std.testing.expect(result.rgb_data.len > 0);
@ -554,8 +473,8 @@ test "renderToSurface returns a populated RGB surface at requested dimensions" {
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null);
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false);
defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 150), rendered.width);
@ -572,10 +491,10 @@ test "renderToSurface fills background with theme bg" {
.{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
.{ .year = 1, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
};
var th = @import("theme.zig").default_theme;
var th = @import("../tui/theme.zig").default_theme;
th.bg = .{ 0xab, 0xcd, 0xef };
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null);
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false);
defer rendered.deinit(alloc);
const buf = switch (rendered.surface) {
@ -594,11 +513,11 @@ test "renderToSurface is deterministic across calls with same input" {
.{ .year = 5, .p10 = 90, .p25 = 110, .p50 = 130, .p75 = 160, .p90 = 200 },
.{ .year = 10, .p10 = 80, .p25 = 120, .p50 = 160, .p75 = 220, .p90 = 300 },
};
const th = @import("theme.zig").default_theme;
const th = @import("../tui/theme.zig").default_theme;
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
defer a.deinit(alloc);
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
defer b.deinit(alloc);
const buf_a = switch (a.surface) {
@ -624,8 +543,8 @@ test "RenderedProjection.extractRgb produces 3 bytes per pixel" {
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
};
const th = @import("theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null);
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false);
defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc);
@ -649,8 +568,8 @@ test "renderToSurface clamps value_min to zero when bands include negatives" {
.{ .year = 0, .p10 = -100, .p25 = -50, .p50 = 0, .p75 = 50, .p90 = 100 },
.{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
};
const th = @import("theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
const th = @import("../tui/theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
defer rendered.deinit(alloc);
// After 5% padding and the `if (value_min < 0) value_min = 0`

211
src/charts/text.zig Normal file
View file

@ -0,0 +1,211 @@
//! A tiny 5x7 bitmap font for drawing axis labels directly into a z2d
//! `image_surface_rgb` pixel buffer.
//!
//! Why a hand-rolled bitmap font instead of `z2d.text` + a TTF?
//! 1. The chart renderers in this directory deliberately draw with
//! anti-aliasing OFF and the `.src` operator, pre-blending colors
//! against the background to sidestep z2d's `src_over` compositor
//! overflow on semi-transparent fills. Glyph outline rasterization
//! needs alpha blending for its edges and would hit that same bug.
//! Solid 1-bit pixels avoid it entirely.
//! 2. It keeps a ~hundreds-of-KB TTF (and its license) out of the repo.
//!
//! The glyph set is intentionally minimal - just what axis labels need:
//! digits, `$`, `.`, `,`, `-`, and the `T`/`B`/`M` magnitude suffixes
//! emitted by `format.fmtLargeNum`, plus space. Unknown chars render blank.
//!
//! Coordinates are in surface pixels; `scale` multiplies the 5x7 cell
//! (so `scale = 3` renders 15x21 glyphs). Drawing is clipped to the
//! surface bounds. Callers own pixel layout (margins, alignment); this
//! module only stamps glyphs.
const std = @import("std");
const z2d = @import("z2d");
const draw = @import("draw.zig");
const Surface = z2d.Surface;
const RGB = z2d.pixel.RGB;
/// Glyph cell dimensions, in font pixels (pre-scale).
pub const glyph_w: i32 = 5;
pub const glyph_h: i32 = 7;
/// Horizontal advance per glyph including the 1px inter-glyph gap.
pub const advance: i32 = glyph_w + 1;
/// Each glyph is 7 rows; the low 5 bits of each row are the pixels,
/// bit 4 (0b10000) = leftmost column. Row 0 is the top.
const Glyph = [7]u8;
const blank: Glyph = .{ 0, 0, 0, 0, 0, 0, 0 };
const digits = [10]Glyph{
.{ 0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E }, // 0
.{ 0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E }, // 1
.{ 0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F }, // 2
.{ 0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E }, // 3
.{ 0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02 }, // 4
.{ 0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E }, // 5
.{ 0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E }, // 6
.{ 0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08 }, // 7
.{ 0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E }, // 8
.{ 0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C }, // 9
};
const glyph_dollar: Glyph = .{ 0x04, 0x0E, 0x14, 0x0E, 0x05, 0x0E, 0x04 };
const glyph_period: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06 };
const glyph_comma: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x08 };
const glyph_minus: Glyph = .{ 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00 };
const glyph_T: Glyph = .{ 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 };
const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E };
const glyph_M: Glyph = .{ 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11 };
/// Look up the bitmap for a character. Unknown characters (including
/// space) render blank.
fn glyphFor(ch: u8) Glyph {
return switch (ch) {
'0'...'9' => digits[ch - '0'],
'$' => glyph_dollar,
'.' => glyph_period,
',' => glyph_comma,
'-' => glyph_minus,
'T' => glyph_T,
'B' => glyph_B,
'M' => glyph_M,
else => blank,
};
}
/// Width in surface pixels that `drawText` will occupy for `text` at
/// `scale` (excludes the trailing inter-glyph gap). Zero for empty text.
/// Useful for right-aligning a label against a known x.
pub fn measureWidth(text: []const u8, scale: i32) i32 {
if (text.len == 0) return 0;
const n: i32 = @intCast(text.len);
// n full glyphs + (n-1) gaps = n*advance - 1, times scale.
return (n * advance - 1) * scale;
}
/// Stamp `text` onto the surface's RGB buffer with its top-left at
/// `(x, y)`, each font pixel drawn as a `scale` x `scale` solid block in
/// `color`. No-op for non-RGB surfaces. Pixels outside the surface are
/// clipped.
pub fn drawText(sfc: *Surface, x: i32, y: i32, scale: i32, color: [3]u8, text: []const u8) void {
if (scale <= 0) return;
const img = switch (sfc.*) {
.image_surface_rgb => |s| s,
else => return,
};
const w = img.width;
const h = img.height;
const px = RGB{ .r = color[0], .g = color[1], .b = color[2] };
var cursor_x = x;
for (text) |ch| {
const glyph = glyphFor(ch);
for (0..@intCast(glyph_h)) |gr| {
const bits = glyph[gr];
for (0..@intCast(glyph_w)) |gc| {
const mask: u8 = @as(u8, 1) << @intCast(glyph_w - 1 - @as(i32, @intCast(gc)));
if (bits & mask == 0) continue;
fillBlock(
img.buf,
w,
h,
cursor_x + @as(i32, @intCast(gc)) * scale,
y + @as(i32, @intCast(gr)) * scale,
scale,
px,
);
}
}
cursor_x += advance * scale;
}
}
/// Fill a `size` x `size` block of pixels at `(bx, by)`, clipped to the
/// `[0,w) x [0,h)` surface bounds.
fn fillBlock(buf: []RGB, w: i32, h: i32, bx: i32, by: i32, size: i32, px: RGB) void {
var dy: i32 = 0;
while (dy < size) : (dy += 1) {
const yy = by + dy;
if (yy < 0 or yy >= h) continue;
var dx: i32 = 0;
while (dx < size) : (dx += 1) {
const xx = bx + dx;
if (xx < 0 or xx >= w) continue;
buf[@intCast(yy * w + xx)] = px;
}
}
}
// Tests
const testing = std.testing;
test "measureWidth: empty is zero, scales with length" {
try testing.expectEqual(@as(i32, 0), measureWidth("", 3));
// One glyph = glyph_w * scale (no trailing gap).
try testing.expectEqual(@as(i32, glyph_w * 2), measureWidth("1", 2));
// Two glyphs = (2*advance - 1) * scale = (2*6 - 1)*2 = 22.
try testing.expectEqual(@as(i32, 22), measureWidth("12", 2));
}
test "drawText stamps glyph pixels in the requested color" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16);
defer sfc.deinit(alloc);
// Surface starts zeroed (black). Draw white text.
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, white));
drawText(&sfc, 1, 1, 1, white, "1");
// '1' has 10 set pixels in the 5x7 bitmap; at scale 1 that's 10 white px.
try testing.expectEqual(@as(usize, 10), draw.countColor(&sfc, white));
}
test "drawText scale multiplies the stamped pixel count" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 32);
defer sfc.deinit(alloc);
const c = [3]u8{ 0x10, 0x20, 0x30 };
drawText(&sfc, 0, 0, 2, c, "1");
// 10 set font-pixels, each a 2x2 block -> 10 * 4 = 40 colored px.
try testing.expectEqual(@as(usize, 40), draw.countColor(&sfc, c));
}
test "drawText clips out-of-bounds without writing past the buffer" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 8, 8);
defer sfc.deinit(alloc);
const c = [3]u8{ 0xAA, 0xBB, 0xCC };
// Draw partly off the right/bottom edge and fully off-screen; must
// not crash or panic (bounds-checked writes).
drawText(&sfc, 6, 6, 3, c, "8");
drawText(&sfc, -50, -50, 4, c, "8");
drawText(&sfc, 100, 100, 4, c, "8");
// Some pixels of the first (partly-visible) glyph may have landed.
try testing.expect(draw.countColor(&sfc, c) <= 8 * 9);
}
test "drawText ignores unknown glyphs (renders blank)" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16);
defer sfc.deinit(alloc);
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
// '?' and space are not in the set -> nothing drawn.
drawText(&sfc, 1, 1, 2, white, " ?");
try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, white));
}
test "drawText renders the comma glyph (so thousands separators show)" {
const alloc = testing.allocator;
var sfc = try Surface.init(.image_surface_rgb, alloc, 16, 16);
defer sfc.deinit(alloc);
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
drawText(&sfc, 1, 1, 1, white, ",");
// The comma bitmap (rows 0x06,0x06,0x08) has 5 set pixels.
try testing.expectEqual(@as(usize, 5), draw.countColor(&sfc, white));
}

View file

@ -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);

View file

@ -43,6 +43,8 @@
const std = @import("std");
const zfin = @import("../root.zig");
const validator = @import("../comptime_validator.zig");
const chart = @import("../charts/chart.zig");
const term_query = @import("../term_query.zig");
// Group taxonomy
@ -206,6 +208,9 @@ pub const Globals = struct {
watchlist_path: ?[]const u8 = null,
/// Cache-freshness policy from `--refresh-data=<value>`.
refresh_policy: RefreshPolicy = .auto,
/// Chart graphics mode from `--chart` (auto / braille / WxH). Default
/// is auto: kitty graphics when the terminal supports it, else braille.
chart_config: chart.ChartConfig = .{},
};
// RunCtx
@ -250,6 +255,10 @@ pub const RunCtx = struct {
now_s: i64,
color: bool,
out: *std.Io.Writer,
/// Detected inline-graphics capability (kitty) + cell size, captured
/// once at invocation entry. Commands consult this together with
/// `globals.chart_config` to choose kitty-graphics vs braille output.
graphics_caps: term_query.Caps = .{},
/// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
/// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME.

View file

@ -16,6 +16,10 @@
//! Defaults to auto: daily 90d, weekly 730d,
//! else monthly.
//! --limit <N> cap the "Recent snapshots" table to N rows
//! --export-chart <path> render the focused-metric timeline as a
//! PNG to <path> and exit (honors
//! --since / --until / --metric / --baseline)
//! --baseline <name> exported-chart y-axis: fit (default) | zero
//! --rebuild-rollup (re)write history/rollup.srf and exit
//!
//! Portfolio layout, top-to-bottom:
@ -38,6 +42,12 @@ const timeline = @import("../analytics/timeline.zig");
const history = @import("../history.zig");
const snapshot_model = @import("../models/snapshot.zig");
const view = @import("../views/history.zig");
const chart_export = @import("../chart_export.zig");
const line_chart = @import("../charts/line_chart.zig");
const chart = @import("../charts/chart.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
const fmt = cli.fmt;
const Date = @import("../Date.zig");
@ -69,7 +79,8 @@ pub const meta: framework.Meta = .{
\\shows the last 30 trading days of candles for that symbol.
\\Portfolio mode (no positional, optionally with flags) reads
\\`history/*-portfolio.srf` snapshots and renders rolling-windows
\\returns + a braille chart + a recent-snapshots table.
\\returns + a chart (an inline Kitty image when the terminal
\\supports it, braille otherwise) + a recent-snapshots table.
\\
\\Portfolio-mode flags:
\\ --since <DATE> earliest as-of date (inclusive)
@ -78,12 +89,14 @@ pub const meta: framework.Meta = .{
\\ --resolution <name> daily | weekly | monthly | auto
\\ (auto: daily ≤90d, weekly ≤730d, else monthly)
\\ --limit <N> cap recent-snapshots table to N rows (default 40)
\\ --export-chart <path> write the focused-metric timeline as a PNG and exit
\\ --baseline <name> exported-chart y-axis: fit (default) or zero
\\ --rebuild-rollup regenerate history/rollup.srf and exit
\\
\\DATE accepts YYYY-MM-DD or relative shortcuts (1W/1M/1Q/1Y).
\\
,
.user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution },
.user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution, UnknownBaseline },
};
pub const Error = error{
@ -92,6 +105,7 @@ pub const Error = error{
MissingFlagValue,
UnknownMetric,
UnknownResolution,
UnknownBaseline,
};
/// Parsed portfolio-mode options. Separated from `run` so the parser
@ -114,6 +128,15 @@ pub const PortfolioOpts = struct {
/// Max rows shown in the recent-snapshots table. Null means default (40).
limit: ?usize = null,
rebuild_rollup: bool = false,
/// When set, render the focused-metric timeline as a PNG to this
/// path and exit, instead of printing the normal timeline output.
/// Honors `--since` / `--until` / `--metric`.
export_chart: ?[]const u8 = null,
/// Y-axis baseline for the exported chart: `.fit` fits the data
/// range; `.zero` anchors the floor at zero (clamped to the data
/// minimum if the series itself dips negative). Only consulted when
/// `--export-chart` is given.
baseline: line_chart.Baseline = .fit,
};
/// Parse the arg list for portfolio-mode flags. Pure function - no IO.
@ -155,6 +178,14 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port
opts.limit = std.fmt.parseInt(usize, args[i], 10) catch return error.InvalidFlagValue;
} else if (std.mem.eql(u8, a, "--rebuild-rollup")) {
opts.rebuild_rollup = true;
} else if (std.mem.eql(u8, a, "--export-chart")) {
i += 1;
if (i >= args.len) return error.MissingFlagValue;
opts.export_chart = args[i];
} else if (std.mem.eql(u8, a, "--baseline")) {
i += 1;
if (i >= args.len) return error.MissingFlagValue;
opts.baseline = std.meta.stringToEnum(line_chart.Baseline, args[i]) orelse return error.UnknownBaseline;
} else {
return error.UnexpectedArg;
}
@ -177,6 +208,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
error.InvalidFlagValue => cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"),
error.UnknownMetric => cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
error.UnknownResolution => cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
error.UnknownBaseline => cli.stderrPrint(ctx.io, "Error: unknown --baseline. Valid: fit, zero.\n"),
}
return err;
};
@ -193,7 +225,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.portfolio => |opts| {
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(ctx.allocator);
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out);
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps);
},
}
}
@ -263,6 +295,8 @@ fn runPortfolio(
opts: PortfolioOpts,
color: bool,
out: *std.Io.Writer,
chart_config: chart.ChartConfig,
caps: term_query.Caps,
) !void {
var tl = try history.loadTimeline(io, allocator, portfolio_path);
defer tl.deinit();
@ -289,6 +323,26 @@ fn runPortfolio(
return;
}
// --export-chart short-circuits: render the focused-metric timeline
// as a PNG (honoring --since / --until / --metric / --baseline) and
// exit without printing the normal timeline output.
if (opts.export_chart) |path| {
exportMetricChart(io, allocator, filtered, opts.metric, opts.baseline, path) catch |err| switch (err) {
error.InsufficientData => {
cli.stderrPrint(io, "Error: need at least 2 snapshots in the selected range to render a chart.\n");
return;
},
else => {
cli.stderrPrint(io, "Error exporting chart: ");
cli.stderrPrint(io, @errorName(err));
cli.stderrPrint(io, "\n");
return;
},
};
try out.print("Chart exported to {s}\n", .{path});
return;
}
// Resolve the effective resolution:
// - explicit `--resolution daily/weekly/monthly/cascading` ->
// use as-is.
@ -301,7 +355,18 @@ fn runPortfolio(
timeline.selectResolution(filtered)
else
.cascading;
try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40);
// Resolve how to draw the inline chart: kitty graphics when the
// terminal supports it (or it's forced via `--chart <WxH>`), else
// braille. `--chart braille` always forces braille.
const k: KittyChart = .{ .io = io, .caps = caps, .baseline = opts.baseline };
const chart_render: ChartRender = switch (chart_config.mode) {
.braille => .braille,
.kitty => .{ .kitty = k },
.auto => if (caps.kitty) .{ .kitty = k } else .braille,
};
try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40, chart_render);
}
/// Regenerate `history/rollup.srf` from `snapshots`. Uses
@ -367,6 +432,7 @@ pub fn renderPortfolio(
resolution: timeline.Resolution,
resolution_override: ?timeline.Resolution,
row_limit: usize,
chart_render: ChartRender,
) !void {
try cli.printBold(out, color, "\nPortfolio Timeline: {s}\n", .{focus_metric.label()});
try out.print("========================================\n", .{});
@ -377,9 +443,9 @@ pub fn renderPortfolio(
defer ws.deinit();
try renderWindowsBlock(out, color, ws);
// Chart (synthetic candles from focused-metric values)
// Chart (inline kitty graphics or braille fallback)
try out.print("\n", .{});
try renderBrailleChart(allocator, out, color, points, focus_metric);
try renderTimelineChart(allocator, out, color, points, focus_metric, chart_render);
// Table
if (resolution == .cascading) {
@ -459,7 +525,103 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
}
}
fn renderBrailleChart(
/// Convert timeline `MetricPoint`s into the chart module's `LinePoint`
/// shape. Same fields, distinct type: the chart module deliberately
/// does not depend on `analytics/timeline.zig`, so the CLI does the
/// conversion at the boundary (mirrors how `projections` converts its
/// overlay points). Caller owns the returned slice.
fn metricLinePoints(allocator: std.mem.Allocator, series: []const timeline.MetricPoint) ![]line_chart.LinePoint {
const out = try allocator.alloc(line_chart.LinePoint, series.len);
for (series, 0..) |mp, i| out[i] = .{ .date = mp.date, .value = mp.value };
return out;
}
/// Render the focused-metric portfolio timeline to a PNG at `path`.
/// Extracts the metric series via `timeline.extractChartSeries` (the
/// single home for the "skip imported-only points for derived metrics"
/// rule), converts to `LinePoint`s, and hands off to
/// `chart_export.exportTimelineChart`. Propagates `error.InsufficientData`
/// when fewer than 2 points remain after extraction.
fn exportMetricChart(
io: std.Io,
allocator: std.mem.Allocator,
points: []const timeline.TimelinePoint,
metric: timeline.Metric,
baseline: line_chart.Baseline,
path: []const u8,
) !void {
const series = try timeline.extractChartSeries(allocator, points, metric);
defer allocator.free(series);
const lps = try metricLinePoints(allocator, series);
defer allocator.free(lps);
try chart_export.exportTimelineChart(io, allocator, lps, baseline, path);
}
/// How `renderPortfolio` draws the timeline chart.
const ChartRender = union(enum) {
/// Terminal-agnostic braille - the universal fallback.
braille,
/// Inline kitty graphics, for capable terminals (or forced via
/// `--chart <WxH>`).
kitty: KittyChart,
};
const KittyChart = struct {
io: std.Io,
caps: term_query.Caps,
baseline: line_chart.Baseline,
};
/// Draw the focused-metric timeline. Dispatches to inline kitty graphics
/// or braille; a kitty render that can't produce a bitmap (too few
/// points) falls back to braille rather than drawing nothing.
fn renderTimelineChart(
allocator: std.mem.Allocator,
out: *std.Io.Writer,
color: bool,
points: []const timeline.TimelinePoint,
metric: timeline.Metric,
render: ChartRender,
) !void {
switch (render) {
.braille => try renderBraille(allocator, out, color, points, metric),
.kitty => |k| emitTimelineKitty(allocator, out, points, metric, k) catch |err| switch (err) {
error.InsufficientData => try renderBraille(allocator, out, color, points, metric),
else => return err,
},
}
}
/// Render the timeline as a kitty-graphics line chart (labeled, sized to
/// `term_graphics.history_cols`) and emit it at the cursor, advancing
/// below it. Returns `error.InsufficientData` when there aren't enough
/// points to draw, so the caller can fall back to braille.
fn emitTimelineKitty(
allocator: std.mem.Allocator,
out: *std.Io.Writer,
points: []const timeline.TimelinePoint,
metric: timeline.Metric,
k: KittyChart,
) !void {
const series = try timeline.extractChartSeries(allocator, points, metric);
defer allocator.free(series);
if (series.len < 2) return error.InsufficientData;
const lps = try metricLinePoints(allocator, series);
defer allocator.free(lps);
const cols = term_graphics.history_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 line_chart.renderToSurface(k.io, allocator, lps, dims.width, dims.height, theme.default_theme, .{ .baseline = k.baseline, .axis_labels = 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);
}
fn renderBraille(
allocator: std.mem.Allocator,
out: *std.Io.Writer,
color: bool,
@ -468,37 +630,34 @@ fn renderBrailleChart(
) !void {
if (points.len < 2) return;
// Synthesize candles from the focused metric's value. For
// illiquid / net_worth, skip imported-only points so the
// line is visually absent in the imported-only range rather
// than hugging zero.
// Extract the focused-metric series via the shared
// `extractChartSeries` rule (skips imported-only points for the
// derived metrics so the line is visually absent in the
// imported-only range rather than hugging zero), then synthesize
// flat candles for the braille renderer.
const series = try timeline.extractChartSeries(allocator, points, metric);
defer allocator.free(series);
if (series.len < 2) return;
var candles_list: std.ArrayList(zfin.Candle) = .empty;
defer candles_list.deinit(allocator);
try candles_list.ensureTotalCapacity(allocator, points.len);
const skip_imported = (metric == .illiquid) or (metric == .net_worth);
for (points) |p| {
if (skip_imported and p.source == .imported) continue;
const v = switch (metric) {
.net_worth => p.net_worth,
.liquid => p.liquid,
.illiquid => p.illiquid,
};
try candles_list.append(allocator, .{
.date = p.as_of_date,
.open = v,
.high = v,
.low = v,
.close = v,
.adj_close = v,
try candles_list.ensureTotalCapacity(allocator, series.len);
for (series) |mp| {
candles_list.appendAssumeCapacity(.{
.date = mp.date,
.open = mp.value,
.high = mp.value,
.low = mp.value,
.close = mp.value,
.adj_close = mp.value,
.volume = 0,
});
}
const candles = candles_list.items;
if (candles.len < 2) return;
var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer chart.deinit(allocator);
try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED, false);
var braille_chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer braille_chart.deinit(allocator);
try fmt.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
}
fn renderTable(
@ -791,6 +950,29 @@ test "parsePortfolioOpts: unknown flag / value errors" {
try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--limit", "not-a-number" }));
}
test "parsePortfolioOpts: --export-chart captures the path, baseline defaults to fit" {
const args = [_][]const u8{ "--export-chart", "timeline.png" };
const o = try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &args);
try testing.expectEqualStrings("timeline.png", o.export_chart.?);
try testing.expectEqual(line_chart.Baseline.fit, o.baseline);
}
test "parsePortfolioOpts: --baseline parses fit and zero" {
const af = [_][]const u8{ "--baseline", "fit" };
try testing.expectEqual(line_chart.Baseline.fit, (try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &af)).baseline);
const az = [_][]const u8{ "--baseline", "zero" };
try testing.expectEqual(line_chart.Baseline.zero, (try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &az)).baseline);
}
test "parsePortfolioOpts: chart flag errors" {
// Unknown baseline value.
try testing.expectError(error.UnknownBaseline, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--baseline", "bogus" }));
// Missing flag values.
try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{"--export-chart"}));
try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{"--baseline"}));
}
// renderPortfolio (end-to-end)
fn makeTimelinePoint(y: i16, m: u8, d: u8, liq: f64, ill: f64, nw: f64) timeline.TimelinePoint {
@ -812,7 +994,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" {
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
makeTimelinePoint(2026, 4, 21, 800, 400, 1200),
};
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40, .braille);
const out = w.buffered();
// Header
@ -860,7 +1042,7 @@ test "renderPortfolio: auto resolution shows '(auto - <effective>)' label" {
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
};
// resolution_override = null -> auto. Effective is daily (span 90d).
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40, .braille);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null);
}
@ -872,7 +1054,7 @@ test "renderPortfolio: color mode emits ANSI" {
makeTimelinePoint(2026, 4, 17, 700, 300, 1000),
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
};
try renderPortfolio(testing.allocator, &w, true, &pts, .liquid, .daily, .daily, 40);
try renderPortfolio(testing.allocator, &w, true, &pts, .liquid, .daily, .daily, 40, .braille);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") != null);
}
@ -882,7 +1064,7 @@ test "renderPortfolio: single point renders without crashing" {
const pts = [_]timeline.TimelinePoint{
makeTimelinePoint(2026, 4, 17, 700, 300, 1000),
};
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40, .braille);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
// Chart requires >= 2 points; confirm no crash, table shows one row.
@ -901,7 +1083,7 @@ test "renderPortfolio: row_limit caps table rows" {
makeTimelinePoint(2026, 4, 20, 770, 370, 1140),
makeTimelinePoint(2026, 4, 21, 780, 380, 1160),
};
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 2);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 2, .braille);
const out = w.buffered();
// 5 snapshots total, 2 shown.
try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null);
@ -920,7 +1102,7 @@ test "renderPortfolio: monthly resolution labels the table accordingly" {
makeTimelinePoint(2026, 3, 31, 800, 400, 1200),
makeTimelinePoint(2026, 4, 21, 900, 500, 1400),
};
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .monthly, .monthly, 40);
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .monthly, .monthly, 40, .braille);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(monthly)") != null);
}
@ -952,6 +1134,62 @@ test "displaySymbol empty candles" {
try testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null);
}
// chart export
test "metricLinePoints converts MetricPoints preserving order and values" {
const series = [_]timeline.MetricPoint{
.{ .date = Date.fromYmd(2026, 1, 1), .value = 100 },
.{ .date = Date.fromYmd(2026, 2, 1), .value = 250 },
};
const lps = try metricLinePoints(testing.allocator, &series);
defer testing.allocator.free(lps);
try testing.expectEqual(@as(usize, 2), lps.len);
try testing.expect(lps[0].date.eql(Date.fromYmd(2026, 1, 1)));
try testing.expectEqual(@as(f64, 100), lps[0].value);
try testing.expect(lps[1].date.eql(Date.fromYmd(2026, 2, 1)));
try testing.expectEqual(@as(f64, 250), lps[1].value);
}
test "exportMetricChart writes a PNG for a multi-point timeline" {
const io = std.testing.io;
const alloc = testing.allocator;
const pts = [_]timeline.TimelinePoint{
makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000),
makeTimelinePoint(2026, 2, 1, 1_050_000, 210_000, 1_260_000),
makeTimelinePoint(2026, 3, 1, 1_030_000, 205_000, 1_235_000),
};
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const path = try std.fs.path.join(alloc, &.{ path_buf[0..dir_len], "history_timeline.png" });
defer alloc.free(path);
try exportMetricChart(io, alloc, &pts, .liquid, .fit, path);
var file = try tmp.dir.openFile(io, "history_timeline.png", .{});
defer file.close(io);
const size = (try file.stat(io)).size;
try testing.expect(size > 1024);
var magic: [8]u8 = undefined;
var reader = file.reader(io, &.{});
_ = try reader.interface.readSliceShort(&magic);
try testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
}
test "exportMetricChart returns InsufficientData with fewer than 2 points" {
const io = std.testing.io;
const alloc = testing.allocator;
// Single point -> extractChartSeries yields 1 point -> renderToSurface
// rejects it before any file is opened, so `path` is never written.
const pts = [_]timeline.TimelinePoint{
makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000),
};
try testing.expectError(error.InsufficientData, exportMetricChart(io, alloc, &pts, .liquid, .fit, "unused.png"));
}
// rebuildRollup
fn makeFixtureSnapshot(

View file

@ -22,6 +22,10 @@ const forecast = @import("../analytics/forecast_evaluation.zig");
const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
/// Tagged-union args for the four projection sub-modes. Mutually-
/// exclusive flag combos (--convergence with --vs, --real with
@ -279,6 +283,14 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// total. Snapshot-only as-of paths ignore it.
var live = try loadLiveData(ctx, today, color);
defer if (live) |*l| l.deinit(allocator);
// Inline kitty band chart when supported (or forced). There's
// no braille fallback for projections - non-kitty terminals
// keep the table-only output.
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
.braille => null,
.kitty => ctx.graphics_caps,
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
};
try runBands(
io,
allocator,
@ -295,6 +307,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
},
color,
out,
kitty_caps,
);
},
}
@ -541,6 +554,42 @@ pub fn anyImportedOnly(
return now_res.source == .imported;
}
/// Render the percentile-band chart (longest horizon, with the actuals
/// overlay when present) as kitty graphics at `term_graphics.projection_cols`
/// wide and emit it inline. Returns `error.InsufficientData` when bands
/// aren't available so the caller can skip the chart - projections has
/// no braille fallback. All allocations come from the arena `va`.
fn emitBandsKitty(
io: std.Io,
va: std.mem.Allocator,
ctx: *const view.ProjectionContext,
caps: term_query.Caps,
out: *std.Io.Writer,
) !void {
const horizons = ctx.config.getHorizons();
if (horizons.len == 0) return error.InsufficientData;
const bands_ec = ctx.data.bands[horizons.len - 1] orelse return error.InsufficientData;
// Translate the view-layer overlay (if any) into the chart module's
// ActualsPoint shape - same conversion as the PNG export path. The
// arena owns the buffer; it lives as long as `overlay_input`.
const overlay_input = blk: {
const ov = ctx.overlay_actuals orelse break :blk @as(?projection_chart.ActualsOverlay, null);
const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk @as(?projection_chart.ActualsOverlay, null);
for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
break :blk projection_chart.ActualsOverlay{ .points = buf, .today_years = ov.today_years };
};
const cols = term_graphics.projection_cols;
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
var rendered = try projection_chart.renderToSurface(io, va, bands_ec, dims.width, dims.height, theme.default_theme, overlay_input, true);
defer rendered.deinit(va);
const rgb = try rendered.extractRgb(va);
try term_graphics.placeInline(out, va, rgb, dims.width, dims.height, cols, rows);
}
pub fn runBands(
io: std.Io,
allocator: std.mem.Allocator,
@ -549,6 +598,7 @@ pub fn runBands(
opts: BandsOptions,
color: bool,
out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
) !void {
// Single arena for all view/render allocations. Same lifetime
// regardless of live vs. as-of path.
@ -665,16 +715,16 @@ pub fn runBands(
// Translate the view-layer overlay points (if any) into the
// chart-module's ActualsPoint shape. Same conversion the TUI
// does in `projections_tab.drawWithKittyChart`.
var overlay_buf: ?[]@import("../tui/projection_chart.zig").ActualsPoint = null;
var overlay_buf: ?[]@import("../charts/projection_chart.zig").ActualsPoint = null;
defer if (overlay_buf) |ob| va.free(ob);
const overlay_input = blk: {
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
const buf = va.alloc(@import("../tui/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
const buf = va.alloc(@import("../charts/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
for (ov.points, 0..) |p, i| {
buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
}
overlay_buf = buf;
break :blk @import("../tui/projection_chart.zig").ActualsOverlay{
break :blk @import("../charts/projection_chart.zig").ActualsOverlay{
.points = buf,
.today_years = ov.today_years,
};
@ -709,6 +759,17 @@ pub fn runBands(
}
try out.print("========================================\n", .{});
// Headline percentile-band chart, inline via kitty graphics when
// supported (or forced). No braille fallback - non-kitty terminals
// keep the table-only view below.
if (kitty_caps) |kc| {
try out.print("\n", .{});
emitBandsKitty(io, va, &ctx, kc, out) catch |err| switch (err) {
error.InsufficientData => {}, // no bands yet; fall through to the table
else => return err,
};
}
// If auto-snapped, print a muted note so the user knows the
// requested date wasn't an exact hit. The wording reflects the
// resolution source - "nearest snapshot" vs "nearest imported
@ -1902,7 +1963,7 @@ test "runBands: imported-only as_of scales today's composition and renders body"
.today = today,
.overlay_actuals = false,
.live = &ld,
}, false, &stream);
}, false, &stream, null);
const out = stream.buffered();
// Header reflects the imported source, and the caveat explains
@ -1936,7 +1997,7 @@ test "runBands: imported-only as_of without live data returns cleanly" {
.today = Date.fromYmd(2026, 3, 13),
.overlay_actuals = false,
.live = null,
}, false, &stream);
}, false, &stream, null);
// The helper printed a clear stderr message (swallowed by
// cli.stderrPrint) and returned without body output.
@ -2129,7 +2190,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" {
var stream = std.Io.Writer.fixed(&buf);
const d = Date.fromYmd(2026, 3, 13);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream, null);
// No body output because the resolution failed - the stderr
// message is swallowed by `cli.stderrPrint` and doesn't land in
@ -2161,7 +2222,7 @@ test "run: as_of with matching snapshot produces body output" {
var buf: [32_768]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream, null);
const out = stream.buffered();
// Header should call out the as-of date explicitly.
@ -2192,7 +2253,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" {
var stream = std.Io.Writer.fixed(&buf);
const requested = Date.fromYmd(2026, 3, 13);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = false }, false, &stream);
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = false }, false, &stream, null);
const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);

View file

@ -5,7 +5,10 @@ const framework = @import("framework.zig");
const fmt = cli.fmt;
const Money = @import("../Money.zig");
const chart_export = @import("../chart_export.zig");
const tui_chart = @import("../tui/chart.zig");
const tui_chart = @import("../charts/chart.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
pub const ParsedArgs = struct {
symbol: []const u8,
@ -14,6 +17,14 @@ 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,
/// 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 = .{
@ -22,17 +33,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 braille price chart of the last 60 candles and a table
\\of the last 20 trading days.
\\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. 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
@ -42,10 +61,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).
@ -62,12 +82,18 @@ 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 since_raw: ?[]const u8 = 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;
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
@ -90,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 };
return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since, .since_raw = since_raw };
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
@ -110,19 +136,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 = blk: {
const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" };
for (candidates) |c| {
if (candles.len >= c.tradingDays()) break :blk c;
}
break :blk .@"6M"; // fallback; renderToSurface enforces >= 20 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;
@ -179,7 +201,23 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
} else |_| {}
}
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out);
const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps };
const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) {
.braille => .braille,
.kitty => .{ .kitty = k },
.auto => if (ctx.graphics_caps.kitty) .{ .kitty = k } else .braille,
};
// 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
@ -220,7 +258,52 @@ fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.Classifica
return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null;
}
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) !void {
/// How `display` draws the price chart.
const ChartRender = union(enum) {
/// Braille price line - the universal fallback.
braille,
/// Inline kitty graphics (price + Bollinger + volume + RSI).
kitty: KittyChart,
};
const KittyChart = struct {
io: std.Io,
caps: term_query.Caps,
};
/// 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 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, 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, 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
@ -258,23 +341,33 @@ 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) });
}
}
// Braille chart (60 columns, 10 rows)
// Chart: inline kitty graphics when supported, else a braille price
// chart over the selected window (`display_count` recent candles).
if (candles.len >= 2) {
try out.print("\n", .{});
const chart_days: usize = @min(candles.len, 60);
const chart_data = candles[candles.len - chart_days ..];
var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
if (chart) |*ch| {
defer ch.deinit(allocator);
try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED, false);
switch (chart_render) {
.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,
},
}
}
@ -352,6 +445,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);
@ -359,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);
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);
@ -367,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);
@ -380,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);
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);
@ -394,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);
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);
@ -406,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);
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);
@ -418,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);
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);
}

View file

@ -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{

View file

@ -3,6 +3,8 @@ const zfin = @import("root.zig");
const tui = @import("tui.zig");
const cli = @import("commands/common.zig");
const cmd_framework = @import("commands/framework.zig");
const chart = @import("charts/chart.zig");
const term_query = @import("term_query.zig");
/// Comptime registry of CLI commands. Field name is the user-facing
/// subcommand name; value is the imported module struct. Order
@ -86,12 +88,15 @@ const usage_footer =
\\ the same directory as the first resolved
\\ portfolio file.
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\ --chart <MODE> Inline chart graphics for history / quote /
\\ projections: auto (Kitty image when the
\\ terminal supports it, else braille),
\\ braille, or WxH (Kitty at that resolution)
\\
\\Interactive command options:
\\ -s, --symbol <SYMBOL> Pre-load a symbol and open on the
\\ Quote tab. Without this flag, the TUI
\\ opens on the Portfolio tab.
\\ --chart <MODE> Chart graphics: auto, braille, or WxH
\\ --default-keys Print default keybindings
\\ --default-theme Print default theme
\\
@ -159,6 +164,8 @@ const Globals = struct {
/// Default: `.auto` (TTL-respecting). Other values: `.force`
/// (re-fetch regardless of TTL) and `.never` (offline mode).
refresh_policy: cmd_framework.RefreshPolicy = .auto,
/// Chart graphics mode from `--chart` (auto / braille / WxH).
chart_config: chart.ChartConfig = .{},
/// Index into args of the first post-global token (the subcommand).
cursor: usize,
};
@ -168,6 +175,8 @@ const GlobalParseError = error{
UnknownGlobalFlag,
/// `--refresh-data=<value>` got something other than auto/force/never.
InvalidRefreshDataValue,
/// `--chart=<value>` got something other than auto/braille/WxH.
InvalidChartValue,
/// Multiple `.srf` files appeared as a single -p argument, almost
/// certainly because the shell expanded an unquoted glob. We
/// surface this as a dedicated error so the user gets a friendly
@ -284,6 +293,12 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
// space). Surface the shape mismatch explicitly.
return error.MissingValue;
}
if (std.mem.eql(u8, a, "--chart")) {
if (i + 1 >= args.len) return error.MissingValue;
g.chart_config = chart.ChartConfig.parse(args[i + 1]) orelse return error.InvalidChartValue;
i += 2;
continue;
}
// Help flags are subcommand-like tokens, stop scanning.
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
@ -355,6 +370,7 @@ fn runCli(init: std.process.Init) !u8 {
cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
},
error.InvalidRefreshDataValue => cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
error.InvalidChartValue => cli.stderrPrint(io, "Error: --chart requires one of: auto, braille, or WxH (e.g. 1920x1080).\n"),
error.UnquotedGlobLikely => {
cli.stderrPrint(io,
\\Error: -p was given a single value followed by additional .srf files.
@ -499,11 +515,13 @@ fn runCli(init: std.process.Init) !u8 {
.portfolio_patterns = globals.portfolio_patterns,
.watchlist_path = globals.watchlist_path,
.refresh_policy = globals.refresh_policy,
.chart_config = globals.chart_config,
},
.today = today,
.now_s = now_s,
.color = color,
.out = out,
.graphics_caps = term_query.detect(io, init.environ_map),
};
const dispatched_args = if (comptime Module.meta.uppercase_first_arg)
try cmd_framework.normalizeFirstArg(allocator, cmd_args)

View file

@ -26,6 +26,8 @@ const std = @import("std");
const zeit = @import("zeit");
const Date = @import("Date.zig");
const log = std.log.scoped(.market);
/// POSIX TZ spec for US Eastern: EST (UTC-5) / EDT (UTC-4) with the
/// current US DST rule (2nd Sunday March -> 1st Sunday November).
const eastern_posix_spec = "EST5EDT,M3.2.0,M11.1.0";
@ -351,6 +353,137 @@ fn etLocalToUtc(d: Date, tod_s: i64) i64 {
return utc;
}
// Wall-clock display + intraday session
/// Re-export so callers can name the resolved-zone type without
/// importing zeit directly - the zeit dependency stays an
/// implementation detail behind this module.
pub const TimeZone = zeit.TimeZone;
/// Resolve the user's local timezone, for rendering user-action
/// timestamps (e.g. the TUI "refreshed at" stamp). Honors a `$TZ`
/// override when `tz_override` is a non-empty string (a POSIX spec,
/// `:/abs/path`, or `:Area/City`); otherwise reads the system default
/// (`/etc/localtime`). Does I/O and may allocate - call once at the
/// top of a unit of work (App init) and thread the result as a value.
/// The returned zone owns memory when it resolves from tzinfo; call
/// `.deinit()` on it at teardown (a no-op for the Eastern fallback,
/// which borrows a static spec).
///
/// Lifetime note: when `tz_override` is a POSIX spec, the returned
/// zone *borrows* `tz_override`, so the caller must keep it alive for
/// the zone's lifetime. Passing `config.environ_map.get("TZ")`
/// satisfies this - the environ map is owned by main and outlives the
/// App.
///
/// On failure (no zoneinfo on the system, unparseable tz, etc.) we
/// fall back to US Eastern - the app's market zone - so a stamp still
/// renders rather than the whole readout failing.
pub fn localTimeZone(alloc: std.mem.Allocator, io: std.Io, tz_override: ?[]const u8) TimeZone {
// An empty $TZ ("TZ=") is not a valid spec for zeit (it asserts
// non-empty); treat it as unset and fall through to the system
// default.
const tz: ?[]const u8 = if (tz_override) |t| (if (t.len > 0) t else null) else null;
return zeit.local(alloc, io, .{ .tz = tz }) catch |err| {
log.debug("local timezone resolve failed ({t}); falling back to ET", .{err});
return eastern;
};
}
/// 12-hour clock components decomposed from a count of seconds since
/// local midnight (`tod`, expected in [0, 86400)).
const Clock12 = struct {
hour: u8, // 1..12
minute: u8, // 0..59
am: bool,
fn ampm(self: Clock12) []const u8 {
return if (self.am) "AM" else "PM";
}
};
fn clock12FromTod(tod: i64) Clock12 {
const hour24 = @divFloor(tod, std.time.s_per_hour);
// tod is non-negative, so hour/minute are in range; cast to
// unsigned so `{d:0>2}` doesn't emit a sign placeholder.
const minute: u8 = @intCast(@divFloor(@mod(tod, std.time.s_per_hour), std.time.s_per_min));
const h: u8 = @intCast(@mod(hour24, 12));
return .{ .hour = if (h == 0) 12 else h, .minute = minute, .am = hour24 < 12 };
}
/// Render a Unix-seconds instant as a US Eastern wall-clock time,
/// 12-hour with an "ET" suffix: "2:34 PM ET", "9:05 AM ET",
/// "12:00 PM ET" (noon), "12:00 AM ET" (midnight). Writes into `buf`
/// (needs >= 11 bytes) and returns the slice.
///
/// Uses the same hardcoded Eastern TZ as the rest of this module, so
/// the label is always "ET" (market time) regardless of the caller's
/// locale - the honest stamp for market data. Pure given `unix_s`.
pub fn fmtClockET(buf: []u8, unix_s: i64) []const u8 {
const local = eastern.adjust(unix_s).timestamp;
const d = Date.fromEpoch(local);
const c = clock12FromTod(local - d.toEpoch());
return std.fmt.bufPrint(buf, "{d}:{d:0>2} {s} ET", .{ c.hour, c.minute, c.ampm() }) catch "?";
}
/// Render a "refreshed at" wall-clock stamp for instant `at_s` in
/// timezone `tz`, using the zone's own abbreviation (e.g. "PST",
/// "EDT"). When `at_s` falls on the same local calendar day as
/// `now_s`, only the time is shown ("2:34 PM PST"); otherwise a short
/// date is prefixed ("Jun 25, 2:34 PM PST") so a stamp left on an
/// idle screen overnight stays unambiguous. Writes into `buf` (needs
/// >= 24 bytes) and returns the slice.
///
/// Unlike `fmtClockET`, this renders in the caller-supplied zone
/// (typically the user's local zone from `localTimeZone`) because a
/// "when did I refresh" readout is about the user's wall clock, not
/// market time.
pub fn fmtStamp(buf: []u8, tz: TimeZone, at_s: i64, now_s: i64) []const u8 {
const at = tz.adjust(at_s);
const d = Date.fromEpoch(at.timestamp);
const c = clock12FromTod(at.timestamp - d.toEpoch());
const now_day = Date.fromEpoch(tz.adjust(now_s).timestamp);
if (d.eql(now_day)) {
return std.fmt.bufPrint(buf, "{d}:{d:0>2} {s} {s}", .{ c.hour, c.minute, c.ampm(), at.designation }) catch "?";
}
return std.fmt.bufPrint(buf, "{s} {d}, {d}:{d:0>2} {s} {s}", .{
Date.monthShort(d.month()), d.day(), c.hour, c.minute, c.ampm(), at.designation,
}) catch "?";
}
/// The US equity market session at a given instant.
pub const MarketSession = enum {
/// Regular trading hours: 09:30-16:00 ET on a trading day.
open,
/// Before the open on a trading day (00:00-09:30 ET).
premarket,
/// At/after the close on a trading day (16:00-24:00 ET).
afterhours,
/// Not a trading day at all - weekend or modeled holiday.
closed,
};
/// Regular trading-session bounds (seconds since ET-local midnight):
/// 09:30 open, 16:00 close. Distinct from the freshness `*_target_s`
/// boundaries above, which sit after the close for cache timing.
const regular_open_s: i64 = 9 * std.time.s_per_hour + 30 * std.time.s_per_min;
const regular_close_s: i64 = 16 * std.time.s_per_hour;
/// Classify the US equity market session at `now_s`. Pure given
/// `now_s` (the wall clock is read by the caller and threaded in),
/// so it is fully deterministic for tests. Holidays follow the same
/// modeled-only policy as `isHoliday` (an un-modeled closure like
/// Good Friday reports `.open`).
pub fn marketSession(now_s: i64) MarketSession {
const local = eastern.adjust(now_s).timestamp;
const today_et = Date.fromEpoch(local);
if (!isTradingDay(today_et)) return .closed;
const tod = local - today_et.toEpoch();
if (tod < regular_open_s) return .premarket;
if (tod >= regular_close_s) return .afterhours;
return .open;
}
// Tests
const testing = std.testing;
@ -603,3 +736,56 @@ test "candleFreshness mutual_fund: late NAV is lagging" {
// Cache already has Monday -> nothing newer is due -> current.
try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .mutual_fund, Date.fromYmd(2025, 6, 16)));
}
test "fmtClockET: 12-hour rendering with EST/EDT and AM/PM" {
var buf: [16]u8 = undefined;
// Afternoon (EST, winter): 14:34 ET.
try testing.expectEqualStrings("2:34 PM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 1, 15), 14 * std.time.s_per_hour + 34 * std.time.s_per_min)));
// Morning (EDT, summer): 09:05 ET, minute zero-padded.
try testing.expectEqualStrings("9:05 AM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 7, 15), 9 * std.time.s_per_hour + 5 * std.time.s_per_min)));
// Noon and midnight are the 12-hour edge cases.
try testing.expectEqualStrings("12:00 PM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 7, 15), 12 * std.time.s_per_hour)));
try testing.expectEqualStrings("12:00 AM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 7, 15), 0)));
}
test "marketSession: trading-day hours, pre/after, weekend, holiday" {
const wed = Date.fromYmd(2025, 6, 11); // a regular trading Wednesday
// 10:00 ET -> open.
try testing.expectEqual(MarketSession.open, marketSession(etLocalToUtc(wed, 10 * std.time.s_per_hour)));
// 09:30 ET exactly -> open (inclusive lower bound).
try testing.expectEqual(MarketSession.open, marketSession(etLocalToUtc(wed, regular_open_s)));
// 09:29 ET -> premarket.
try testing.expectEqual(MarketSession.premarket, marketSession(etLocalToUtc(wed, regular_open_s - std.time.s_per_min)));
// 16:00 ET exactly -> afterhours (inclusive upper bound).
try testing.expectEqual(MarketSession.afterhours, marketSession(etLocalToUtc(wed, regular_close_s)));
// Saturday -> closed regardless of clock.
try testing.expectEqual(MarketSession.closed, marketSession(etLocalToUtc(Date.fromYmd(2025, 6, 14), 12 * std.time.s_per_hour)));
// Thanksgiving 2025-11-27 -> closed (modeled holiday).
try testing.expectEqual(MarketSession.closed, marketSession(etLocalToUtc(Date.fromYmd(2025, 11, 27), 12 * std.time.s_per_hour)));
}
test "fmtStamp: time-only same local day, date-prefixed across days" {
var buf: [32]u8 = undefined;
// Tested against the Eastern zone so the designation is deterministic.
const at = etLocalToUtc(Date.fromYmd(2025, 1, 15), 14 * std.time.s_per_hour + 34 * std.time.s_per_min);
// Same local day as `at` -> time + zone abbreviation only.
const same_day_now = etLocalToUtc(Date.fromYmd(2025, 1, 15), 18 * std.time.s_per_hour);
try testing.expectEqualStrings("2:34 PM EST", fmtStamp(&buf, eastern, at, same_day_now));
// `now` is the following day -> short date prefix keeps an
// overnight-idle stamp unambiguous.
const next_day_now = etLocalToUtc(Date.fromYmd(2025, 1, 16), 9 * std.time.s_per_hour);
try testing.expectEqualStrings("Jan 15, 2:34 PM EST", fmtStamp(&buf, eastern, at, next_day_now));
}
test "localTimeZone: honors a POSIX $TZ override" {
// A POSIX TZ spec resolves without touching the filesystem (zeit
// parses it directly), so this exercises the $TZ path
// deterministically. Pacific in January -> PST.
const tz = localTimeZone(testing.allocator, testing.io, "PST8PDT,M3.2.0,M11.1.0");
defer tz.deinit();
var buf: [32]u8 = undefined;
// 2025-01-15 20:00 UTC == 12:00 PST. Same instant for `now`, so the
// stamp is time-only and must carry the override zone's "PST", not ET.
const at = utcSeconds(2025, 1, 15, 20, 0);
try testing.expectEqualStrings("12:00 PM PST", fmtStamp(&buf, tz, at, at));
}

190
src/term_graphics.zig Normal file
View file

@ -0,0 +1,190 @@
//! Kitty graphics protocol emission for the plain CLI commands.
//!
//! The TUI renders bitmap charts through vaxis, which speaks the kitty
//! graphics protocol for us. The non-interactive CLI (`zfin history`,
//! `quote`, `projections`) has no vaxis loop, so it emits the protocol
//! directly: this module turns a raw RGB buffer into the APC escape
//! sequence that a kitty-capable terminal renders inline at the cursor.
//!
//! It also centralizes the inline-chart sizing: the on-screen size is
//! expressed in terminal *columns* (the headline projections/quote
//! charts are wider than the history timeline), and the pixel render
//! size is derived from the terminal's cell pixel dimensions.
const std = @import("std");
/// Default cell pixel size assumed when the terminal hasn't reported one.
/// Kitty scales the transmitted image to the requested `c`=cols,`r`=rows
/// footprint, so an imperfect assumption only affects render crispness,
/// not on-screen layout.
pub const default_cell_w: u32 = 8;
pub const default_cell_h: u32 = 16;
/// Inline-chart target widths in terminal columns. The headline charts
/// (projections, quote) are wider than the history timeline; tuned to
/// roughly match-and-exceed the braille chart's on-screen footprint.
pub const history_cols: u16 = 80;
pub const projection_cols: u16 = 120;
pub const quote_cols: u16 = 120;
/// Maximum base64 payload bytes per APC escape, per the kitty protocol.
const chunk_max: usize = 4096;
pub const PixelDims = struct { width: u32, height: u32 };
/// Pixel dimensions for a chart occupying `cols` x `rows` cells.
pub fn pixelDims(cols: u16, rows: u16, cell_w: u32, cell_h: u32) PixelDims {
return .{ .width = @as(u32, cols) * cell_w, .height = @as(u32, rows) * cell_h };
}
/// Rows for a chart `cols` wide that preserve a ~3:1 width:height pixel
/// aspect (matching the braille chart's proportions), given the cell
/// pixel size. Always at least 1.
pub fn rowsForWidth(cols: u16, cell_w: u32, cell_h: u32) u16 {
const width_px = @as(u32, cols) * cell_w;
const height_px = width_px / 3;
const rows = (height_px + cell_h - 1) / cell_h; // round up
return @intCast(@max(rows, @as(u32, 1)));
}
/// Emit `rgb` (length must be `width_px * height_px * 3`) as a kitty
/// graphics image displayed at the cursor, occupying `cols` x `rows`
/// terminal cells. The payload is base64-encoded and split across as
/// many APC escapes as needed (<= 4096 base64 bytes each). `q=2`
/// suppresses the terminal's OK/error replies since the CLI isn't
/// reading them back; `C=1` keeps the terminal from moving the cursor
/// when the image is placed.
///
/// Because of `C=1` the cursor stays at the image's top-left, so callers
/// MUST print `rows` newlines afterward to advance below the chart. This
/// makes the vertical advance deterministic across terminals (rather
/// than relying on each terminal's default cursor-movement policy).
pub fn emitKittyRGB(
writer: *std.Io.Writer,
alloc: std.mem.Allocator,
rgb: []const u8,
width_px: u32,
height_px: u32,
cols: u16,
rows: u16,
) !void {
const Encoder = std.base64.standard.Encoder;
const b64 = try alloc.alloc(u8, Encoder.calcSize(rgb.len));
defer alloc.free(b64);
_ = Encoder.encode(b64, rgb);
var off: usize = 0;
var first = true;
while (true) {
const end = @min(off + chunk_max, b64.len);
const more = end < b64.len;
try writer.writeAll("\x1b_G");
if (first) {
// First escape carries the image metadata + control keys.
try writer.print(
"a=T,q=2,C=1,f=24,s={d},v={d},c={d},r={d},m={d}",
.{ width_px, height_px, cols, rows, @intFromBool(more) },
);
first = false;
} else {
try writer.print("m={d}", .{@intFromBool(more)});
}
try writer.writeAll(";");
try writer.writeAll(b64[off..end]);
try writer.writeAll("\x1b\\");
off = end;
if (!more) break;
}
}
/// Emit `rgb` as a kitty image (see `emitKittyRGB`), then advance the
/// cursor `rows` lines so following output lands directly below it.
/// Centralizes the pairing of the `C=1` (no cursor move) emission with
/// its matching vertical advance - callers should use this rather than
/// emitting + advancing by hand.
pub fn placeInline(
writer: *std.Io.Writer,
alloc: std.mem.Allocator,
rgb: []const u8,
width_px: u32,
height_px: u32,
cols: u16,
rows: u16,
) !void {
try emitKittyRGB(writer, alloc, rgb, width_px, height_px, cols, rows);
var i: u16 = 0;
while (i < rows) : (i += 1) try writer.writeByte('\n');
}
// Tests
const testing = std.testing;
test "pixelDims multiplies cells by cell size" {
const d = pixelDims(80, 14, 8, 16);
try testing.expectEqual(@as(u32, 640), d.width);
try testing.expectEqual(@as(u32, 224), d.height);
}
test "rowsForWidth keeps a ~3:1 pixel aspect" {
// 8x16 cells: rows ~= cols/6, rounded up.
try testing.expectEqual(@as(u16, 14), rowsForWidth(80, 8, 16)); // 640/3/16 = 13.3 -> 14
try testing.expectEqual(@as(u16, 20), rowsForWidth(120, 8, 16)); // 960/3/16 = 20
// Never zero, even for a tiny width.
try testing.expect(rowsForWidth(1, 8, 16) >= 1);
}
test "emitKittyRGB: single chunk carries control keys + decodable payload" {
const alloc = testing.allocator;
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
const rgb = [_]u8{ 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33 }; // 2x1 RGB
try emitKittyRGB(&aw.writer, alloc, &rgb, 2, 1, 4, 2);
const out = aw.written();
try testing.expect(std.mem.startsWith(u8, out, "\x1b_G"));
try testing.expect(std.mem.endsWith(u8, out, "\x1b\\"));
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,f=24,s=2,v=1,c=4,r=2,m=0") != null);
// Exactly one APC escape (one terminator).
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\x1b\\"));
// Payload (between ';' and the ST) round-trips back to the input.
const semi = std.mem.indexOfScalar(u8, out, ';').?;
const payload = out[semi + 1 .. out.len - 2];
var decoded: [6]u8 = undefined;
try std.base64.standard.Decoder.decode(&decoded, payload);
try testing.expectEqualSlices(u8, &rgb, &decoded);
}
test "emitKittyRGB: large payload is chunked with m=1 then a final m=0" {
const alloc = testing.allocator;
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
// 64x64 RGB = 12288 bytes -> base64 16384 bytes -> exactly 4 chunks.
const rgb = try alloc.alloc(u8, 64 * 64 * 3);
defer alloc.free(rgb);
@memset(rgb, 0x7F);
try emitKittyRGB(&aw.writer, alloc, rgb, 64, 64, 10, 5);
const out = aw.written();
try testing.expectEqual(@as(usize, 4), std.mem.count(u8, out, "\x1b_G"));
// First escape: control keys + m=1 (more chunks follow).
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,f=24,s=64,v=64,c=10,r=5,m=1") != null);
// A continuation escape with no control keys, and the final m=0.
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=1;") != null);
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=0;") != null);
}
test "placeInline appends exactly `rows` newlines after the image" {
const alloc = testing.allocator;
var aw: std.Io.Writer.Allocating = .init(alloc);
defer aw.deinit();
const rgb = [_]u8{ 0, 0, 0, 0, 0, 0 }; // 2x1, base64 has no newlines
try placeInline(&aw.writer, alloc, &rgb, 2, 1, 4, 3);
const out = aw.written();
try testing.expect(std.mem.endsWith(u8, out, "\x1b\\\n\n\n"));
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, out, "\n"));
}

95
src/term_query.zig Normal file
View file

@ -0,0 +1,95 @@
//! Terminal capability probing for inline kitty graphics in the plain
//! CLI commands (the TUI gets this from vaxis).
//!
//! v1 uses environment heuristics plus a TTY check; it does not actively
//! query the terminal. The cell pixel size defaults to a common 8x16 -
//! kitty scales the transmitted image to the requested cell footprint,
//! so the default only affects render crispness, not layout. (An active
//! `CSI 16 t` cell-size query is a sensible follow-up.)
const std = @import("std");
const term_graphics = @import("term_graphics.zig");
pub const Caps = struct {
/// The terminal appears to speak the kitty graphics protocol.
kitty: bool = false,
/// Cell pixel size used to size inline charts.
cell_w: u32 = term_graphics.default_cell_w,
cell_h: u32 = term_graphics.default_cell_h,
};
/// Detect inline-graphics capability. Graphics escapes are only ever
/// emitted to a real terminal, never into a pipe or file, so a non-TTY
/// stdout always yields `.kitty = false` (callers fall back to braille).
pub fn detect(io: std.Io, environ_map: *const std.process.Environ.Map) Caps {
const is_tty = std.Io.File.stdout().isTty(io) catch false;
if (!is_tty) return .{};
return .{ .kitty = kittyFromEnv(environ_map) };
}
/// Environment heuristic for kitty-graphics support. Recognizes kitty,
/// Ghostty, and WezTerm - the common terminals implementing the
/// protocol. (iTerm2 uses a different image protocol and is not matched.)
pub fn kittyFromEnv(env: *const std.process.Environ.Map) bool {
if (env.get("TERM")) |t| {
if (std.mem.indexOf(u8, t, "kitty") != null) return true;
}
if (env.get("KITTY_WINDOW_ID") != null) return true;
if (env.get("GHOSTTY_RESOURCES_DIR") != null) return true;
if (env.get("GHOSTTY_BIN_DIR") != null) return true;
if (env.get("WEZTERM_EXECUTABLE") != null) return true;
if (env.get("WEZTERM_PANE") != null) return true;
if (env.get("TERM_PROGRAM")) |p| {
if (std.mem.eql(u8, p, "ghostty")) return true;
if (std.mem.eql(u8, p, "WezTerm")) return true;
}
return false;
}
// Tests
const testing = std.testing;
test "kittyFromEnv: kitty TERM" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "xterm-kitty");
try testing.expect(kittyFromEnv(&env));
}
test "kittyFromEnv: KITTY_WINDOW_ID, ghostty, wezterm" {
{
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "xterm-256color");
try env.put("KITTY_WINDOW_ID", "1");
try testing.expect(kittyFromEnv(&env));
}
{
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM_PROGRAM", "ghostty");
try testing.expect(kittyFromEnv(&env));
}
{
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("WEZTERM_PANE", "0");
try testing.expect(kittyFromEnv(&env));
}
}
test "kittyFromEnv: plain xterm / Apple Terminal is not kitty" {
var env = std.process.Environ.Map.init(testing.allocator);
defer env.deinit();
try env.put("TERM", "xterm-256color");
try env.put("TERM_PROGRAM", "Apple_Terminal");
try testing.expect(!kittyFromEnv(&env));
}
test "Caps defaults to the assumed cell size, no kitty" {
const c: Caps = .{};
try testing.expect(!c.kitty);
try testing.expectEqual(term_graphics.default_cell_w, c.cell_w);
try testing.expectEqual(term_graphics.default_cell_h, c.cell_h);
}

View file

@ -9,7 +9,7 @@ const keybinds = @import("tui/keybinds.zig");
const tab_framework = @import("tui/tab_framework.zig");
const framework = @import("commands/framework.zig");
const theme = @import("tui/theme.zig");
const chart = @import("tui/chart.zig");
const chart = @import("charts/chart.zig");
const input_buffer = @import("tui/input_buffer.zig");
pub const PortfolioData = @import("PortfolioData.zig");
@ -494,6 +494,12 @@ pub const App = struct {
/// deterministic within a single frame and avoids threading `io`
/// through pure date-consuming helpers like `positions()`.
today: zfin.Date,
/// The user's local timezone, resolved once at App init (reads the
/// system tz database) and threaded as a value. Used by `getStatus`
/// to render the "refreshed at" stamp in local wall-clock time -
/// distinct from the portfolio footer's ET market-time stamp.
/// Owns memory when resolved from tzinfo; `deinit`'d in `deinitData`.
local_tz: zfin.market.TimeZone,
config: zfin.Config,
svc: *zfin.DataService,
keymap: keybinds.KeyMap,
@ -1417,8 +1423,8 @@ pub const App = struct {
/// flight, shows the in-progress indicator. Otherwise: a
/// user-set message if present, else a dynamic default hint
/// (global keys + the active tab's `status_hints`), prefixed
/// with a "refreshed Xs ago" readout once a refresh has run.
/// Allocated in `arena` for the dynamic forms; the user-set
/// with a "refreshed at <local clock>" stamp once a refresh has
/// run. Allocated in `arena` for the dynamic forms; the user-set
/// buffer is returned by reference.
fn getStatus(self: *App, arena: std.mem.Allocator) []const u8 {
if (self.refresh_pending) return "Refreshing...";
@ -1426,14 +1432,17 @@ pub const App = struct {
const hint = self.buildDefaultStatusHint(arena) catch
"h/l tabs | j/k select | / symbol | ? help";
if (self.last_refresh_s > 0) {
// wall-clock required: per-frame "now" for the relative
// "refreshed Xs ago" readout.
// wall-clock required: per-frame "now" to decide whether
// the refresh stamp needs a date prefix (refreshed on an
// earlier local day - the TUI doesn't auto-refresh, so a
// stamp can sit on screen across midnight).
const now_s = std.Io.Timestamp.now(self.io, .real).toSeconds();
var ago_buf: [24]u8 = undefined;
const ago = fmt.fmtTimeAgo(&ago_buf, self.last_refresh_s, now_s);
if (ago.len > 0) {
return std.fmt.allocPrint(arena, "refreshed {s} | {s}", .{ ago, hint }) catch hint;
}
// Absolute local-time stamp rather than a relative "Xs
// ago": with no auto-refresh, a relative readout silently
// goes stale while the screen sits idle.
var stamp_buf: [32]u8 = undefined;
const stamp = zfin.market.fmtStamp(&stamp_buf, self.local_tz, self.last_refresh_s, now_s);
return std.fmt.allocPrint(arena, "refreshed {s} | {s}", .{ stamp, hint }) catch hint;
}
return hint;
}
@ -1507,6 +1516,9 @@ pub const App = struct {
Module.tab.deinit(state_ptr, self);
}
self.portfolio.deinit();
// Release the resolved local timezone (no-op for the ET
// fallback, which borrows a static spec).
self.local_tz.deinit();
}
fn reloadPortfolioFile(self: *App) void {
@ -1622,10 +1634,9 @@ pub const App = struct {
}
/// Whether the given tab should be treated as disabled in
/// the current App context. All migrated tabs are consulted
/// via their framework-contract `isDisabled` hook. (Portfolio
/// is the only remaining unmigrated tab; it has no disabled
/// predicate today.)
/// the current App context. All tabs are consulted via their
/// framework-contract `isDisabled` hook (tabs without one
/// default to always-enabled).
fn isDisabled(self: *App, t: Tab) bool {
return self.appPredicate(t, "isDisabled");
}
@ -2509,10 +2520,17 @@ pub fn run(
var app_inst = try allocator.create(App);
defer allocator.destroy(app_inst);
// Resolve the user's local timezone once here at the top of the
// TUI's unit of work, honoring $TZ from the environ map (owned by
// main, so it outlives the App and any zone that borrows it).
// Falls back to the system default, then ET. deinit'd in deinitData.
const tz_override = if (config.environ_map) |em| em.get("TZ") else null;
const local_tz = zfin.market.localTimeZone(allocator, io, tz_override);
app_inst.* = .{
.allocator = allocator,
.io = io,
.today = today,
.local_tz = local_tz,
.config = config,
.svc = svc,
.keymap = keymap,
@ -2537,6 +2555,17 @@ pub fn run(
if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| {
resolved_pf_paths = rp;
} else |_| {}
} else {
// Explicit-symbol launch (e.g. `zfin AAPL`) skips the full
// portfolio load below, but the quote tab (and the 'K' overlay)
// still want the curated security name from metadata.srf - same
// as the CLI `quote` command. Prime just the classification map
// (no price fetch) so classificationMap() resolves the name.
if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| {
var rp_owned = rp;
defer rp_owned.deinit();
app_inst.portfolio.primeClassificationMap(rp_owned.paths);
} else |_| {}
}
var resolved_wl: ?zfin.Config.ResolvedPath = null;

View file

@ -330,6 +330,16 @@ pub const tab = struct {
pub const deactivate = framework.noopDeactivate(State);
/// Disabled only when the TUI was opened on a specific symbol
/// (`zfin AAPL`) with no portfolio loaded: there's nothing to show
/// here, so it greys out and becomes unselectable to match the
/// other portfolio-backed tabs (analysis, review, projections,
/// history). A plain `zfin` launch with no portfolio keeps the tab
/// enabled so it can show the welcome screen (`drawWelcomeScreen`).
pub fn isDisabled(app: *App) bool {
return app.portfolio.file == null and app.has_explicit_symbol;
}
/// Manual refresh (r/F5): re-value the portfolio with live
/// intraday quotes, then rebuild the summary. `r` means "give me
/// current prices now" - it does NOT force candle work. Candle
@ -377,10 +387,15 @@ pub const tab = struct {
// falls back to the candle last close for those.
var live = app.svc.loadLiveQuotes(quote_syms.items);
defer live.deinit();
// wall-clock required: stamp the instant the live quotes were
// fetched so the portfolio footer can render a precise
// "(as of H:MM PM ET)" stamp via market.fmtClockET.
const live_at_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
_ = app.portfolio.reload(app.today, .{
.watchlist_syms = watch_syms.items,
.live_quotes = &live,
.live_quotes_at_s = live_at_s,
}) catch |err| {
app.setStatus("Error refreshing portfolio data");
std.log.scoped(.tui).warn("portfolio.reload: {t}", .{err});
@ -461,11 +476,6 @@ pub const tab = struct {
}
}
/// Portfolio is always enabled (the tab itself; data may be
/// empty if no portfolio file is loaded - that's a separate
/// concern handled by `drawWelcomeScreen`).
pub const isDisabled = framework.alwaysEnabled();
/// Drop UI state that referenced the previous portfolio.
///
/// `account_list` holds borrowed strings into the old
@ -1340,6 +1350,34 @@ fn computeFilteredTotals(state: *const State, app: *const App) FilteredTotals {
// Rendering
/// Append the "(as of ...)" footer line describing how current the
/// portfolio valuation is. When live intraday quotes were overlaid
/// (the TUI refresh path), show a precise ET wall-clock stamp of the
/// fetch instant - and flag it when the market was closed at that
/// moment, so a weekend/after-hours refresh no longer implies live
/// intraday data. Otherwise fall back to the candle close date.
fn appendAsOfLine(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), app: *App) !void {
const th = app.theme;
if (app.portfolio.live_prices_applied) {
if (app.portfolio.live_quotes_at_s) |ts| {
var clk: [16]u8 = undefined;
const stamp = zfin.market.fmtClockET(&clk, ts);
const text = if (zfin.market.marketSession(ts) != .open)
try std.fmt.allocPrint(arena, " (as of {s}, market closed)", .{stamp})
else
try std.fmt.allocPrint(arena, " (as of {s})", .{stamp});
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
} else {
// Live overlay applied but no fetch timestamp was threaded
// (defensive fallback).
try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() });
}
} else if (app.portfolio.latest_quote_date) |d| {
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
}
}
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
// Modal sub-state takes over the content surface entirely.
// Picker overlay replaces the portfolio table while open.
@ -1382,15 +1420,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
if (app.portfolio.live_prices_applied) {
// Live overlay applied: values reflect today's intraday
// quotes, not the candle close. Static text for now; a
// precise "as of HH:MM ET" awaits real tz handling.
try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() });
} else if (app.portfolio.latest_quote_date) |d| {
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
}
try appendAsOfLine(arena, &lines, app);
// No historical snapshots or net worth when filtered
} else {
// Unfiltered mode: use portfolio_summary totals directly
@ -1406,15 +1436,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
// "as of" date indicator
if (app.portfolio.live_prices_applied) {
// Live overlay applied: values reflect today's intraday
// quotes, not the candle close. Static text for now; a
// precise "as of HH:MM ET" awaits real tz handling.
try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() });
} else if (app.portfolio.latest_quote_date) |d| {
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
}
try appendAsOfLine(arena, &lines, app);
// Net Worth line (only if portfolio has illiquid assets)
if (app.portfolio.file) |pf| {
@ -1888,6 +1910,17 @@ pub fn buildWelcomeScreenLines(
/// Inactive tabs stay in `loaded = false` and lazy-rebuild on
/// next switch.
pub fn reloadPortfolioFile(state: *State, app: *App) void {
// `paths` is non-empty once a portfolio has been resolved - either
// by a normal load, OR by the symbol-mode classification-map prime
// (see PortfolioData.primeClassificationMap). In that second case
// `file` is still null (no portfolio actually loaded yet), and the
// `pd.reload` below runs the FIRST full load. That's intentional:
// `R` lets you "upgrade" a symbol-only session (`zfin -s AAPL`)
// into the full portfolio view, lighting up the portfolio-scoped
// tabs. Only when nothing has been resolved at all (no portfolio
// file on disk) is `paths` empty and there's genuinely nothing to
// do. Do NOT tighten this to `file == null` - that would break the
// upgrade path.
if (app.portfolio.paths.len == 0) {
app.setStatus("No portfolio file to reload");
return;

View file

@ -26,8 +26,8 @@ const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const projection_chart = @import("projection_chart.zig");
const forecast_chart = @import("forecast_chart.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const forecast_chart = @import("../charts/forecast_chart.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const imported = @import("../data/imported_values.zig");
const milestones = @import("../analytics/milestones.zig");
@ -159,7 +159,7 @@ pub const State = struct {
/// view (`.bands`) renders the standard percentile-band chart
/// + projection report. `.convergence` and `.return_backtest`
/// pull data from `imported_values.srf` and render
/// forecast-evaluation charts via `tui/forecast_chart.zig`.
/// forecast-evaluation charts via `charts/forecast_chart.zig`.
/// Toggled by the `c` and `r` keybinds; toggling either
/// clears the other (mutually exclusive).
sub_view: SubView = .bands,

View file

@ -4,7 +4,7 @@ const zfin = @import("../root.zig");
const fmt = @import("../format.zig");
const Money = @import("../Money.zig");
const theme = @import("theme.zig");
const chart = @import("chart.zig");
const chart = @import("../charts/chart.zig");
const tui = @import("../tui.zig");
const framework = @import("tab_framework.zig");
@ -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)