Compare commits

..

No commits in common. "1d89b68da4513d7db39ed9dca6ca8ea9954f133e" and "72e874f052f9096ebc498f4ceb3981bd99294185" have entirely different histories.

32 changed files with 655 additions and 2968 deletions

56
TODO.md
View file

@ -81,10 +81,28 @@ ranking; unlabeled items are "someday, if the mood strikes."
## `--export-chart` follow-ups - priority LOW ## `--export-chart` follow-ups - priority LOW
V1 of `--export-chart <PATH>` shipped for `quote`, `projections` V1 of `--export-chart <PATH>` shipped for `quote` and `projections`
(default bands mode only), and `history`. Several adjacent surfaces (default bands mode only). Several adjacent surfaces still don't
still don't have PNG export and were deferred: 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 - **`projections --convergence` / `--return-backtest`.** Both
render forecast-evaluation charts via `tui/forecast_chart.zig`. render forecast-evaluation charts via `tui/forecast_chart.zig`.
Not refactored to expose a `renderToSurface` seam yet - Not refactored to expose a `renderToSurface` seam yet -
@ -242,9 +260,39 @@ Implementation notes:
(`RateLimiter.perHour`, wired into the provider). A batched quote (`RateLimiter.perHour`, wired into the provider). A batched quote
call is 1 request, but heavy `r` use plus candle refreshes draw from call is 1 request, but heavy `r` use plus candle refreshes draw from
the same hourly budget, so watch for contention. 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. 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 ## Analysis: dividend equity / income-shaped equity - think about it
Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.) Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.)

View file

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

View file

@ -28,13 +28,9 @@ Price History for VTI (last 30 days)
## Portfolio mode ## Portfolio mode
With no symbol, reads your `history/*-portfolio.srf` snapshots and With no symbol, reads your `history/*-portfolio.srf` snapshots and
renders rolling-window returns, a chart, and a recent-snapshots renders rolling-window returns, a braille chart, and a recent-snapshots
table. (Build that history with [`snapshot`](snapshot.md).) 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 | | Flag | Effect |
|-----------------------|-------------------------------------------------| |-----------------------|-------------------------------------------------|
| `--since <DATE>` | Earliest as-of date (inclusive). | | `--since <DATE>` | Earliest as-of date (inclusive). |

View file

@ -73,7 +73,6 @@ 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). | | `--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. | | `-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`). | | `-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 ```bash
zfin --no-color --refresh-data=never -p 'portfolio_*.srf' analysis zfin --no-color --refresh-data=never -p 'portfolio_*.srf' analysis

View file

@ -4,27 +4,14 @@ Show the latest quote for a symbol, with a price chart and recent
history. history.
``` ```
Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>] Usage: zfin quote <SYMBOL>
``` ```
Prints the last price, the day's open/high/low, volume, and the Prints the last price, the day's open/high/low, volume, and the
day-over-day change, followed by a price chart over a recent window day-over-day change, followed by a 20-day chart. Quotes come from Yahoo
(the last ~3 months by default) and a table of the last 20 trading
days. Quotes come from Yahoo
(TwelveData fallback) and are **never cached** -- so this command needs (TwelveData fallback) and are **never cached** -- so this command needs
network access and does nothing useful in `--refresh-data=never` mode. network access and does nothing useful in `--refresh-data=never` mode.
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 Supports `--export-chart <PATH>` to render the chart as a 1920x1080
PNG instead of text (see [export charts](../../guides/offline-and-refresh.md) PNG instead of text (see [export charts](../../guides/offline-and-refresh.md)
and the projections page). and the projections page).
@ -33,9 +20,6 @@ and the projections page).
```bash ```bash
ZFIN_HOME=examples/pre-retirement-both zfin quote SPY ZFIN_HOME=examples/pre-retirement-both zfin quote SPY
# A one-year chart window instead of the default ~3 months:
ZFIN_HOME=examples/pre-retirement-both zfin quote SPY --since 1Y
``` ```
``` ```
@ -46,18 +30,11 @@ SPY $746.74 (close)
High: $748.23 High: $748.23
Low: $743.86 Low: $743.86
Volume: 80,875,657 Volume: 80,875,657
Change (1D): +$5.78 (+0.78%) Change: +$5.78 (+0.78%)
Change (3M): +$31.40 (+4.39%)
... (price chart over the selected window -- inline Kitty image, or braille) ... (20-day braille chart)
``` ```
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 ## See also
- [`perf`](perf.md) -- trailing returns instead of a spot price. - [`perf`](perf.md) -- trailing returns instead of a spot price.

View file

@ -29,12 +29,6 @@ currently-selected symbol.
The symbol-scoped tabs follow a single "current symbol"; change it from The symbol-scoped tabs follow a single "current symbol"; change it from
the Portfolio tab (select a holding) or by launching with `-s`. 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 ## Charts
Tabs with charts render high-fidelity **Kitty graphics** when your Tabs with charts render high-fidelity **Kitty graphics** when your
@ -52,10 +46,9 @@ current bindings). The global defaults:
|----------------------------------------------|--------------------------------| |----------------------------------------------|--------------------------------|
| `q`, `Ctrl-C` | Quit | | `q`, `Ctrl-C` | Quit |
| `r`, `F5` | Refresh the current tab's data | | `r`, `F5` | Refresh the current tab's data |
| `R` | Reload the portfolio from disk |
| `l` / `right` / `tab` | Next tab | | `l` / `right` / `tab` | Next tab |
| `h` / `left` / `shift+tab` | Previous tab | | `h` / `left` / `shift+tab` | Previous tab |
| `1`-`9` | Jump to a tab by number | | `1`-`8` | Jump to a tab by number |
| `j` / `down`, `k` / `up` | Move the selection | | `j` / `down`, `k` / `up` | Move the selection |
| `g` / `G` | Jump to top / bottom | | `g` / `G` | Jump to top / bottom |
| `Ctrl-d` / `Ctrl-u` | Half-page down / up | | `Ctrl-d` / `Ctrl-u` | Half-page down / up |

View file

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

View file

@ -191,13 +191,6 @@ pub const LoadOptions = struct {
/// portfolio with current intraday quotes rather than the prior /// portfolio with current intraday quotes rather than the prior
/// daily close. Borrowed; pd does not take ownership. /// daily close. Borrowed; pd does not take ownership.
live_quotes: ?*const std.StringHashMap(f64) = null, 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 /// Per-worker start delays. Each background worker sleeps for
/// its delay before doing any work, letting the caller /// its delay before doing any work, letting the caller
/// deprioritize a specific worker (e.g. push it later so a /// deprioritize a specific worker (e.g. push it later so a
@ -283,12 +276,6 @@ latest_quote_date: ?Date = null,
/// Drives the portfolio "as of" label wording. /// Drives the portfolio "as of" label wording.
live_prices_applied: bool = false, 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 /// Cached prices for watchlist symbols (no live fetching during
/// render). Allocated in pd's arena. /// render). Allocated in pd's arena.
watchlist_prices: ?std.StringHashMap(f64) = null, watchlist_prices: ?std.StringHashMap(f64) = null,
@ -447,38 +434,6 @@ pub fn invalidateClassificationMap(self: *PortfolioData) void {
self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) }); 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 /// Drain a worker future. Idempotent (Future.await is itself
/// idempotent); safe to call every time. /// idempotent); safe to call every time.
fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void { fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void {
@ -561,7 +516,6 @@ pub fn load(
self.summary = null; self.summary = null;
self.latest_quote_date = null; self.latest_quote_date = null;
self.live_prices_applied = false; self.live_prices_applied = false;
self.live_quotes_at_s = null;
self.watchlist_prices = null; self.watchlist_prices = null;
self.snapshots_data = null; self.snapshots_data = null;
self.dividends_data = null; self.dividends_data = null;
@ -709,10 +663,6 @@ pub fn load(
// so the summary reflects today's intraday prices rather than // so the summary reflects today's intraday prices rather than
// the candle close. Drives the portfolio "as of" label. // the candle close. Drives the portfolio "as of" label.
self.live_prices_applied = held_overrides > 0; 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; self.watchlist_prices = wp;
@ -1016,43 +966,6 @@ test "PortfolioData.cancelLoad: idempotent on idle state" {
try testing.expect(pd.classification_map_data == null); 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" { test "PortfolioData.candles: returns null after cancelLoad with no data" {
var svc: DataService = .{ var svc: DataService = .{
.allocator = testing.allocator, .allocator = testing.allocator,

View file

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

View file

@ -299,33 +299,6 @@ pub fn extractMetric(
return out; 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. /// Which collection of per-row named values on `TimelinePoint` to project.
pub const NamedSeriesSource = enum { accounts, tax_types }; pub const NamedSeriesSource = enum { accounts, tax_types };
@ -1734,47 +1707,6 @@ test "Metric.label: stable strings" {
try testing.expectEqualStrings("Illiquid", Metric.illiquid.label()); 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" { test "extractNamedSeries accounts: matches + absent days emit 0" {
// Build three snapshots: day1 has account A; day2 has account B; day3 has both. // 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. // 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`) //! module. Each chart-bearing command (`quote`, `projections`)
//! has a corresponding `export*` function here that: //! has a corresponding `export*` function here that:
//! //!
//! 1. Calls the relevant `renderToSurface` (in `charts/chart.zig` or //! 1. Calls the relevant `renderToSurface` (in `tui/chart.zig` or
//! `charts/projection_chart.zig`) to draw the chart into a z2d //! `tui/projection_chart.zig`) to draw the chart into a z2d
//! `Surface`. //! `Surface`.
//! 2. Calls `z2d.png_exporter.writeToPNGFile` to land the surface //! 2. Calls `z2d.png_exporter.writeToPNGFile` to land the surface
//! as a PNG file at the user-supplied path. //! as a PNG file at the user-supplied path.
@ -25,48 +25,36 @@
const std = @import("std"); const std = @import("std");
const z2d = @import("z2d"); const z2d = @import("z2d");
const zfin = @import("root.zig"); const zfin = @import("root.zig");
const chart = @import("charts/chart.zig"); const chart = @import("tui/chart.zig");
const projection_chart = @import("charts/projection_chart.zig"); const projection_chart = @import("tui/projection_chart.zig");
const line_chart = @import("charts/line_chart.zig");
const projections = @import("analytics/projections.zig"); const projections = @import("analytics/projections.zig");
const theme = @import("tui/theme.zig"); const theme = @import("tui/theme.zig");
/// Default PNG export resolution. Matches `charts/chart.zig`'s /// Default PNG export resolution. Matches `tui/chart.zig`'s
/// `ChartConfig.max_width/max_height` defaults so an exported /// `ChartConfig.max_width/max_height` defaults so an exported
/// image carries the same fidelity as a maximally-sized TUI /// image carries the same fidelity as a maximally-sized TUI
/// chart. /// chart.
pub const default_width: u32 = 1920; pub const default_width: u32 = 1920;
pub const default_height: u32 = 1080; pub const default_height: u32 = 1080;
/// Export a price+Bollinger+RSI chart for a single symbol, showing the /// Export a price+Bollinger+RSI chart for a single symbol.
/// most recent `display_count` candles. The overlays are computed with a /// Wraps `chart.renderToSurface` + `writeToPNGFile`.
/// warmup lookback (via `chart.computeIndicatorsWarmup`) so they're valid
/// from the first displayed candle. Wraps `renderToSurface` + `writeToPNGFile`.
pub fn exportSymbolChart( pub fn exportSymbolChart(
io: std.Io, io: std.Io,
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
candles: []const zfin.Candle, candles: []const zfin.Candle,
display_count: usize, timeframe: chart.Timeframe,
path: []const u8, path: []const u8,
) !void { ) !void {
var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData,
else => return err,
};
defer cached.deinit(alloc);
const n = @min(candles.len, display_count);
const display = candles[candles.len - n ..];
var rendered = chart.renderToSurface( var rendered = chart.renderToSurface(
io, io,
alloc, alloc,
display, candles,
null, timeframe,
default_width, default_width,
default_height, default_height,
theme.default_theme, theme.default_theme,
&cached, null,
true,
) catch |err| switch (err) { ) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData, error.InsufficientData => return error.InsufficientData,
else => return err, else => return err,
@ -94,34 +82,6 @@ pub fn exportProjectionChart(
default_height, default_height,
theme.default_theme, theme.default_theme,
actuals, 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) { ) catch |err| switch (err) {
error.InsufficientData => return error.InsufficientData, error.InsufficientData => return error.InsufficientData,
else => return err, else => return err,
@ -161,7 +121,7 @@ test "exportSymbolChart writes a non-empty PNG file" {
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" }); const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" });
defer alloc.free(path); defer alloc.free(path);
try exportSymbolChart(io, alloc, &candles, 60, path); try exportSymbolChart(io, alloc, &candles, .@"6M", path);
// Verify the file exists, starts with the PNG magic, and is // Verify the file exists, starts with the PNG magic, and is
// big enough to plausibly contain a chart (not just headers). // big enough to plausibly contain a chart (not just headers).
@ -204,7 +164,7 @@ test "exportSymbolChart returns InsufficientData on too-few candles" {
try std.testing.expectError( try std.testing.expectError(
error.InsufficientData, error.InsufficientData,
exportSymbolChart(io, alloc, &candles, 60, path), exportSymbolChart(io, alloc, &candles, .@"6M", path),
); );
} }
@ -274,58 +234,3 @@ test "exportProjectionChart returns InsufficientData with single band" {
exportProjectionChart(io, alloc, &bands, null, path), 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),
);
}

View file

@ -1,160 +0,0 @@
//! 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

@ -1,308 +0,0 @@
//! 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

@ -1,429 +0,0 @@
//! 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

@ -1,211 +0,0 @@
//! 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,10 +443,6 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D
if (std.ascii.eqlIgnoreCase(s, "live") or std.ascii.eqlIgnoreCase(s, "now")) { if (std.ascii.eqlIgnoreCase(s, "live") or std.ascii.eqlIgnoreCase(s, "now")) {
return null; return null;
} }
// Year-to-date: Jan 1 of the reference year.
if (std.ascii.eqlIgnoreCase(s, "ytd")) {
return zfin.Date.fromYmd(as_of.year(), 1, 1);
}
// Explicit YYYY-MM-DD. // Explicit YYYY-MM-DD.
if (s.len == 10 and s[4] == '-' and s[7] == '-') { if (s.len == 10 and s[4] == '-' and s[7] == '-') {
@ -481,7 +477,7 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D
/// caller is responsible for formatting the surrounding message. /// caller is responsible for formatting the surrounding message.
pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 { pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 {
return switch (err) { return switch (err) {
error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), 'ytd', 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), or 'live'.", .{input}) catch input,
error.EmptyUnit => std.fmt.bufPrint(buf, "As-of value {s} is missing a unit. Expected one of W, M, Q, Y.", .{input}) catch input, error.EmptyUnit => std.fmt.bufPrint(buf, "As-of value {s} is missing a unit. Expected one of W, M, Q, Y.", .{input}) catch input,
error.UnknownUnit => std.fmt.bufPrint(buf, "As-of value {s} has an unknown unit. Expected one of W (weeks), M (months), Q (quarters), Y (years).", .{input}) catch input, error.UnknownUnit => std.fmt.bufPrint(buf, "As-of value {s} has an unknown unit. Expected one of W (weeks), M (months), Q (quarters), Y (years).", .{input}) catch input,
error.ZeroQuantity => std.fmt.bufPrint(buf, "As-of quantity must be at least 1 (got {s}).", .{input}) catch input, error.ZeroQuantity => std.fmt.bufPrint(buf, "As-of quantity must be at least 1 (got {s}).", .{input}) catch input,
@ -1036,12 +1032,6 @@ test "parseAsOfDate: literal 'live' and 'now' (case-insensitive)" {
try std.testing.expect((try parseAsOfDate("Now", today)) == null); try std.testing.expect((try parseAsOfDate("Now", today)) == null);
} }
test "parseAsOfDate: 'ytd' is Jan 1 of the reference year (case-insensitive)" {
const today = zfin.Date.fromYmd(2026, 4, 2);
try std.testing.expect((try parseAsOfDate("ytd", today)).?.eql(zfin.Date.fromYmd(2026, 1, 1)));
try std.testing.expect((try parseAsOfDate("YTD", today)).?.eql(zfin.Date.fromYmd(2026, 1, 1)));
}
test "parseAsOfDate: explicit YYYY-MM-DD" { test "parseAsOfDate: explicit YYYY-MM-DD" {
const today = zfin.Date.fromYmd(2026, 4, 2); const today = zfin.Date.fromYmd(2026, 4, 2);
const r = try parseAsOfDate("2026-03-13", today); const r = try parseAsOfDate("2026-03-13", today);

View file

@ -43,8 +43,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const validator = @import("../comptime_validator.zig"); const validator = @import("../comptime_validator.zig");
const chart = @import("../charts/chart.zig");
const term_query = @import("../term_query.zig");
// ── Group taxonomy ──────────────────────────────────────────── // ── Group taxonomy ────────────────────────────────────────────
@ -208,9 +206,6 @@ pub const Globals = struct {
watchlist_path: ?[]const u8 = null, watchlist_path: ?[]const u8 = null,
/// Cache-freshness policy from `--refresh-data=<value>`. /// Cache-freshness policy from `--refresh-data=<value>`.
refresh_policy: RefreshPolicy = .auto, 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 ──────────────────────────────────────────────────── // ── RunCtx ────────────────────────────────────────────────────
@ -255,10 +250,6 @@ pub const RunCtx = struct {
now_s: i64, now_s: i64,
color: bool, color: bool,
out: *std.Io.Writer, 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 /// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
/// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME. /// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME.

View file

@ -16,10 +16,6 @@
//! Defaults to auto: daily ≤90d, weekly ≤730d, //! Defaults to auto: daily ≤90d, weekly ≤730d,
//! else monthly. //! else monthly.
//! --limit <N> cap the "Recent snapshots" table to N rows //! --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 //! --rebuild-rollup (re)write history/rollup.srf and exit
//! //!
//! Portfolio layout, top-to-bottom: //! Portfolio layout, top-to-bottom:
@ -42,12 +38,6 @@ const timeline = @import("../analytics/timeline.zig");
const history = @import("../history.zig"); const history = @import("../history.zig");
const snapshot_model = @import("../models/snapshot.zig"); const snapshot_model = @import("../models/snapshot.zig");
const view = @import("../views/history.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 fmt = cli.fmt;
const Date = @import("../Date.zig"); const Date = @import("../Date.zig");
@ -79,8 +69,7 @@ pub const meta: framework.Meta = .{
\\shows the last 30 trading days of candles for that symbol. \\shows the last 30 trading days of candles for that symbol.
\\Portfolio mode (no positional, optionally with flags) reads \\Portfolio mode (no positional, optionally with flags) reads
\\`history/*-portfolio.srf` snapshots and renders rolling-windows \\`history/*-portfolio.srf` snapshots and renders rolling-windows
\\returns + a chart (an inline Kitty image when the terminal \\returns + a braille chart + a recent-snapshots table.
\\supports it, braille otherwise) + a recent-snapshots table.
\\ \\
\\Portfolio-mode flags: \\Portfolio-mode flags:
\\ --since <DATE> earliest as-of date (inclusive) \\ --since <DATE> earliest as-of date (inclusive)
@ -89,14 +78,12 @@ pub const meta: framework.Meta = .{
\\ --resolution <name> daily | weekly | monthly | auto \\ --resolution <name> daily | weekly | monthly | auto
\\ (auto: daily ≤90d, weekly ≤730d, else monthly) \\ (auto: daily ≤90d, weekly ≤730d, else monthly)
\\ --limit <N> cap recent-snapshots table to N rows (default 40) \\ --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 \\ --rebuild-rollup regenerate history/rollup.srf and exit
\\ \\
\\DATE accepts YYYY-MM-DD or relative shortcuts (1W/1M/1Q/1Y). \\DATE accepts YYYY-MM-DD or relative shortcuts (1W/1M/1Q/1Y).
\\ \\
, ,
.user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution, UnknownBaseline }, .user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution },
}; };
pub const Error = error{ pub const Error = error{
@ -105,7 +92,6 @@ pub const Error = error{
MissingFlagValue, MissingFlagValue,
UnknownMetric, UnknownMetric,
UnknownResolution, UnknownResolution,
UnknownBaseline,
}; };
/// Parsed portfolio-mode options. Separated from `run` so the parser /// Parsed portfolio-mode options. Separated from `run` so the parser
@ -128,15 +114,6 @@ pub const PortfolioOpts = struct {
/// Max rows shown in the recent-snapshots table. Null means default (40). /// Max rows shown in the recent-snapshots table. Null means default (40).
limit: ?usize = null, limit: ?usize = null,
rebuild_rollup: bool = false, 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. /// Parse the arg list for portfolio-mode flags. Pure function - no IO.
@ -178,14 +155,6 @@ 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; opts.limit = std.fmt.parseInt(usize, args[i], 10) catch return error.InvalidFlagValue;
} else if (std.mem.eql(u8, a, "--rebuild-rollup")) { } else if (std.mem.eql(u8, a, "--rebuild-rollup")) {
opts.rebuild_rollup = true; 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 { } else {
return error.UnexpectedArg; return error.UnexpectedArg;
} }
@ -208,7 +177,6 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
error.InvalidFlagValue => cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"), 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.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.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; return err;
}; };
@ -225,7 +193,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
.portfolio => |opts| { .portfolio => |opts| {
const pf = ctx.resolvePortfolioPath(); const pf = ctx.resolvePortfolioPath();
defer pf.deinit(ctx.allocator); defer pf.deinit(ctx.allocator);
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps); try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out);
}, },
} }
} }
@ -295,8 +263,6 @@ fn runPortfolio(
opts: PortfolioOpts, opts: PortfolioOpts,
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
chart_config: chart.ChartConfig,
caps: term_query.Caps,
) !void { ) !void {
var tl = try history.loadTimeline(io, allocator, portfolio_path); var tl = try history.loadTimeline(io, allocator, portfolio_path);
defer tl.deinit(); defer tl.deinit();
@ -323,26 +289,6 @@ fn runPortfolio(
return; 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: // Resolve the effective resolution:
// - explicit `--resolution daily/weekly/monthly/cascading` -> // - explicit `--resolution daily/weekly/monthly/cascading` ->
// use as-is. // use as-is.
@ -355,18 +301,7 @@ fn runPortfolio(
timeline.selectResolution(filtered) timeline.selectResolution(filtered)
else else
.cascading; .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 /// Regenerate `history/rollup.srf` from `snapshots`. Uses
@ -432,7 +367,6 @@ pub fn renderPortfolio(
resolution: timeline.Resolution, resolution: timeline.Resolution,
resolution_override: ?timeline.Resolution, resolution_override: ?timeline.Resolution,
row_limit: usize, row_limit: usize,
chart_render: ChartRender,
) !void { ) !void {
try cli.printBold(out, color, "\nPortfolio Timeline: {s}\n", .{focus_metric.label()}); try cli.printBold(out, color, "\nPortfolio Timeline: {s}\n", .{focus_metric.label()});
try out.print("========================================\n", .{}); try out.print("========================================\n", .{});
@ -443,9 +377,9 @@ pub fn renderPortfolio(
defer ws.deinit(); defer ws.deinit();
try renderWindowsBlock(out, color, ws); try renderWindowsBlock(out, color, ws);
// ── Chart (inline kitty graphics or braille fallback) ───── // ── Chart (synthetic candles from focused-metric values) ─
try out.print("\n", .{}); try out.print("\n", .{});
try renderTimelineChart(allocator, out, color, points, focus_metric, chart_render); try renderBrailleChart(allocator, out, color, points, focus_metric);
// ── Table ──────────────────────────────────────────────── // ── Table ────────────────────────────────────────────────
if (resolution == .cascading) { if (resolution == .cascading) {
@ -525,103 +459,7 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
} }
} }
/// Convert timeline `MetricPoint`s into the chart module's `LinePoint` fn renderBrailleChart(
/// 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, allocator: std.mem.Allocator,
out: *std.Io.Writer, out: *std.Io.Writer,
color: bool, color: bool,
@ -630,34 +468,37 @@ fn renderBraille(
) !void { ) !void {
if (points.len < 2) return; if (points.len < 2) return;
// Extract the focused-metric series via the shared // Synthesize candles from the focused metric's value. For
// `extractChartSeries` rule (skips imported-only points for the // illiquid / net_worth, skip imported-only points so the
// derived metrics so the line is visually absent in the // line is visually absent in the imported-only range rather
// imported-only range rather than hugging zero), then synthesize // than hugging zero.
// 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; var candles_list: std.ArrayList(zfin.Candle) = .empty;
defer candles_list.deinit(allocator); defer candles_list.deinit(allocator);
try candles_list.ensureTotalCapacity(allocator, series.len); try candles_list.ensureTotalCapacity(allocator, points.len);
for (series) |mp| { const skip_imported = (metric == .illiquid) or (metric == .net_worth);
candles_list.appendAssumeCapacity(.{ for (points) |p| {
.date = mp.date, if (skip_imported and p.source == .imported) continue;
.open = mp.value, const v = switch (metric) {
.high = mp.value, .net_worth => p.net_worth,
.low = mp.value, .liquid => p.liquid,
.close = mp.value, .illiquid => p.illiquid,
.adj_close = mp.value, };
try candles_list.append(allocator, .{
.date = p.as_of_date,
.open = v,
.high = v,
.low = v,
.close = v,
.adj_close = v,
.volume = 0, .volume = 0,
}); });
} }
const candles = candles_list.items; const candles = candles_list.items;
if (candles.len < 2) return;
var braille_chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer braille_chart.deinit(allocator); defer chart.deinit(allocator);
try fmt.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false); try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED, false);
} }
fn renderTable( fn renderTable(
@ -950,29 +791,6 @@ test "parsePortfolioOpts: unknown flag / value errors" {
try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--limit", "not-a-number" })); 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) ───────────────────────────── // ── renderPortfolio (end-to-end) ─────────────────────────────
fn makeTimelinePoint(y: i16, m: u8, d: u8, liq: f64, ill: f64, nw: f64) timeline.TimelinePoint { fn makeTimelinePoint(y: i16, m: u8, d: u8, liq: f64, ill: f64, nw: f64) timeline.TimelinePoint {
@ -994,7 +812,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" {
makeTimelinePoint(2026, 4, 18, 750, 350, 1100), makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
makeTimelinePoint(2026, 4, 21, 800, 400, 1200), makeTimelinePoint(2026, 4, 21, 800, 400, 1200),
}; };
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40, .braille); try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40);
const out = w.buffered(); const out = w.buffered();
// Header // Header
@ -1042,7 +860,7 @@ test "renderPortfolio: auto resolution shows '(auto - <effective>)' label" {
makeTimelinePoint(2026, 4, 18, 750, 350, 1100), makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
}; };
// resolution_override = null -> auto. Effective is daily (span ≤ 90d). // resolution_override = null -> auto. Effective is daily (span ≤ 90d).
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40, .braille); try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40);
const out = w.buffered(); const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null); try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null);
} }
@ -1054,7 +872,7 @@ test "renderPortfolio: color mode emits ANSI" {
makeTimelinePoint(2026, 4, 17, 700, 300, 1000), makeTimelinePoint(2026, 4, 17, 700, 300, 1000),
makeTimelinePoint(2026, 4, 18, 750, 350, 1100), makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
}; };
try renderPortfolio(testing.allocator, &w, true, &pts, .liquid, .daily, .daily, 40, .braille); try renderPortfolio(testing.allocator, &w, true, &pts, .liquid, .daily, .daily, 40);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") != null); try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") != null);
} }
@ -1064,7 +882,7 @@ test "renderPortfolio: single point renders without crashing" {
const pts = [_]timeline.TimelinePoint{ const pts = [_]timeline.TimelinePoint{
makeTimelinePoint(2026, 4, 17, 700, 300, 1000), makeTimelinePoint(2026, 4, 17, 700, 300, 1000),
}; };
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40, .braille); try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40);
const out = w.buffered(); const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null); try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
// Chart requires >= 2 points; confirm no crash, table shows one row. // Chart requires >= 2 points; confirm no crash, table shows one row.
@ -1083,7 +901,7 @@ test "renderPortfolio: row_limit caps table rows" {
makeTimelinePoint(2026, 4, 20, 770, 370, 1140), makeTimelinePoint(2026, 4, 20, 770, 370, 1140),
makeTimelinePoint(2026, 4, 21, 780, 380, 1160), makeTimelinePoint(2026, 4, 21, 780, 380, 1160),
}; };
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 2, .braille); try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 2);
const out = w.buffered(); const out = w.buffered();
// 5 snapshots total, 2 shown. // 5 snapshots total, 2 shown.
try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null); try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null);
@ -1102,7 +920,7 @@ test "renderPortfolio: monthly resolution labels the table accordingly" {
makeTimelinePoint(2026, 3, 31, 800, 400, 1200), makeTimelinePoint(2026, 3, 31, 800, 400, 1200),
makeTimelinePoint(2026, 4, 21, 900, 500, 1400), makeTimelinePoint(2026, 4, 21, 900, 500, 1400),
}; };
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .monthly, .monthly, 40, .braille); try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .monthly, .monthly, 40);
const out = w.buffered(); const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "(monthly)") != null); try testing.expect(std.mem.indexOf(u8, out, "(monthly)") != null);
} }
@ -1134,62 +952,6 @@ test "displaySymbol empty candles" {
try testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null); 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 ──────────────────────────────────────────── // ── rebuildRollup ────────────────────────────────────────────
fn makeFixtureSnapshot( fn makeFixtureSnapshot(

View file

@ -22,10 +22,6 @@ const forecast = @import("../analytics/forecast_evaluation.zig");
const milestones = @import("../analytics/milestones.zig"); const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig"); const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.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- /// Tagged-union args for the four projection sub-modes. Mutually-
/// exclusive flag combos (--convergence with --vs, --real with /// exclusive flag combos (--convergence with --vs, --real with
@ -283,14 +279,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
// total. Snapshot-only as-of paths ignore it. // total. Snapshot-only as-of paths ignore it.
var live = try loadLiveData(ctx, today, color); var live = try loadLiveData(ctx, today, color);
defer if (live) |*l| l.deinit(allocator); 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( try runBands(
io, io,
allocator, allocator,
@ -307,7 +295,6 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
}, },
color, color,
out, out,
kitty_caps,
); );
}, },
} }
@ -554,42 +541,6 @@ pub fn anyImportedOnly(
return now_res.source == .imported; 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( pub fn runBands(
io: std.Io, io: std.Io,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
@ -598,7 +549,6 @@ pub fn runBands(
opts: BandsOptions, opts: BandsOptions,
color: bool, color: bool,
out: *std.Io.Writer, out: *std.Io.Writer,
kitty_caps: ?term_query.Caps,
) !void { ) !void {
// Single arena for all view/render allocations. Same lifetime // Single arena for all view/render allocations. Same lifetime
// regardless of live vs. as-of path. // regardless of live vs. as-of path.
@ -715,16 +665,16 @@ pub fn runBands(
// Translate the view-layer overlay points (if any) into the // Translate the view-layer overlay points (if any) into the
// chart-module's ActualsPoint shape. Same conversion the TUI // chart-module's ActualsPoint shape. Same conversion the TUI
// does in `projections_tab.drawWithKittyChart`. // does in `projections_tab.drawWithKittyChart`.
var overlay_buf: ?[]@import("../charts/projection_chart.zig").ActualsPoint = null; var overlay_buf: ?[]@import("../tui/projection_chart.zig").ActualsPoint = null;
defer if (overlay_buf) |ob| va.free(ob); defer if (overlay_buf) |ob| va.free(ob);
const overlay_input = blk: { const overlay_input = blk: {
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null); const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../tui/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); const buf = va.alloc(@import("../tui/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
for (ov.points, 0..) |p, i| { for (ov.points, 0..) |p, i| {
buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid }; buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
} }
overlay_buf = buf; overlay_buf = buf;
break :blk @import("../charts/projection_chart.zig").ActualsOverlay{ break :blk @import("../tui/projection_chart.zig").ActualsOverlay{
.points = buf, .points = buf,
.today_years = ov.today_years, .today_years = ov.today_years,
}; };
@ -759,17 +709,6 @@ pub fn runBands(
} }
try out.print("========================================\n", .{}); 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 // If auto-snapped, print a muted note so the user knows the
// requested date wasn't an exact hit. The wording reflects the // requested date wasn't an exact hit. The wording reflects the
// resolution source - "nearest snapshot" vs "nearest imported // resolution source - "nearest snapshot" vs "nearest imported
@ -1963,7 +1902,7 @@ test "runBands: imported-only as_of scales today's composition and renders body"
.today = today, .today = today,
.overlay_actuals = false, .overlay_actuals = false,
.live = &ld, .live = &ld,
}, false, &stream, null); }, false, &stream);
const out = stream.buffered(); const out = stream.buffered();
// Header reflects the imported source, and the caveat explains // Header reflects the imported source, and the caveat explains
@ -1997,7 +1936,7 @@ test "runBands: imported-only as_of without live data returns cleanly" {
.today = Date.fromYmd(2026, 3, 13), .today = Date.fromYmd(2026, 3, 13),
.overlay_actuals = false, .overlay_actuals = false,
.live = null, .live = null,
}, false, &stream, null); }, false, &stream);
// The helper printed a clear stderr message (swallowed by // The helper printed a clear stderr message (swallowed by
// cli.stderrPrint) and returned without body output. // cli.stderrPrint) and returned without body output.
@ -2190,7 +2129,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" {
var stream = std.Io.Writer.fixed(&buf); var stream = std.Io.Writer.fixed(&buf);
const d = Date.fromYmd(2026, 3, 13); 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, null); try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
// No body output because the resolution failed - the stderr // No body output because the resolution failed - the stderr
// message is swallowed by `cli.stderrPrint` and doesn't land in // message is swallowed by `cli.stderrPrint` and doesn't land in
@ -2222,7 +2161,7 @@ test "run: as_of with matching snapshot produces body output" {
var buf: [32_768]u8 = undefined; var buf: [32_768]u8 = undefined;
var stream = std.Io.Writer.fixed(&buf); 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, null); try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
const out = stream.buffered(); const out = stream.buffered();
// Header should call out the as-of date explicitly. // Header should call out the as-of date explicitly.
@ -2253,7 +2192,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" {
var stream = std.Io.Writer.fixed(&buf); var stream = std.Io.Writer.fixed(&buf);
const requested = Date.fromYmd(2026, 3, 13); 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, null); try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = false }, false, &stream);
const out = stream.buffered(); const out = stream.buffered();
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null); try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);

View file

@ -5,10 +5,7 @@ const framework = @import("framework.zig");
const fmt = cli.fmt; const fmt = cli.fmt;
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const chart_export = @import("../chart_export.zig"); const chart_export = @import("../chart_export.zig");
const tui_chart = @import("../charts/chart.zig"); const tui_chart = @import("../tui/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 { pub const ParsedArgs = struct {
symbol: []const u8, symbol: []const u8,
@ -17,14 +14,6 @@ pub const ParsedArgs = struct {
/// z2d-rendered pixel buffers for the TUI; this flag just lands /// z2d-rendered pixel buffers for the TUI; this flag just lands
/// the same pixels in a file via z2d's PNG exporter. /// the same pixels in a file via z2d's PNG exporter.
export_chart: ?[]const u8 = null, export_chart: ?[]const u8 = null,
/// Chart start date from `--since` (resolved at parse time). Null
/// means the default window (last ~3 months). Accepts the same
/// grammar as `history --since`: `YYYY-MM-DD`, `N[WMQY]`, or `ytd`.
since: ?zfin.Date = null,
/// 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 = .{ pub const meta: framework.Meta = .{
@ -33,25 +22,17 @@ pub const meta: framework.Meta = .{
.synopsis = "Show latest quote with chart and 20-day history", .synopsis = "Show latest quote with chart and 20-day history",
.uppercase_first_arg = true, .uppercase_first_arg = true,
.help = .help =
\\Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>] \\Usage: zfin quote <SYMBOL> [--export-chart <PATH>]
\\ \\
\\Show the latest real-time quote for a symbol (Yahoo / TwelveData) \\Show the latest real-time quote for a symbol (Yahoo / TwelveData)
\\plus a price chart over a recent window (an inline Kitty image \\plus a braille price chart of the last 60 candles and a table
\\when the terminal supports it, braille otherwise) and a table \\of the last 20 trading days.
\\of the last 20 trading days. The chart spans the last ~3 months
\\by default; use --since to widen or narrow it.
\\ \\
\\If real-time fetch fails, falls back to the cached close. The \\If real-time fetch fails, falls back to the cached close. The
\\Yahoo path is free and unauthenticated; TwelveData requires \\Yahoo path is free and unauthenticated; TwelveData requires
\\TWELVEDATA_API_KEY. \\TWELVEDATA_API_KEY.
\\ \\
\\Options: \\Options:
\\ --since <WHEN> Start the price chart at WHEN instead of
\\ the default last ~3 months. Applies to
\\ both the inline chart and --export-chart.
\\ Accepts YYYY-MM-DD, a relative shortcut
\\ (1W/1M/1Q/1Y), or 'ytd'. The 20-day
\\ history table is unaffected.
\\ --export-chart <PATH> Render the price+Bollinger+RSI chart \\ --export-chart <PATH> Render the price+Bollinger+RSI chart
\\ to a PNG file at the given path \\ to a PNG file at the given path
\\ (1920x1080) and exit. No text output \\ (1920x1080) and exit. No text output
@ -61,11 +42,10 @@ pub const meta: framework.Meta = .{
\\Examples: \\Examples:
\\ zfin quote AAPL \\ zfin quote AAPL
\\ zfin quote spy # symbols are case-insensitive \\ zfin quote spy # symbols are case-insensitive
\\ zfin quote AAPL --since 1Y
\\ zfin quote AAPL --export-chart aapl.png \\ zfin quote AAPL --export-chart aapl.png
\\ \\
, ,
.user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue, InvalidDate }, .user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue },
}; };
/// Quote data extracted from the real-time API (or synthesized from candles). /// Quote data extracted from the real-time API (or synthesized from candles).
@ -82,18 +62,12 @@ pub const QuoteData = struct {
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var symbol: ?[]const u8 = null; var symbol: ?[]const u8 = null;
var export_chart: ?[]const u8 = null; var export_chart: ?[]const u8 = null;
var since: ?zfin.Date = null;
var since_raw: ?[]const u8 = null;
var i: usize = 0; var i: usize = 0;
while (i < cmd_args.len) : (i += 1) { while (i < cmd_args.len) : (i += 1) {
const a = cmd_args[i]; const a = cmd_args[i];
if (std.mem.eql(u8, a, "--export-chart")) { if (std.mem.eql(u8, a, "--export-chart")) {
export_chart = try cli.requireFlagValue(ctx.io, cmd_args, &i, a); export_chart = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
} else if (std.mem.eql(u8, a, "--since")) {
const value = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
since = cli.parseRequiredDateOrStderr(ctx.io, value, ctx.today, "--since") catch return error.InvalidDate;
since_raw = value;
} else if (a.len > 0 and a[0] == '-') { } else if (a.len > 0 and a[0] == '-') {
// Reject ANY leading-dash token we don't recognize, // Reject ANY leading-dash token we don't recognize,
// including single-dash ones like `-x`. Previously only // including single-dash ones like `-x`. Previously only
@ -116,7 +90,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n"); cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
return error.MissingSymbol; return error.MissingSymbol;
} }
return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since, .since_raw = since_raw }; return .{ .symbol = symbol.?, .export_chart = export_chart };
} }
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
@ -136,15 +110,19 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
defer candle_result.deinit(); defer candle_result.deinit();
const candles = candle_result.data; const candles = candle_result.data;
// Chart window: candles on/after the `--since` date (default: the // PNG export short-circuits all text rendering. Use the
// last ~3 months). `display_count` is how many recent candles get // longest timeframe the candle history can support - falling
// drawn; the overlays warm up over extra lookback (see emitQuoteKitty). // back to shorter ones until one fits - so the user gets the
const since_date = parsed.since orelse ctx.today.subtractMonths(3); // most chart context without having to think about it.
const display_count = fmt.filterCandlesFrom(candles, since_date).len;
// PNG export short-circuits all text rendering.
if (parsed.export_chart) |path| { if (parsed.export_chart) |path| {
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, path) catch |err| switch (err) { 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) {
error.InsufficientData => { error.InsufficientData => {
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n"); cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
return; return;
@ -201,23 +179,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
} else |_| {} } else |_| {}
} }
const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps }; try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out);
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 /// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
@ -258,52 +220,7 @@ fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.Classifica
return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null; return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null;
} }
/// How `display` draws the price chart. 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 {
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; const has_quote = quote != null;
// Header. The security name (when resolved) renders between the // Header. The security name (when resolved) renders between the
@ -341,33 +258,23 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
try out.print(" Low: ${d:.2}\n", .{low_val}); try out.print(" Low: ${d:.2}\n", .{low_val});
try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)}); try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)});
if (fmt.pctChange(price, prev_close)) |dc| { if (prev_close > 0) {
const change = price - prev_close;
const pct = (change / prev_close) * 100.0;
var chg_buf: [64]u8 = undefined; var chg_buf: [64]u8 = undefined;
try cli.printGainLoss(out, color, dc.change, " {s:<14} {s}\n", .{ "Change (1D):", fmt.fmtPriceChange(&chg_buf, dc.change, dc.pct) }); try cli.printGainLoss(out, color, change, " Change: {s}\n", .{fmt.fmtPriceChange(&chg_buf, change, 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) });
} }
} }
// Chart: inline kitty graphics when supported, else a braille price // Braille chart (60 columns, 10 rows)
// chart over the selected window (`display_count` recent candles).
if (candles.len >= 2) { if (candles.len >= 2) {
try out.print("\n", .{}); try out.print("\n", .{});
switch (chart_render) { const chart_days: usize = @min(candles.len, 60);
.braille => try renderBrailleCandles(allocator, out, color, candles, display_count), const chart_data = candles[candles.len - chart_days ..];
.kitty => |k| emitQuoteKitty(allocator, out, candles, display_count, k) catch |err| switch (err) { var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
error.InsufficientData => try renderBrailleCandles(allocator, out, color, candles, display_count), if (chart) |*ch| {
else => return err, defer ch.deinit(allocator);
}, try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED, false);
} }
} }
@ -445,49 +352,6 @@ test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args)); try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
} }
test "parseArgs: --since accepts an explicit YYYY-MM-DD" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
const args = [_][]const u8{ "AAPL", "--since", "2025-01-15" };
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.since.?.eql(zfin.Date.fromYmd(2025, 1, 15)));
}
test "parseArgs: --since accepts a relative shortcut" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
const args = [_][]const u8{ "AAPL", "--since", "1Y" };
const parsed = try parseArgs(&ctx, &args);
// 1Y back from 2026-05-08 is 2025-05-08 (calendar-year subtraction).
try std.testing.expect(parsed.since.?.eql(zfin.Date.fromYmd(2025, 5, 8)));
}
test "parseArgs: --since defaults to null when omitted" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"AAPL"};
const parsed = try parseArgs(&ctx, &args);
try std.testing.expect(parsed.since == null);
}
test "parseArgs: --since without a value is rejected" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
const args = [_][]const u8{ "AAPL", "--since" };
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
}
test "parseArgs: --since with an invalid value is rejected" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
const args = [_][]const u8{ "AAPL", "--since", "garbage" };
try std.testing.expectError(error.InvalidDate, parseArgs(&ctx, &args));
}
test "display with candles only" { test "display with candles only" {
var buf: [8192]u8 = undefined; var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
@ -495,7 +359,7 @@ test "display with candles only" {
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
.{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 }, .{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
}; };
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null); try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
@ -503,24 +367,6 @@ test "display with candles only" {
try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days shown") != null); 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" { test "display with quote data" {
var buf: [8192]u8 = undefined; var buf: [8192]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf); var w: std.Io.Writer = .fixed(&buf);
@ -534,7 +380,7 @@ test "display with quote data" {
.prev_close = 172.00, .prev_close = 172.00,
.date = .{ .days = 20001 }, .date = .{ .days = 20001 },
}; };
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
@ -548,7 +394,7 @@ test "display renders the security name when provided" {
const candles = [_]zfin.Candle{ const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
}; };
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered(); const out = w.buffered();
// Name appears between the symbol and the price. // Name appears between the symbol and the price.
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null);
@ -560,7 +406,7 @@ test "display omits an empty name" {
const candles = [_]zfin.Candle{ const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 }, .{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
}; };
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered(); const out = w.buffered();
// No double-space orphan where the name would have gone. // No double-space orphan where the name would have gone.
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null);
@ -572,7 +418,7 @@ test "display no ANSI without color" {
const candles = [_]zfin.Candle{ const candles = [_]zfin.Candle{
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 }, .{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
}; };
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille); try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
const out = w.buffered(); const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
} }

View file

@ -512,37 +512,6 @@ pub fn filterCandlesFrom(candles: []const Candle, from: Date) []const Candle {
return candles[lo..]; 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 ────────────────────────────────────────── // ── Options helpers ──────────────────────────────────────────
/// Filter options contracts to +/- N strikes from ATM. /// Filter options contracts to +/- N strikes from ATM.
@ -1336,64 +1305,6 @@ test "filterCandlesFrom" {
try std.testing.expectEqual(@as(usize, 0), from_empty.len); 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" { test "filterNearMoney" {
const exp = Date.fromYmd(2024, 3, 15); const exp = Date.fromYmd(2024, 3, 15);
const contracts = [_]OptionContract{ const contracts = [_]OptionContract{

View file

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

View file

@ -26,8 +26,6 @@ const std = @import("std");
const zeit = @import("zeit"); const zeit = @import("zeit");
const Date = @import("Date.zig"); 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 /// POSIX TZ spec for US Eastern: EST (UTC-5) / EDT (UTC-4) with the
/// current US DST rule (2nd Sunday March -> 1st Sunday November). /// current US DST rule (2nd Sunday March -> 1st Sunday November).
const eastern_posix_spec = "EST5EDT,M3.2.0,M11.1.0"; const eastern_posix_spec = "EST5EDT,M3.2.0,M11.1.0";
@ -353,137 +351,6 @@ fn etLocalToUtc(d: Date, tod_s: i64) i64 {
return utc; 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 ──────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────
const testing = std.testing; const testing = std.testing;
@ -736,56 +603,3 @@ test "candleFreshness mutual_fund: late NAV is lagging" {
// Cache already has Monday -> nothing newer is due -> current. // Cache already has Monday -> nothing newer is due -> current.
try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .mutual_fund, Date.fromYmd(2025, 6, 16))); 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));
}

View file

@ -1,190 +0,0 @@
//! 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"));
}

View file

@ -1,95 +0,0 @@
//! 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 tab_framework = @import("tui/tab_framework.zig");
const framework = @import("commands/framework.zig"); const framework = @import("commands/framework.zig");
const theme = @import("tui/theme.zig"); const theme = @import("tui/theme.zig");
const chart = @import("charts/chart.zig"); const chart = @import("tui/chart.zig");
const input_buffer = @import("tui/input_buffer.zig"); const input_buffer = @import("tui/input_buffer.zig");
pub const PortfolioData = @import("PortfolioData.zig"); pub const PortfolioData = @import("PortfolioData.zig");
@ -494,12 +494,6 @@ pub const App = struct {
/// deterministic within a single frame and avoids threading `io` /// deterministic within a single frame and avoids threading `io`
/// through pure date-consuming helpers like `positions()`. /// through pure date-consuming helpers like `positions()`.
today: zfin.Date, 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, config: zfin.Config,
svc: *zfin.DataService, svc: *zfin.DataService,
keymap: keybinds.KeyMap, keymap: keybinds.KeyMap,
@ -1423,8 +1417,8 @@ pub const App = struct {
/// flight, shows the in-progress indicator. Otherwise: a /// flight, shows the in-progress indicator. Otherwise: a
/// user-set message if present, else a dynamic default hint /// user-set message if present, else a dynamic default hint
/// (global keys + the active tab's `status_hints`), prefixed /// (global keys + the active tab's `status_hints`), prefixed
/// with a "refreshed at <local clock>" stamp once a refresh has /// with a "refreshed Xs ago" readout once a refresh has run.
/// run. Allocated in `arena` for the dynamic forms; the user-set /// Allocated in `arena` for the dynamic forms; the user-set
/// buffer is returned by reference. /// buffer is returned by reference.
fn getStatus(self: *App, arena: std.mem.Allocator) []const u8 { fn getStatus(self: *App, arena: std.mem.Allocator) []const u8 {
if (self.refresh_pending) return "Refreshing..."; if (self.refresh_pending) return "Refreshing...";
@ -1432,17 +1426,14 @@ pub const App = struct {
const hint = self.buildDefaultStatusHint(arena) catch const hint = self.buildDefaultStatusHint(arena) catch
"h/l tabs | j/k select | / symbol | ? help"; "h/l tabs | j/k select | / symbol | ? help";
if (self.last_refresh_s > 0) { if (self.last_refresh_s > 0) {
// wall-clock required: per-frame "now" to decide whether // wall-clock required: per-frame "now" for the relative
// the refresh stamp needs a date prefix (refreshed on an // "refreshed Xs ago" readout.
// 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(); const now_s = std.Io.Timestamp.now(self.io, .real).toSeconds();
// Absolute local-time stamp rather than a relative "Xs var ago_buf: [24]u8 = undefined;
// ago": with no auto-refresh, a relative readout silently const ago = fmt.fmtTimeAgo(&ago_buf, self.last_refresh_s, now_s);
// goes stale while the screen sits idle. if (ago.len > 0) {
var stamp_buf: [32]u8 = undefined; return std.fmt.allocPrint(arena, "refreshed {s} | {s}", .{ ago, hint }) catch hint;
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; return hint;
} }
@ -1516,9 +1507,6 @@ pub const App = struct {
Module.tab.deinit(state_ptr, self); Module.tab.deinit(state_ptr, self);
} }
self.portfolio.deinit(); 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 { fn reloadPortfolioFile(self: *App) void {
@ -1634,9 +1622,10 @@ pub const App = struct {
} }
/// Whether the given tab should be treated as disabled in /// Whether the given tab should be treated as disabled in
/// the current App context. All tabs are consulted via their /// the current App context. All migrated tabs are consulted
/// framework-contract `isDisabled` hook (tabs without one /// via their framework-contract `isDisabled` hook. (Portfolio
/// default to always-enabled). /// is the only remaining unmigrated tab; it has no disabled
/// predicate today.)
fn isDisabled(self: *App, t: Tab) bool { fn isDisabled(self: *App, t: Tab) bool {
return self.appPredicate(t, "isDisabled"); return self.appPredicate(t, "isDisabled");
} }
@ -2520,17 +2509,10 @@ pub fn run(
var app_inst = try allocator.create(App); var app_inst = try allocator.create(App);
defer allocator.destroy(app_inst); 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.* = .{ app_inst.* = .{
.allocator = allocator, .allocator = allocator,
.io = io, .io = io,
.today = today, .today = today,
.local_tz = local_tz,
.config = config, .config = config,
.svc = svc, .svc = svc,
.keymap = keymap, .keymap = keymap,
@ -2555,17 +2537,6 @@ pub fn run(
if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| { if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| {
resolved_pf_paths = rp; resolved_pf_paths = rp;
} else |_| {} } 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; var resolved_wl: ?zfin.Config.ResolvedPath = null;

View file

@ -5,9 +5,7 @@
const std = @import("std"); const std = @import("std");
const z2d = @import("z2d"); const z2d = @import("z2d");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const theme = @import("../tui/theme.zig"); const theme = @import("theme.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const Surface = z2d.Surface; const Surface = z2d.Surface;
@ -51,7 +49,6 @@ const Pixel = z2d.Pixel;
/// Chart timeframe selection. /// Chart timeframe selection.
pub const Timeframe = enum { pub const Timeframe = enum {
@"3M",
@"6M", @"6M",
ytd, ytd,
@"1Y", @"1Y",
@ -60,7 +57,6 @@ pub const Timeframe = enum {
pub fn label(self: Timeframe) []const u8 { pub fn label(self: Timeframe) []const u8 {
return switch (self) { return switch (self) {
.@"3M" => "3M",
.@"6M" => "6M", .@"6M" => "6M",
.ytd => "YTD", .ytd => "YTD",
.@"1Y" => "1Y", .@"1Y" => "1Y",
@ -71,7 +67,6 @@ pub const Timeframe = enum {
pub fn tradingDays(self: Timeframe) usize { pub fn tradingDays(self: Timeframe) usize {
return switch (self) { return switch (self) {
.@"3M" => 63,
.@"6M" => 126, .@"6M" => 126,
.ytd => 252, // approximation, we'll clamp .ytd => 252, // approximation, we'll clamp
.@"1Y" => 252, .@"1Y" => 252,
@ -82,19 +77,17 @@ pub const Timeframe = enum {
pub fn next(self: Timeframe) Timeframe { pub fn next(self: Timeframe) Timeframe {
return switch (self) { return switch (self) {
.@"3M" => .@"6M",
.@"6M" => .ytd, .@"6M" => .ytd,
.ytd => .@"1Y", .ytd => .@"1Y",
.@"1Y" => .@"3Y", .@"1Y" => .@"3Y",
.@"3Y" => .@"5Y", .@"3Y" => .@"5Y",
.@"5Y" => .@"3M", .@"5Y" => .@"6M",
}; };
} }
pub fn prev(self: Timeframe) Timeframe { pub fn prev(self: Timeframe) Timeframe {
return switch (self) { return switch (self) {
.@"3M" => .@"5Y", .@"6M" => .@"5Y",
.@"6M" => .@"3M",
.ytd => .@"6M", .ytd => .@"6M",
.@"1Y" => .ytd, .@"1Y" => .ytd,
.@"3Y" => .@"1Y", .@"3Y" => .@"1Y",
@ -180,51 +173,6 @@ pub fn computeIndicators(
}; };
} }
/// Like `computeIndicators`, but for a fixed *display count* of the most
/// recent candles, computing the Bollinger/RSI series over an extra
/// `warmup` candles of lookback so the overlays are valid from the first
/// displayed candle (no warm-up gap). Returns owned arrays of length
/// `min(candles.len, display_count)`, aligned with the last that many
/// candles. Pair with `renderToSurface(candles[len-n..], null, ...)`.
pub fn computeIndicatorsWarmup(
alloc: std.mem.Allocator,
candles: []const zfin.Candle,
display_count: usize,
warmup: usize,
) !CachedIndicators {
if (candles.len < 20) return error.InsufficientData;
const n = @min(candles.len, display_count);
const m = @min(candles.len, n + warmup);
const window = candles[candles.len - m ..];
const closes_w = try zfin.indicators.closePrices(alloc, window);
defer alloc.free(closes_w);
const vols_w = try zfin.indicators.volumes(alloc, window);
defer alloc.free(vols_w);
const bb_w = try zfin.indicators.bollingerBands(alloc, closes_w, 20, 2.0);
defer alloc.free(bb_w);
const rsi_w = try zfin.indicators.rsi(alloc, closes_w, 14);
defer alloc.free(rsi_w);
// Keep only the last `n` of each (the displayed candles); the leading
// `off` were lookback for the overlays. Dupe into clean owned arrays
// so the result has a normal `deinit` (no views into freed buffers).
const off = m - n;
const closes = try alloc.dupe(f64, closes_w[off..]);
errdefer alloc.free(closes);
const vols = try alloc.dupe(f64, vols_w[off..]);
errdefer alloc.free(vols);
const bb = try alloc.dupe(?zfin.indicators.BollingerBand, bb_w[off..]);
errdefer alloc.free(bb);
const rsi_vals = try alloc.dupe(?f64, rsi_w[off..]);
return .{
.closes = closes,
.volumes = vols,
.bb = bb,
.rsi_vals = rsi_vals,
};
}
/// Render a complete financial chart to raw RGB pixel data. /// Render a complete financial chart to raw RGB pixel data.
/// The returned rgb_data is allocated with `alloc` and must be freed by caller. /// The returned rgb_data is allocated with `alloc` and must be freed by caller.
/// If `cached` is provided, uses pre-computed indicators instead of recomputing. /// If `cached` is provided, uses pre-computed indicators instead of recomputing.
@ -248,7 +196,17 @@ pub const RenderedChart = struct {
/// Caller owns the returned slice. The surface is left intact so /// Caller owns the returned slice. The surface is left intact so
/// the caller can still call `deinit`. /// the caller can still call `deinit`.
pub fn extractRgb(self: *const RenderedChart, alloc: std.mem.Allocator) ![]u8 { pub fn extractRgb(self: *const RenderedChart, alloc: std.mem.Allocator) ![]u8 {
return draw.extractRgb(alloc, &self.surface); 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;
} }
}; };
@ -264,18 +222,17 @@ pub fn renderToSurface(
io: std.Io, io: std.Io,
alloc: std.mem.Allocator, alloc: std.mem.Allocator,
candles: []const zfin.Candle, candles: []const zfin.Candle,
timeframe: ?Timeframe, timeframe: Timeframe,
width_px: u32, width_px: u32,
height_px: u32, height_px: u32,
th: theme.Theme, th: theme.Theme,
cached: ?*const CachedIndicators, cached: ?*const CachedIndicators,
axis_labels: bool,
) !RenderedChart { ) !RenderedChart {
if (candles.len < 20) return error.InsufficientData; if (candles.len < 20) return error.InsufficientData;
// Render the timeframe's slice, or all of `candles` when the caller // Slice candles to timeframe
// already sliced (timeframe == null - the CLI's date-based path). const max_days = timeframe.tradingDays();
const n = if (timeframe) |tf| @min(candles.len, tf.tradingDays()) else candles.len; const n = @min(candles.len, max_days);
const data = candles[candles.len - n ..]; const data = candles[candles.len - n ..];
// Use cached indicators or compute fresh ones // Use cached indicators or compute fresh ones
@ -330,23 +287,21 @@ pub fn renderToSurface(
const fheight: f64 = @floatFromInt(height_px); const fheight: f64 = @floatFromInt(height_px);
// Background // Background
try draw.fillBackground(&ctx, fwidth, fheight, bg); 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();
// Panel dimensions. With axis labels we reserve a left margin // Panel dimensions
// (price dollar ticks) and a bottom margin (start/end dates), const chart_left = margin_left;
// scaled to the surface so labels stay legible on large exports. const chart_right = fwidth - margin_right;
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_w = chart_right - chart_left;
const chart_top = m_top; const chart_top = margin_top;
const total_h = fheight - m_top - m_bottom; const total_h = fheight - margin_top - margin_bottom;
const price_h = total_h * price_frac; const price_h = total_h * price_frac;
const price_top = chart_top; const price_top = chart_top;
@ -549,18 +504,6 @@ 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 .{ return .{
.surface = sfc, .surface = sfc,
.width = @intCast(width_px), .width = @intCast(width_px),
@ -584,7 +527,7 @@ pub fn renderChart(
th: theme.Theme, th: theme.Theme,
cached: ?*const CachedIndicators, cached: ?*const CachedIndicators,
) !ChartResult { ) !ChartResult {
var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached, false); var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached);
defer rendered.deinit(alloc); defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc); const raw = try rendered.extractRgb(alloc);
return .{ return .{
@ -598,14 +541,29 @@ pub fn renderChart(
} }
// ── Drawing helpers ─────────────────────────────────────────────────── // ── 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; fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
const blendColor = draw.blendColor; if (max_val == min_val) return (top_px + bottom_px) / 2;
const opaqueColor = draw.opaqueColor; 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 BandField = enum { upper, middle, lower }; const BandField = enum { upper, middle, lower };
@ -648,12 +606,90 @@ fn drawLineSeries(
ctx.setLineWidth(2.0); ctx.setLineWidth(2.0);
} }
const drawHorizontalGridLines = draw.drawHorizontalGridLines; fn drawHorizontalGridLines(
const drawHLine = draw.drawHLine; ctx: *Context,
const drawRect = draw.drawRect; 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);
}
// ── Tests ───────────────────────────────────────────────────────────── // ── 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" { test "ChartConfig.parse" {
// Named modes // Named modes
const auto = ChartConfig.parse("auto").?; const auto = ChartConfig.parse("auto").?;
@ -676,38 +712,22 @@ test "ChartConfig.parse" {
} }
test "Timeframe next/prev cycle" { test "Timeframe next/prev cycle" {
// next cycles: 3M -> 6M -> ytd -> 1Y -> 3Y -> 5Y -> 3M // next cycles through all values
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"3M".next());
try std.testing.expectEqual(Timeframe.ytd, Timeframe.@"6M".next()); try std.testing.expectEqual(Timeframe.ytd, Timeframe.@"6M".next());
try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.next()); try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.next());
try std.testing.expectEqual(Timeframe.@"3M", Timeframe.@"5Y".next()); // wraps try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"5Y".next()); // wraps
// prev is the reverse // prev is the reverse
try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"3M".prev()); // wraps try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"6M".prev()); // wraps
try std.testing.expectEqual(Timeframe.@"3M", Timeframe.@"6M".prev());
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev()); try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev());
} }
test "Timeframe tradingDays" { test "Timeframe tradingDays" {
try std.testing.expectEqual(@as(usize, 63), Timeframe.@"3M".tradingDays());
try std.testing.expectEqual(@as(usize, 126), Timeframe.@"6M".tradingDays()); try std.testing.expectEqual(@as(usize, 126), Timeframe.@"6M".tradingDays());
try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays()); try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays());
try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays()); try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays());
} }
test "computeIndicatorsWarmup: overlays valid from the first displayed candle" {
var candles: [80]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0);
var cached = try computeIndicatorsWarmup(test_alloc, &candles, 60, 20);
defer cached.deinit(test_alloc);
// Displays the last 60; the 20-candle lookback means the first
// displayed point already carries a Bollinger band and an RSI value.
try std.testing.expectEqual(@as(usize, 60), cached.closes.len);
try std.testing.expectEqual(@as(usize, 60), cached.bb.len);
try std.testing.expect(cached.bb[0] != null);
try std.testing.expect(cached.rsi_vals[0] != null);
}
// ── renderToSurface tests ───────────────────────────────────────────── // ── renderToSurface tests ─────────────────────────────────────────────
// //
// These exercise the actual chart rendering pipeline (z2d surface + // These exercise the actual chart rendering pipeline (z2d surface +
@ -738,7 +758,7 @@ fn buildLinearCandles(arr: []zfin.Candle, start_price: f64) void {
test "renderToSurface returns InsufficientData with < 20 candles" { test "renderToSurface returns InsufficientData with < 20 candles" {
var candles: [10]zfin.Candle = undefined; var candles: [10]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0); buildLinearCandles(&candles, 100.0);
const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
try std.testing.expectError(error.InsufficientData, result); try std.testing.expectError(error.InsufficientData, result);
} }
@ -746,7 +766,7 @@ test "renderToSurface produces a populated surface at requested dimensions" {
var candles: [30]zfin.Candle = undefined; var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0); buildLinearCandles(&candles, 100.0);
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
defer rendered.deinit(test_alloc); defer rendered.deinit(test_alloc);
try std.testing.expectEqual(@as(u16, 200), rendered.width); try std.testing.expectEqual(@as(u16, 200), rendered.width);
@ -762,7 +782,7 @@ test "renderToSurface price range covers the input close range" {
var candles: [30]zfin.Candle = undefined; var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0); // closes: 100..129 buildLinearCandles(&candles, 100.0); // closes: 100..129
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
defer rendered.deinit(test_alloc); defer rendered.deinit(test_alloc);
// 5% padding is applied inside renderToSurface, so the recorded // 5% padding is applied inside renderToSurface, so the recorded
@ -792,7 +812,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, false); var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
defer rendered.deinit(test_alloc); defer rendered.deinit(test_alloc);
// With chartClose, max should be near 100 - definitely not 250+. // With chartClose, max should be near 100 - definitely not 250+.
@ -809,7 +829,7 @@ test "renderToSurface fills background with theme bg" {
var th = theme.default_theme; var th = theme.default_theme;
th.bg = .{ 0x12, 0x34, 0x56 }; th.bg = .{ 0x12, 0x34, 0x56 };
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null, false); var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null);
defer rendered.deinit(test_alloc); defer rendered.deinit(test_alloc);
// Pixel at (0, 0) is in the top-left margin - outside the chart // Pixel at (0, 0) is in the top-left margin - outside the chart
@ -829,9 +849,9 @@ test "renderToSurface is deterministic across two calls with same input" {
var candles: [30]zfin.Candle = undefined; var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0); buildLinearCandles(&candles, 100.0);
var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
defer a.deinit(test_alloc); defer a.deinit(test_alloc);
var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false); var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
defer b.deinit(test_alloc); defer b.deinit(test_alloc);
const buf_a = switch (a.surface) { const buf_a = switch (a.surface) {
@ -857,7 +877,7 @@ test "RenderedChart.extractRgb produces 3 bytes per pixel matching surface buffe
var candles: [30]zfin.Candle = undefined; var candles: [30]zfin.Candle = undefined;
buildLinearCandles(&candles, 100.0); buildLinearCandles(&candles, 100.0);
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null, false); var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null);
defer rendered.deinit(test_alloc); defer rendered.deinit(test_alloc);
const raw = try rendered.extractRgb(test_alloc); const raw = try rendered.extractRgb(test_alloc);

View file

@ -27,10 +27,9 @@
const std = @import("std"); const std = @import("std");
const z2d = @import("z2d"); const z2d = @import("z2d");
const theme = @import("../tui/theme.zig"); const theme = @import("theme.zig");
const forecast = @import("../analytics/forecast_evaluation.zig"); const forecast = @import("../analytics/forecast_evaluation.zig");
const Date = @import("../Date.zig"); const Date = @import("../Date.zig");
const draw = @import("draw.zig");
const Surface = z2d.Surface; const Surface = z2d.Surface;
const Context = z2d.Context; const Context = z2d.Context;
@ -93,7 +92,14 @@ pub fn renderConvergenceChart(
const fheight: f64 = @floatFromInt(height_px); const fheight: f64 = @floatFromInt(height_px);
// Background // Background
try draw.fillBackground(&ctx, fwidth, fheight, bg); 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();
const chart_left = margin_left; const chart_left = margin_left;
const chart_right = fwidth - margin_right; const chart_right = fwidth - margin_right;
@ -238,7 +244,14 @@ pub fn renderBacktestChart(
const fheight: f64 = @floatFromInt(height_px); const fheight: f64 = @floatFromInt(height_px);
// Background // Background
try draw.fillBackground(&ctx, fwidth, fheight, bg); 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();
const chart_left = margin_left; const chart_left = margin_left;
const chart_right = fwidth - margin_right; const chart_right = fwidth - margin_right;
@ -479,20 +492,90 @@ fn fillCircle(ctx: *Context, cx: f64, cy: f64, r: f64) !void {
try ctx.fill(); try ctx.fill();
} }
// ── Shared helpers ──────────────────────────────────────────────────── // ── Shared helpers (mirrors of projection_chart's privates) ───
//
// 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.
const mapY = draw.mapY; fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
const blendColor = draw.blendColor; if (max_val == min_val) return (top_px + bottom_px) / 2;
const opaqueColor = draw.opaqueColor; const norm = (value - min_val) / (max_val - min_val);
const drawHorizontalGridLines = draw.drawHorizontalGridLines; return bottom_px - norm * (bottom_px - top_px);
const drawHLine = draw.drawHLine; }
const drawRect = draw.drawRect;
const extractRgb = draw.extractRgb; 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;
}
// ── Tests ───────────────────────────────────────────────────── // ── Tests ─────────────────────────────────────────────────────

View file

@ -330,16 +330,6 @@ pub const tab = struct {
pub const deactivate = framework.noopDeactivate(State); 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 /// Manual refresh (r/F5): re-value the portfolio with live
/// intraday quotes, then rebuild the summary. `r` means "give me /// intraday quotes, then rebuild the summary. `r` means "give me
/// current prices now" - it does NOT force candle work. Candle /// current prices now" - it does NOT force candle work. Candle
@ -387,15 +377,10 @@ pub const tab = struct {
// falls back to the candle last close for those. // falls back to the candle last close for those.
var live = app.svc.loadLiveQuotes(quote_syms.items); var live = app.svc.loadLiveQuotes(quote_syms.items);
defer live.deinit(); 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, .{ _ = app.portfolio.reload(app.today, .{
.watchlist_syms = watch_syms.items, .watchlist_syms = watch_syms.items,
.live_quotes = &live, .live_quotes = &live,
.live_quotes_at_s = live_at_s,
}) catch |err| { }) catch |err| {
app.setStatus("Error refreshing portfolio data"); app.setStatus("Error refreshing portfolio data");
std.log.scoped(.tui).warn("portfolio.reload: {t}", .{err}); std.log.scoped(.tui).warn("portfolio.reload: {t}", .{err});
@ -476,6 +461,11 @@ 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. /// Drop UI state that referenced the previous portfolio.
/// ///
/// `account_list` holds borrowed strings into the old /// `account_list` holds borrowed strings into the old
@ -1350,34 +1340,6 @@ fn computeFilteredTotals(state: *const State, app: *const App) FilteredTotals {
// ── Rendering ───────────────────────────────────────────────── // ── 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 { 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. // Modal sub-state takes over the content surface entirely.
// Picker overlay replaces the portfolio table while open. // Picker overlay replaces the portfolio table while open.
@ -1420,7 +1382,15 @@ 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(); const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
try lines.append(arena, .{ .text = summary_text, .style = summary_style }); try lines.append(arena, .{ .text = summary_text, .style = summary_style });
try appendAsOfLine(arena, &lines, app); 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() });
}
// No historical snapshots or net worth when filtered // No historical snapshots or net worth when filtered
} else { } else {
// Unfiltered mode: use portfolio_summary totals directly // Unfiltered mode: use portfolio_summary totals directly
@ -1436,7 +1406,15 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
try lines.append(arena, .{ .text = summary_text, .style = summary_style }); try lines.append(arena, .{ .text = summary_text, .style = summary_style });
// "as of" date indicator // "as of" date indicator
try appendAsOfLine(arena, &lines, app); 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() });
}
// Net Worth line (only if portfolio has illiquid assets) // Net Worth line (only if portfolio has illiquid assets)
if (app.portfolio.file) |pf| { if (app.portfolio.file) |pf| {
@ -1910,17 +1888,6 @@ pub fn buildWelcomeScreenLines(
/// Inactive tabs stay in `loaded = false` and lazy-rebuild on /// Inactive tabs stay in `loaded = false` and lazy-rebuild on
/// next switch. /// next switch.
pub fn reloadPortfolioFile(state: *State, app: *App) void { 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) { if (app.portfolio.paths.len == 0) {
app.setStatus("No portfolio file to reload"); app.setStatus("No portfolio file to reload");
return; return;

View file

@ -16,13 +16,12 @@
const std = @import("std"); const std = @import("std");
const z2d = @import("z2d"); const z2d = @import("z2d");
const theme = @import("../tui/theme.zig"); const theme = @import("theme.zig");
const projections = @import("../analytics/projections.zig"); const projections = @import("../analytics/projections.zig");
const draw = @import("draw.zig");
const axis = @import("axis.zig");
const Surface = z2d.Surface; const Surface = z2d.Surface;
const Context = z2d.Context; const Context = z2d.Context;
const Pixel = z2d.Pixel;
/// Margins in pixels. /// Margins in pixels.
const margin_left: f64 = 4; const margin_left: f64 = 4;
@ -78,7 +77,17 @@ pub const RenderedProjection = struct {
/// Extract a flat []u8 of R,G,B triplets from the surface buffer. /// Extract a flat []u8 of R,G,B triplets from the surface buffer.
/// Caller owns the returned slice. The surface is left intact. /// Caller owns the returned slice. The surface is left intact.
pub fn extractRgb(self: *const RenderedProjection, alloc: std.mem.Allocator) ![]u8 { pub fn extractRgb(self: *const RenderedProjection, alloc: std.mem.Allocator) ![]u8 {
return draw.extractRgb(alloc, &self.surface); 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;
} }
}; };
@ -98,7 +107,6 @@ pub fn renderToSurface(
height_px: u32, height_px: u32,
th: theme.Theme, th: theme.Theme,
actuals: ?ActualsOverlay, actuals: ?ActualsOverlay,
axis_labels: bool,
) !RenderedProjection { ) !RenderedProjection {
if (bands.len < 2) return error.InsufficientData; if (bands.len < 2) return error.InsufficientData;
@ -118,21 +126,21 @@ pub fn renderToSurface(
const fheight: f64 = @floatFromInt(height_px); const fheight: f64 = @floatFromInt(height_px);
// Background // Background
try draw.fillBackground(&ctx, fwidth, fheight, bg); 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();
// Chart area. With axis labels we reserve a left margin (dollar // Chart area
// ticks) and a bottom margin (year endpoints), scaled to the surface. const chart_left = margin_left;
const label_scale: i32 = axis.labelScale(h); const chart_right = fwidth - margin_right;
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_w = chart_right - chart_left;
const chart_top = m_top; const chart_top = margin_top;
const chart_bottom = fheight - m_bottom; const chart_bottom = fheight - margin_bottom;
// Compute value range from all bands // Compute value range from all bands
var value_min: f64 = bands[0].p10; var value_min: f64 = bands[0].p10;
@ -309,17 +317,6 @@ pub fn renderToSurface(
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0); 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 .{ return .{
.surface = sfc, .surface = sfc,
.width = @intCast(width_px), .width = @intCast(width_px),
@ -344,7 +341,7 @@ pub fn renderProjectionChart(
th: theme.Theme, th: theme.Theme,
actuals: ?ActualsOverlay, actuals: ?ActualsOverlay,
) !ProjectionChartResult { ) !ProjectionChartResult {
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false); var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals);
defer rendered.deinit(alloc); defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc); const raw = try rendered.extractRgb(alloc);
return .{ return .{
@ -357,21 +354,105 @@ pub fn renderProjectionChart(
} }
// ── Drawing helpers ─────────────────────────────────────────────────── // ── 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; fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
const blendColor = draw.blendColor; if (max_val == min_val) return (top_px + bottom_px) / 2;
const opaqueColor = draw.opaqueColor; const norm = (value - min_val) / (max_val - min_val);
const drawHorizontalGridLines = draw.drawHorizontalGridLines; return bottom_px - norm * (bottom_px - top_px);
const drawHLine = draw.drawHLine; }
const drawVLine = draw.drawVLine;
const drawRect = draw.drawRect; 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);
}
// ── Tests ───────────────────────────────────────────────────────────── // ── 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" { test "renderProjectionChart produces valid output" {
const alloc = std.testing.allocator; const alloc = std.testing.allocator;
const bands = [_]projections.YearPercentiles{ const bands = [_]projections.YearPercentiles{
@ -380,7 +461,7 @@ test "renderProjectionChart produces valid output" {
.{ .year = 20, .p10 = 3000000, .p25 = 9000000, .p50 = 18000000, .p75 = 30000000, .p90 = 50000000 }, .{ .year = 20, .p10 = 3000000, .p25 = 9000000, .p50 = 18000000, .p75 = 30000000, .p90 = 50000000 },
}; };
const th = @import("../tui/theme.zig").default_theme; const th = @import("theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null); const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
defer alloc.free(result.rgb_data); defer alloc.free(result.rgb_data);
@ -396,7 +477,7 @@ test "renderProjectionChart insufficient data" {
.{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 }, .{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 },
}; };
const th = @import("../tui/theme.zig").default_theme; const th = @import("theme.zig").default_theme;
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null); const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
try std.testing.expectError(error.InsufficientData, result); try std.testing.expectError(error.InsufficientData, result);
} }
@ -415,7 +496,7 @@ test "renderProjectionChart with overlay produces valid output" {
}; };
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 }; const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("../tui/theme.zig").default_theme; const th = @import("theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay); const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
defer alloc.free(result.rgb_data); defer alloc.free(result.rgb_data);
@ -438,7 +519,7 @@ test "renderProjectionChart overlay expands y-range when actuals exceed bands" {
}; };
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 }; const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
const th = @import("../tui/theme.zig").default_theme; const th = @import("theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay); const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
defer alloc.free(result.rgb_data); defer alloc.free(result.rgb_data);
@ -455,7 +536,7 @@ test "renderProjectionChart overlay with no points renders without crash" {
}; };
const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 }; const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 };
const th = @import("../tui/theme.zig").default_theme; const th = @import("theme.zig").default_theme;
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay); const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
defer alloc.free(result.rgb_data); defer alloc.free(result.rgb_data);
try std.testing.expect(result.rgb_data.len > 0); try std.testing.expect(result.rgb_data.len > 0);
@ -473,8 +554,8 @@ test "renderToSurface returns a populated RGB surface at requested dimensions" {
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }, .{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 }, .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
}; };
const th = @import("../tui/theme.zig").default_theme; const th = @import("theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false); var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null);
defer rendered.deinit(alloc); defer rendered.deinit(alloc);
try std.testing.expectEqual(@as(u16, 150), rendered.width); try std.testing.expectEqual(@as(u16, 150), rendered.width);
@ -491,10 +572,10 @@ test "renderToSurface fills background with theme bg" {
.{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 }, .{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
.{ .year = 1, .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("../tui/theme.zig").default_theme; var th = @import("theme.zig").default_theme;
th.bg = .{ 0xab, 0xcd, 0xef }; th.bg = .{ 0xab, 0xcd, 0xef };
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false); var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null);
defer rendered.deinit(alloc); defer rendered.deinit(alloc);
const buf = switch (rendered.surface) { const buf = switch (rendered.surface) {
@ -513,11 +594,11 @@ test "renderToSurface is deterministic across calls with same input" {
.{ .year = 5, .p10 = 90, .p25 = 110, .p50 = 130, .p75 = 160, .p90 = 200 }, .{ .year = 5, .p10 = 90, .p25 = 110, .p50 = 130, .p75 = 160, .p90 = 200 },
.{ .year = 10, .p10 = 80, .p25 = 120, .p50 = 160, .p75 = 220, .p90 = 300 }, .{ .year = 10, .p10 = 80, .p25 = 120, .p50 = 160, .p75 = 220, .p90 = 300 },
}; };
const th = @import("../tui/theme.zig").default_theme; const th = @import("theme.zig").default_theme;
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false); var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
defer a.deinit(alloc); defer a.deinit(alloc);
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false); var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
defer b.deinit(alloc); defer b.deinit(alloc);
const buf_a = switch (a.surface) { const buf_a = switch (a.surface) {
@ -543,8 +624,8 @@ test "RenderedProjection.extractRgb produces 3 bytes per pixel" {
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 }, .{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 }, .{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
}; };
const th = @import("../tui/theme.zig").default_theme; const th = @import("theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false); var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null);
defer rendered.deinit(alloc); defer rendered.deinit(alloc);
const raw = try rendered.extractRgb(alloc); const raw = try rendered.extractRgb(alloc);
@ -568,8 +649,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 = 0, .p10 = -100, .p25 = -50, .p50 = 0, .p75 = 50, .p90 = 100 },
.{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 }, .{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
}; };
const th = @import("../tui/theme.zig").default_theme; const th = @import("theme.zig").default_theme;
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false); var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
defer rendered.deinit(alloc); defer rendered.deinit(alloc);
// After 5% padding and the `if (value_min < 0) value_min = 0` // After 5% padding and the `if (value_min < 0) value_min = 0`

View file

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

View file

@ -4,7 +4,7 @@ const zfin = @import("../root.zig");
const fmt = @import("../format.zig"); const fmt = @import("../format.zig");
const Money = @import("../Money.zig"); const Money = @import("../Money.zig");
const theme = @import("theme.zig"); const theme = @import("theme.zig");
const chart = @import("../charts/chart.zig"); const chart = @import("chart.zig");
const tui = @import("../tui.zig"); const tui = @import("../tui.zig");
const framework = @import("tab_framework.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; const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
if (content_row != tf_row) return false; if (content_row != tf_row) return false;
// Layout: " Chart: [3M] 6M YTD 1Y 3Y 5Y ([ ] to change)" // Layout: " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
// Prefix " Chart: " is 9 chars. Each timeframe label takes // Prefix " Chart: " is 9 chars. Each timeframe label takes
// `label_len + 2` (brackets/spaces around the label) + 1 (gap). // `label_len + 2` (brackets/spaces around the label) + 1 (gap).
const col: usize = @intCast(mouse.col); const col: usize = @intCast(mouse.col);
const prefix_len: usize = 9; const prefix_len: usize = 9;
if (col < prefix_len) return false; if (col < prefix_len) return false;
const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
var x: usize = prefix_len; var x: usize = prefix_len;
for (timeframes) |tf| { for (timeframes) |tf| {
const lbl_len = tf.label().len; const lbl_len = tf.label().len;
@ -273,28 +273,28 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
else else
app.symbol; app.symbol;
// Symbol + price header. The headline "Change" reflects the // Symbol + price header
// 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| { if (app.states.quote.live) |q| {
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ sym_label, q.close }); 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() }); 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) { } else if (c.len > 0) {
const last = c[c.len - 1]; const last = c[c.len - 1];
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ sym_label, last.close }); 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() }); try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
} if (c.len >= 2) {
const prev_close = c[c.len - 2].close;
if (cur_price) |price| { const change = last.close - prev_close;
if (fmt.windowChange(c, cur_tf.tradingDays(), price)) |wc| { const pct = (change / prev_close) * 100.0;
var chg_buf: [64]u8 = undefined; var chg_buf: [64]u8 = undefined;
var lbl_buf: [24]u8 = undefined; const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{cur_tf.label()}) catch "Change:"; try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
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: "; const prefix = " Chart: ";
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix); @memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
tf_pos += prefix.len; tf_pos += prefix.len;
const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" }; const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
for (timeframes) |tf| { for (timeframes) |tf| {
const lbl = tf.label(); const lbl = tf.label();
if (tf == app.states.quote.chart.timeframe) { if (tf == app.states.quote.chart.timeframe) {
@ -388,15 +388,11 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
// Free old cache if it exists // Free old cache if it exists
app.states.quote.chart.freeCache(app.allocator); app.states.quote.chart.freeCache(app.allocator);
// Compute and cache new indicators. Use the warmup variant so // Compute and cache new indicators
// the Bollinger bands / RSI are computed over an extra lookback const new_cache = chart.computeIndicators(
// 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, app.allocator,
c, c,
app.states.quote.chart.timeframe.tradingDays(), app.states.quote.chart.timeframe,
20,
) catch |err| { ) catch |err| {
app.states.quote.chart.dirty = false; app.states.quote.chart.dirty = false;
var err_buf: [128]u8 = undefined; var err_buf: [128]u8 = undefined;
@ -574,10 +570,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
const price = if (quote_data) |q| q.close else latest.close; 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); const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
// `cur_tf` (the selected timeframe) is computed once at the try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close);
// 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 // Write detail lines into the buffer below the image
const detail_buf_start = detail_start_row * @as(usize, width); const detail_buf_start = detail_start_row * @as(usize, width);
@ -671,7 +664,7 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
{ {
var chg_buf: [64]u8 = undefined; var chg_buf: [64]u8 = undefined;
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle(); const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
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 }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
} }
return lines.toOwnedSlice(arena); return lines.toOwnedSlice(arena);
} }
@ -688,11 +681,7 @@ 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 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]; const latest = c[c.len - 1];
// The braille fallback charts the last 60 candles; the window try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close);
// 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 // Braille sparkline chart of recent 60 trading days
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -748,35 +737,25 @@ fn buildDetailColumns(
quote_data: ?zfin.Quote, quote_data: ?zfin.Quote,
price: f64, price: f64,
prev_close: f64, prev_close: f64,
period: ?fmt.PctChange,
period_label: []const u8,
) !void { ) !void {
const th = app.theme; const th = app.theme;
var vol_buf: [32]u8 = undefined; var vol_buf: [32]u8 = undefined;
// Column 1: Price/OHLCV // Column 1: Price/OHLCV
var col1 = Column.init(); var col1 = Column.init();
col1.width = 36; col1.width = 30;
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {f}", .{latest.date}), th.contentStyle()); 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, " 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, " 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, " 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, " 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()); 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 (fmt.pctChange(price, prev_close)) |dc| { if (prev_close > 0) {
const change = price - prev_close;
const pct = (change / prev_close) * 100.0;
var chg_buf: [64]u8 = undefined; var chg_buf: [64]u8 = undefined;
const change_style = if (dc.change >= 0) th.positiveStyle() else th.negativeStyle(); const change_style = if (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); try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, 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) // Columns 2-4: ETF profile (only for actual ETFs)