Compare commits
16 commits
72e874f052
...
1d89b68da4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d89b68da4 | |||
| 0e43e09f13 | |||
| ce5d787083 | |||
| 128085eefc | |||
| 8e0288d437 | |||
| 3e45393d93 | |||
| 3f48a5b38c | |||
| 25f074f622 | |||
| dd550a85d9 | |||
| 692a28aed7 | |||
| 78076b5319 | |||
| 6d47b46a5a | |||
| 889cfc215a | |||
| 6170dc1ac1 | |||
| e6ec5fdac1 | |||
| 5a2b29fdd4 |
32 changed files with 2965 additions and 652 deletions
56
TODO.md
56
TODO.md
|
|
@ -81,28 +81,10 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
|||
|
||||
## `--export-chart` follow-ups - priority LOW
|
||||
|
||||
V1 of `--export-chart <PATH>` shipped for `quote` and `projections`
|
||||
(default bands mode only). Several adjacent surfaces still don't
|
||||
have PNG export and were deferred:
|
||||
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
|
||||
(default bands mode only), and `history`. Several adjacent surfaces
|
||||
still don't have PNG export and were deferred:
|
||||
|
||||
- **`history --export-chart`.** The `history` command renders a
|
||||
single-series braille chart of portfolio value over time
|
||||
(synthesized into `Candle` records and fed to
|
||||
`format.computeBrailleChart`). It doesn't share the z2d
|
||||
pipeline that `quote` (`tui/chart.zig`) and `projections`
|
||||
(`tui/projection_chart.zig`) use. To export, options:
|
||||
- **A.** Pipe the synthesized candles through
|
||||
`tui/chart.zig`'s `renderChart` - but that draws Bollinger
|
||||
Bands and an RSI panel, both meaningless on a portfolio-
|
||||
value series.
|
||||
- **B.** Add a minimal "single-series line chart" z2d
|
||||
renderer (a slimmed-down `projection_chart.zig` without
|
||||
bands). ~150 lines. Same renderToSurface shape so PNG
|
||||
export is trivial after.
|
||||
- **C.** Skip it permanently; the braille chart is fine for
|
||||
what `history` is. Document as "not exportable".
|
||||
B is the right answer if PNG export of the history chart is
|
||||
ever requested.
|
||||
- **`projections --convergence` / `--return-backtest`.** Both
|
||||
render forecast-evaluation charts via `tui/forecast_chart.zig`.
|
||||
Not refactored to expose a `renderToSurface` seam yet -
|
||||
|
|
@ -260,39 +242,9 @@ Implementation notes:
|
|||
(`RateLimiter.perHour`, wired into the provider). A batched quote
|
||||
call is 1 request, but heavy `r` use plus candle refreshes draw from
|
||||
the same hourly budget, so watch for contention.
|
||||
- Tiingo websocket streaming would be the natural follow-on for true
|
||||
- Tiingo websocket streaming would be the natural follow-on for true
|
||||
push-based real-time, replacing poll-on-`r` entirely.
|
||||
|
||||
## Precise "as of <clock time>" via a datetime/timezone lib (zeit) - priority LOW
|
||||
|
||||
The portfolio tab's live-price footer is deliberately vague static
|
||||
text: "(as of intraday quote today)" after a live refresh, falling
|
||||
back to "(as of close on YYYY-MM-DD)" otherwise. We can't do better
|
||||
today because the codebase has no wall-clock-to-local-time machinery -
|
||||
`Date` is days-only, and every time display is either a date or a
|
||||
relative "X ago" (`fmt.fmtTimeAgo`). There's no way to render an
|
||||
absolute local clock time like "2:34 PM ET".
|
||||
|
||||
Pulling in a datetime/timezone library (e.g. [zeit](https://github.com/rockorager/zeit),
|
||||
already by the libvaxis author) would let us:
|
||||
|
||||
- Show a precise, honest stamp: "(as of 2:34 PM ET)" / "refreshed
|
||||
2:34 PM" instead of "today" / "Xs ago".
|
||||
- Fix the current label's weekend/after-hours imprecision. Right now a
|
||||
refresh when the market is closed flips the footer to "intraday quote
|
||||
today" even though Yahoo returned the last close (which on a Saturday
|
||||
is Friday's). With real clock + market-calendar awareness, the label
|
||||
could say "as of Fri close" or "(market closed, last quote Fri 4:00
|
||||
PM ET)" instead of implying live intraday data.
|
||||
- Replace `last_refresh_s` "refreshed Xs ago" in the TUI status bar
|
||||
(and the quote/earnings/options "data Xs ago" readouts) with absolute
|
||||
times where that reads better.
|
||||
|
||||
Scope is a judgment call: a new dependency for what's currently a
|
||||
cosmetic label. Worth it once we want trustworthy timestamps (e.g. for
|
||||
screenshots, or to stop conflating "live" with "last close"); not
|
||||
before.
|
||||
|
||||
## Analysis: dividend equity / income-shaped equity - think about it
|
||||
|
||||
Dividend-equity ETFs (SCHD, VYM, DGRO, NOBL, SDY, VIG, etc.)
|
||||
|
|
|
|||
|
|
@ -83,8 +83,9 @@ there.)
|
|||
## 3. Review the timeline with `history`
|
||||
|
||||
Run [`zfin history`](../reference/cli/history.md) with no symbol for the
|
||||
portfolio-value timeline: rolling-window changes, a braille chart, and
|
||||
a recent-snapshots table.
|
||||
portfolio-value timeline: rolling-window changes, a chart (an inline
|
||||
Kitty image when your terminal supports it, else braille), and a
|
||||
recent-snapshots table.
|
||||
|
||||
```bash
|
||||
ZFIN_HOME=examples/post-retirement zfin history
|
||||
|
|
|
|||
|
|
@ -28,9 +28,13 @@ Price History for VTI (last 30 days)
|
|||
## Portfolio mode
|
||||
|
||||
With no symbol, reads your `history/*-portfolio.srf` snapshots and
|
||||
renders rolling-window returns, a braille chart, and a recent-snapshots
|
||||
renders rolling-window returns, a chart, and a recent-snapshots
|
||||
table. (Build that history with [`snapshot`](snapshot.md).)
|
||||
|
||||
The chart renders as an inline Kitty image when your terminal supports
|
||||
it, falling back to braille otherwise. Force a mode with the global
|
||||
[`--chart`](index.md) flag (`auto` / `braille` / `WxH`).
|
||||
|
||||
| Flag | Effect |
|
||||
|-----------------------|-------------------------------------------------|
|
||||
| `--since <DATE>` | Earliest as-of date (inclusive). |
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ subcommand:
|
|||
| `--refresh-data=<auto\|force\|never>` | Cache freshness policy. `auto` (default) respects TTLs; `force` re-fetches everything; `never` is offline. See [offline guide](../../guides/offline-and-refresh.md). |
|
||||
| `-p, --portfolio <PATTERN>` | Portfolio file or glob (repeatable; default `portfolio*.srf`). Resolved against `ZFIN_HOME` when set, else the current directory. Quote globs to prevent shell expansion. |
|
||||
| `-w, --watchlist <FILE>` | Watchlist file (default `watchlist.srf`). |
|
||||
| `--chart <auto\|braille\|WxH>` | Inline chart graphics for `history` / `quote` / `projections`. `auto` (default) renders a Kitty image when the terminal supports it, else braille; `WxH` forces Kitty at that resolution. |
|
||||
|
||||
```bash
|
||||
zfin --no-color --refresh-data=never -p 'portfolio_*.srf' analysis
|
||||
|
|
|
|||
|
|
@ -4,14 +4,27 @@ Show the latest quote for a symbol, with a price chart and recent
|
|||
history.
|
||||
|
||||
```
|
||||
Usage: zfin quote <SYMBOL>
|
||||
Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>]
|
||||
```
|
||||
|
||||
Prints the last price, the day's open/high/low, volume, and the
|
||||
day-over-day change, followed by a 20-day chart. Quotes come from Yahoo
|
||||
day-over-day change, followed by a price chart over a recent window
|
||||
(the last ~3 months by default) and a table of the last 20 trading
|
||||
days. Quotes come from Yahoo
|
||||
(TwelveData fallback) and are **never cached** -- so this command needs
|
||||
network access and does nothing useful in `--refresh-data=never` mode.
|
||||
|
||||
The inline chart renders as a Kitty image (price + Bollinger bands +
|
||||
volume + RSI) when your terminal supports it, falling back to a braille
|
||||
price line otherwise. Force a mode with the global
|
||||
[`--chart`](index.md) flag (`auto` / `braille` / `WxH`).
|
||||
|
||||
Use `--since <WHEN>` to change how far back the chart reaches. `WHEN`
|
||||
accepts an absolute `YYYY-MM-DD`, a relative shortcut (`1W`, `1M`,
|
||||
`1Q`, `1Y`), or `ytd`. It governs both the inline chart and the
|
||||
`--export-chart` PNG; the 20-day history table is always the last 20
|
||||
trading days regardless.
|
||||
|
||||
Supports `--export-chart <PATH>` to render the chart as a 1920x1080
|
||||
PNG instead of text (see [export charts](../../guides/offline-and-refresh.md)
|
||||
and the projections page).
|
||||
|
|
@ -20,6 +33,9 @@ and the projections page).
|
|||
|
||||
```bash
|
||||
ZFIN_HOME=examples/pre-retirement-both zfin quote SPY
|
||||
|
||||
# A one-year chart window instead of the default ~3 months:
|
||||
ZFIN_HOME=examples/pre-retirement-both zfin quote SPY --since 1Y
|
||||
```
|
||||
|
||||
```
|
||||
|
|
@ -30,11 +46,18 @@ SPY $746.74 (close)
|
|||
High: $748.23
|
||||
Low: $743.86
|
||||
Volume: 80,875,657
|
||||
Change: +$5.78 (+0.78%)
|
||||
Change (1D): +$5.78 (+0.78%)
|
||||
Change (3M): +$31.40 (+4.39%)
|
||||
|
||||
... (20-day braille chart)
|
||||
... (price chart over the selected window -- inline Kitty image, or braille)
|
||||
```
|
||||
|
||||
Two change rows are shown: `Change (1D)` is the day-over-day move,
|
||||
and `Change (<span>)` is the move across the chart window (from the
|
||||
first visible candle's close to the current price). The span label
|
||||
reflects `--since` (`3M` by default; `1Y`, `YTD`, or a date when set).
|
||||
In the TUI it's the selected chart timeframe instead (e.g. `Change (1Y)`).
|
||||
|
||||
## See also
|
||||
|
||||
- [`perf`](perf.md) -- trailing returns instead of a spot price.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,12 @@ currently-selected symbol.
|
|||
The symbol-scoped tabs follow a single "current symbol"; change it from
|
||||
the Portfolio tab (select a holding) or by launching with `-s`.
|
||||
|
||||
When you launch on a symbol (`zfin i -s AAPL`), no portfolio is loaded,
|
||||
so the portfolio-scoped tabs (Portfolio, Analysis, Review, Projections,
|
||||
History) are greyed out and unselectable - you start on the Quote tab.
|
||||
Press `R` to load your portfolio on demand: this "upgrades" the session
|
||||
into the full portfolio view and enables those tabs.
|
||||
|
||||
## Charts
|
||||
|
||||
Tabs with charts render high-fidelity **Kitty graphics** when your
|
||||
|
|
@ -46,9 +52,10 @@ current bindings). The global defaults:
|
|||
|----------------------------------------------|--------------------------------|
|
||||
| `q`, `Ctrl-C` | Quit |
|
||||
| `r`, `F5` | Refresh the current tab's data |
|
||||
| `R` | Reload the portfolio from disk |
|
||||
| `l` / `right` / `tab` | Next tab |
|
||||
| `h` / `left` / `shift+tab` | Previous tab |
|
||||
| `1`-`8` | Jump to a tab by number |
|
||||
| `1`-`9` | Jump to a tab by number |
|
||||
| `j` / `down`, `k` / `up` | Move the selection |
|
||||
| `g` / `G` | Jump to top / bottom |
|
||||
| `Ctrl-d` / `Ctrl-u` | Half-page down / up |
|
||||
|
|
|
|||
98
src/Date.zig
98
src/Date.zig
|
|
@ -5,6 +5,19 @@
|
|||
//! and use it as the type directly - no `.Date` field
|
||||
//! extraction.
|
||||
//!
|
||||
//! ## Calendar engine
|
||||
//!
|
||||
//! `zeit.Days` is `i32` days-since-epoch - the exact same
|
||||
//! representation as this type's `days` field - so the civil
|
||||
//! conversions (`epochDaysToYmd` / `ymdToEpochDays`), leap-year
|
||||
//! test, day-of-week, and month tables all delegate to zeit
|
||||
//! rather than carrying a parallel hand-rolled implementation.
|
||||
//! The compact `i32` representation, the SRF hooks, and the
|
||||
//! zfin-specific domain helpers (`ageOn`, `yearMonth`,
|
||||
//! `monthsBetween`, the 365.25-day `yearsBetween`, the Feb-29
|
||||
//! clamp on `addYears`/`subtractYears`) stay here - zeit has no
|
||||
//! equivalent.
|
||||
//!
|
||||
//! ## Format methods
|
||||
//!
|
||||
//! - `Date.format(self, *std.Io.Writer) !void` - Zig 0.15+
|
||||
|
|
@ -27,6 +40,7 @@
|
|||
|
||||
const std = @import("std");
|
||||
const srf = @import("srf");
|
||||
const zeit = @import("zeit");
|
||||
|
||||
/// Days since 1970-01-01.
|
||||
days: i32,
|
||||
|
|
@ -107,9 +121,10 @@ pub fn padLeft(self: Date, width: usize) Padded(Date) {
|
|||
|
||||
/// Day of week: 0=Monday, 1=Tuesday, ..., 4=Friday, 5=Saturday, 6=Sunday.
|
||||
pub fn dayOfWeek(self: Date) u8 {
|
||||
// 1970-01-01 was a Thursday (day 3 in 0=Mon scheme)
|
||||
const d = @mod(self.days + 3, @as(i32, 7));
|
||||
return @intCast(if (d < 0) d + 7 else d);
|
||||
// zeit's Weekday is sun=0..sat=6; remap to this type's
|
||||
// mon=0..sun=6 scheme with (w + 6) % 7.
|
||||
const w: u8 = @intFromEnum(zeit.weekdayFromDays(self.days));
|
||||
return (w + 6) % 7;
|
||||
}
|
||||
|
||||
pub fn eql(a: Date, b: Date) bool {
|
||||
|
|
@ -138,7 +153,7 @@ pub fn fromEpoch(epoch_secs: i64) Date {
|
|||
pub fn subtractYears(self: Date, n: u16) Date {
|
||||
const ymd = epochDaysToYmd(self.days);
|
||||
const new_year: i16 = ymd.year - @as(i16, @intCast(n));
|
||||
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day;
|
||||
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !zeit.isLeapYear(new_year)) 28 else ymd.day;
|
||||
return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +164,7 @@ pub fn subtractYears(self: Date, n: u16) Date {
|
|||
pub fn addYears(self: Date, n: u16) Date {
|
||||
const ymd = epochDaysToYmd(self.days);
|
||||
const new_year: i16 = ymd.year + @as(i16, @intCast(n));
|
||||
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day;
|
||||
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !zeit.isLeapYear(new_year)) 28 else ymd.day;
|
||||
return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
|
||||
}
|
||||
|
||||
|
|
@ -187,9 +202,8 @@ pub fn lastDayOfMonth(y: i16, m: u8) Date {
|
|||
}
|
||||
|
||||
fn daysInMonth(y: i16, m: u8) u8 {
|
||||
const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
|
||||
if (m == 2 and isLeapYear(y)) return 29;
|
||||
return table[m - 1];
|
||||
const mon: zeit.Month = @enumFromInt(m);
|
||||
return mon.lastDay(y);
|
||||
}
|
||||
|
||||
/// Three-letter English abbreviation of a month number
|
||||
|
|
@ -197,12 +211,12 @@ fn daysInMonth(y: i16, m: u8) u8 {
|
|||
/// out-of-range input rather than panicking - display
|
||||
/// helpers prefer a placeholder over a crash.
|
||||
pub fn monthShort(m: u8) []const u8 {
|
||||
const table = [_][]const u8{
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
};
|
||||
// Range-guard before the enum cast: zeit.Month is enum(u4)
|
||||
// jan=1..dec=12, so @enumFromInt on an out-of-range value
|
||||
// would be illegal behavior. Display callers want "???".
|
||||
if (m < 1 or m > 12) return "???";
|
||||
return table[m - 1];
|
||||
const mon: zeit.Month = @enumFromInt(m);
|
||||
return mon.shortName();
|
||||
}
|
||||
|
||||
/// Returns approximate number of years between two dates.
|
||||
|
|
@ -280,39 +294,29 @@ pub fn ageOn(self: Date, on: Date) u16 {
|
|||
return @intCast(years);
|
||||
}
|
||||
|
||||
fn isLeapYear(y: i16) bool {
|
||||
const yu: u16 = @bitCast(y);
|
||||
return (yu % 4 == 0 and yu % 100 != 0) or (yu % 400 == 0);
|
||||
}
|
||||
|
||||
const Ymd = struct { year: i16, month: u8, day: u8 };
|
||||
|
||||
/// Decompose epoch-days into civil year/month/day. `zeit.Days` is
|
||||
/// also `i32` days-since-epoch, so this is a direct delegation to
|
||||
/// `zeit.civilFromDays` (no parallel hand-rolled algorithm).
|
||||
fn epochDaysToYmd(days: i32) Ymd {
|
||||
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
|
||||
// Using i64 throughout to avoid overflow on unsigned intermediate values.
|
||||
const z: i64 = @as(i64, days) + 719468;
|
||||
const era: i64 = @divFloor(if (z >= 0) z else z - 146096, 146097);
|
||||
const doe_i: i64 = z - era * 146097; // [0, 146096]
|
||||
const doe: u64 = @intCast(doe_i);
|
||||
const yoe_val: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
|
||||
const y: i64 = @as(i64, @intCast(yoe_val)) + era * 400;
|
||||
const doy: u64 = doe - (365 * yoe_val + yoe_val / 4 - yoe_val / 100);
|
||||
const mp: u64 = (5 * doy + 2) / 153;
|
||||
const d: u8 = @intCast(doy - (153 * mp + 2) / 5 + 1);
|
||||
const m_raw: u64 = if (mp < 10) mp + 3 else mp - 9;
|
||||
const m: u8 = @intCast(m_raw);
|
||||
const y_adj: i16 = @intCast(if (m <= 2) y + 1 else y);
|
||||
return .{ .year = y_adj, .month = m, .day = d };
|
||||
const zd = zeit.civilFromDays(days);
|
||||
return .{
|
||||
.year = @intCast(zd.year),
|
||||
.month = @intFromEnum(zd.month),
|
||||
.day = zd.day,
|
||||
};
|
||||
}
|
||||
|
||||
/// Compose epoch-days from civil year/month/day via
|
||||
/// `zeit.daysFromCivil`. `m` must be 1..12 (callers within this
|
||||
/// file guarantee it; `zeit.Month` is `enum(u4) { jan = 1, .. }`).
|
||||
fn ymdToEpochDays(y: i16, m: u8, d: u8) i32 {
|
||||
const y_adj: i64 = @as(i64, y) - @as(i64, if (m <= 2) @as(i64, 1) else @as(i64, 0));
|
||||
const era: i64 = @divFloor(if (y_adj >= 0) y_adj else y_adj - 399, 400);
|
||||
const yoe: u64 = @intCast(y_adj - era * 400);
|
||||
const m_adj: u64 = if (m > 2) @as(u64, m) - 3 else @as(u64, m) + 9;
|
||||
const doy: u64 = (153 * m_adj + 2) / 5 + @as(u64, d) - 1;
|
||||
const doe: u64 = yoe * 365 + yoe / 4 -| yoe / 100 + doy;
|
||||
return @intCast(era * 146097 + @as(i64, @intCast(doe)) - 719468);
|
||||
return zeit.daysFromCivil(.{
|
||||
.year = y,
|
||||
.month = @enumFromInt(m),
|
||||
.day = @intCast(d),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
|
@ -480,6 +484,10 @@ test "dayOfWeek" {
|
|||
try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek());
|
||||
// 2024-01-01 (Monday)
|
||||
try std.testing.expectEqual(@as(u8, 0), Date.fromYmd(2024, 1, 1).dayOfWeek());
|
||||
// 2024-01-16 (Tuesday)
|
||||
try std.testing.expectEqual(@as(u8, 1), Date.fromYmd(2024, 1, 16).dayOfWeek());
|
||||
// 2024-01-17 (Wednesday)
|
||||
try std.testing.expectEqual(@as(u8, 2), Date.fromYmd(2024, 1, 17).dayOfWeek());
|
||||
// 2024-01-19 (Friday)
|
||||
try std.testing.expectEqual(@as(u8, 4), Date.fromYmd(2024, 1, 19).dayOfWeek());
|
||||
// 2024-01-20 (Saturday)
|
||||
|
|
@ -488,6 +496,16 @@ test "dayOfWeek" {
|
|||
try std.testing.expectEqual(@as(u8, 6), Date.fromYmd(2024, 1, 21).dayOfWeek());
|
||||
}
|
||||
|
||||
test "monthShort" {
|
||||
try std.testing.expectEqualStrings("Jan", Date.monthShort(1));
|
||||
try std.testing.expectEqualStrings("Feb", Date.monthShort(2));
|
||||
try std.testing.expectEqualStrings("Dec", Date.monthShort(12));
|
||||
// Out-of-range returns the placeholder rather than panicking on
|
||||
// the enum cast.
|
||||
try std.testing.expectEqualStrings("???", Date.monthShort(0));
|
||||
try std.testing.expectEqualStrings("???", Date.monthShort(13));
|
||||
}
|
||||
|
||||
test "eql and lessThan" {
|
||||
const a = Date.fromYmd(2024, 6, 15);
|
||||
const b = Date.fromYmd(2024, 6, 15);
|
||||
|
|
|
|||
|
|
@ -191,6 +191,13 @@ pub const LoadOptions = struct {
|
|||
/// portfolio with current intraday quotes rather than the prior
|
||||
/// daily close. Borrowed; pd does not take ownership.
|
||||
live_quotes: ?*const std.StringHashMap(f64) = null,
|
||||
/// Wall-clock instant (Unix seconds) at which `live_quotes` were
|
||||
/// fetched. Stored on pd as `live_quotes_at_s` when the overlay
|
||||
/// actually re-prices a held position, so the renderer can show a
|
||||
/// precise "(as of H:MM PM ET)" stamp. Ignored when `live_quotes`
|
||||
/// is null. Caller captures it once via
|
||||
/// `std.Io.Timestamp.now(io, .real).toSeconds()`.
|
||||
live_quotes_at_s: ?i64 = null,
|
||||
/// Per-worker start delays. Each background worker sleeps for
|
||||
/// its delay before doing any work, letting the caller
|
||||
/// deprioritize a specific worker (e.g. push it later so a
|
||||
|
|
@ -276,6 +283,12 @@ latest_quote_date: ?Date = null,
|
|||
/// Drives the portfolio "as of" label wording.
|
||||
live_prices_applied: bool = false,
|
||||
|
||||
/// Unix-seconds instant the live intraday quotes were fetched, when
|
||||
/// `live_prices_applied` is true (else null). Threaded in from the
|
||||
/// TUI refresh path via `LoadOptions.live_quotes_at_s`; drives the
|
||||
/// precise "(as of H:MM PM ET)" portfolio footer stamp.
|
||||
live_quotes_at_s: ?i64 = null,
|
||||
|
||||
/// Cached prices for watchlist symbols (no live fetching during
|
||||
/// render). Allocated in pd's arena.
|
||||
watchlist_prices: ?std.StringHashMap(f64) = null,
|
||||
|
|
@ -434,6 +447,38 @@ pub fn invalidateClassificationMap(self: *PortfolioData) void {
|
|||
self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) });
|
||||
}
|
||||
|
||||
/// Prime ONLY the `metadata.srf` classification map for `paths_in`,
|
||||
/// without the full `load()` (no price fetch, no summary, no
|
||||
/// candle/dividend/account workers). Spawns the same
|
||||
/// `classificationMapWorker` that `load()` uses, so a subsequent
|
||||
/// `classificationMap()` resolves curated security names identically.
|
||||
///
|
||||
/// Used by the TUI's explicit-symbol launch (`zfin AAPL`), which skips
|
||||
/// the portfolio load entirely but still wants the quote tab (and the
|
||||
/// 'K' overlay) to show the `metadata.srf` security name the way the CLI
|
||||
/// `quote` command does. No-op when `paths_in` is empty or a portfolio
|
||||
/// is already loaded (a full `load()` populates the map itself).
|
||||
///
|
||||
/// Intentional side effect: capturing `paths_in` here also lets the `R`
|
||||
/// (reload_portfolio) key run a first full `load()` from symbol mode -
|
||||
/// see `portfolio_tab.reloadPortfolioFile` - so the user can "upgrade" a
|
||||
/// symbol-only session into the full portfolio view on demand.
|
||||
pub fn primeClassificationMap(self: *PortfolioData, paths_in: []const []const u8) void {
|
||||
if (paths_in.len == 0 or self.paths.len != 0) return;
|
||||
|
||||
// Dupe the anchor paths into the per-load arena so `anchorPath()`
|
||||
// (and the worker's metadata.srf derivation) outlive `paths_in`.
|
||||
const arena_alloc = self.allocator();
|
||||
const paths_dup = arena_alloc.alloc([]const u8, paths_in.len) catch return;
|
||||
for (paths_in, 0..) |p, i| {
|
||||
paths_dup[i] = arena_alloc.dupe(u8, p) catch return;
|
||||
}
|
||||
self.paths = paths_dup;
|
||||
|
||||
if (self.classification_map_future) |*f| _ = f.cancel(self.io);
|
||||
self.classification_map_future = self.io.async(classificationMapWorker, .{ self, @as(usize, 0) });
|
||||
}
|
||||
|
||||
/// Drain a worker future. Idempotent (Future.await is itself
|
||||
/// idempotent); safe to call every time.
|
||||
fn awaitWorker(self: *PortfolioData, fut: *?std.Io.Future(void)) void {
|
||||
|
|
@ -516,6 +561,7 @@ pub fn load(
|
|||
self.summary = null;
|
||||
self.latest_quote_date = null;
|
||||
self.live_prices_applied = false;
|
||||
self.live_quotes_at_s = null;
|
||||
self.watchlist_prices = null;
|
||||
self.snapshots_data = null;
|
||||
self.dividends_data = null;
|
||||
|
|
@ -663,6 +709,10 @@ pub fn load(
|
|||
// so the summary reflects today's intraday prices rather than
|
||||
// the candle close. Drives the portfolio "as of" label.
|
||||
self.live_prices_applied = held_overrides > 0;
|
||||
// Stamp the fetch instant only when the overlay actually took
|
||||
// effect, so the footer's precise "(as of H:MM PM ET)" reflects
|
||||
// a real re-price (not a no-op refresh).
|
||||
self.live_quotes_at_s = if (held_overrides > 0) opts.live_quotes_at_s else null;
|
||||
}
|
||||
self.watchlist_prices = wp;
|
||||
|
||||
|
|
@ -966,6 +1016,43 @@ test "PortfolioData.cancelLoad: idempotent on idle state" {
|
|||
try testing.expect(pd.classification_map_data == null);
|
||||
}
|
||||
|
||||
test "PortfolioData.primeClassificationMap: spawns the classification worker without a full load" {
|
||||
var svc: DataService = .{
|
||||
.allocator = testing.allocator,
|
||||
.io = testing.io,
|
||||
.config = .{ .cache_dir = "./.tmp/zfin-pd-prime-cache" },
|
||||
};
|
||||
var pd = PortfolioData.init(.{ .gpa = testing.allocator, .io = testing.io, .svc = &svc });
|
||||
defer pd.deinit();
|
||||
|
||||
// Explicit-symbol launch shape: nothing loaded yet, so the map is
|
||||
// null - exactly the state that left the TUI quote tab nameless.
|
||||
try testing.expectEqual(@as(usize, 0), pd.paths.len);
|
||||
try testing.expect(pd.classification_map_future == null);
|
||||
try testing.expect(pd.classificationMap() == null);
|
||||
|
||||
// Empty paths: no-op (still nothing loaded).
|
||||
pd.primeClassificationMap(&.{});
|
||||
try testing.expectEqual(@as(usize, 0), pd.paths.len);
|
||||
try testing.expect(pd.classification_map_future == null);
|
||||
|
||||
// A real anchor sets the path and spawns the SAME worker `load()`
|
||||
// uses, so a later `classificationMap()` reads metadata.srf and
|
||||
// resolves the curated name the way the CLI does.
|
||||
pd.primeClassificationMap(&.{"./.tmp/zfin-pd-prime-test/portfolio.srf"});
|
||||
try testing.expectEqual(@as(usize, 1), pd.paths.len);
|
||||
try testing.expect(pd.classification_map_future != null);
|
||||
try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?);
|
||||
|
||||
// Already primed: a second call must not clobber the loaded paths.
|
||||
pd.primeClassificationMap(&.{"./.tmp/other/portfolio.srf"});
|
||||
try testing.expectEqual(@as(usize, 1), pd.paths.len);
|
||||
try testing.expectEqualStrings("./.tmp/zfin-pd-prime-test/portfolio.srf", pd.anchorPath().?);
|
||||
|
||||
// Drain the spawned worker so teardown leaves no dangling future.
|
||||
_ = pd.classificationMap();
|
||||
}
|
||||
|
||||
test "PortfolioData.candles: returns null after cancelLoad with no data" {
|
||||
var svc: DataService = .{
|
||||
.allocator = testing.allocator,
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ pub fn rsi(
|
|||
/// Extract chart-ready close prices from candles into a contiguous f64 slice.
|
||||
/// Uses `Candle.chartClose()` (split-adjusted when available) so chart
|
||||
/// renderers don't show false cliffs at split dates. The only callers
|
||||
/// today are chart code paths in `tui/chart.zig`; if a future caller
|
||||
/// today are chart code paths in `charts/chart.zig`; if a future caller
|
||||
/// genuinely needs raw `close`, add a separate `rawClosePrices` helper
|
||||
/// rather than re-purposing this one.
|
||||
pub fn closePrices(alloc: std.mem.Allocator, candles: []const Candle) ![]f64 {
|
||||
|
|
|
|||
|
|
@ -299,6 +299,33 @@ pub fn extractMetric(
|
|||
return out;
|
||||
}
|
||||
|
||||
/// Extract a single metric into `MetricPoint`s for charting, skipping
|
||||
/// imported-only points for the derived metrics (`.illiquid` /
|
||||
/// `.net_worth`) that `imported_values.srf` does not carry. Imported
|
||||
/// rows record only `liquid`; their illiquid/net_worth read as zero, so
|
||||
/// including them would yank a chart line down to zero across the
|
||||
/// imported-only range. For `.liquid`, every point is kept.
|
||||
///
|
||||
/// Result is caller-owned and preserves the input's ascending-by-date
|
||||
/// order. This is the single home for the "skip imported-only for
|
||||
/// derived metrics" rule shared by the CLI braille chart, the CLI
|
||||
/// `--export-chart` / inline path, and the TUI history chart.
|
||||
pub fn extractChartSeries(
|
||||
allocator: std.mem.Allocator,
|
||||
points: []const TimelinePoint,
|
||||
metric: Metric,
|
||||
) ![]MetricPoint {
|
||||
const skip_imported = (metric == .illiquid) or (metric == .net_worth);
|
||||
var list: std.ArrayList(MetricPoint) = .empty;
|
||||
errdefer list.deinit(allocator);
|
||||
try list.ensureTotalCapacity(allocator, points.len);
|
||||
for (points) |p| {
|
||||
if (skip_imported and p.source == .imported) continue;
|
||||
list.appendAssumeCapacity(.{ .date = p.as_of_date, .value = extractValue(p, metric) });
|
||||
}
|
||||
return list.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
/// Which collection of per-row named values on `TimelinePoint` to project.
|
||||
pub const NamedSeriesSource = enum { accounts, tax_types };
|
||||
|
||||
|
|
@ -1707,6 +1734,47 @@ test "Metric.label: stable strings" {
|
|||
try testing.expectEqualStrings("Illiquid", Metric.illiquid.label());
|
||||
}
|
||||
|
||||
test "extractChartSeries: liquid keeps imported-only points" {
|
||||
const points = [_]TimelinePoint{
|
||||
.{ .as_of_date = Date.fromYmd(2026, 1, 1), .net_worth = 0, .liquid = 700, .illiquid = 0, .accounts = &.{}, .tax_types = &.{}, .source = .imported },
|
||||
.{ .as_of_date = Date.fromYmd(2026, 2, 1), .net_worth = 1000, .liquid = 800, .illiquid = 200, .accounts = &.{}, .tax_types = &.{}, .source = .snapshot },
|
||||
};
|
||||
const out = try extractChartSeries(testing.allocator, &points, .liquid);
|
||||
defer testing.allocator.free(out);
|
||||
// Both rows kept; liquid is carried by imported rows.
|
||||
try testing.expectEqual(@as(usize, 2), out.len);
|
||||
try testing.expectEqual(@as(f64, 700), out[0].value);
|
||||
try testing.expectEqual(@as(f64, 800), out[1].value);
|
||||
try testing.expect(out[0].date.eql(Date.fromYmd(2026, 1, 1)));
|
||||
}
|
||||
|
||||
test "extractChartSeries: illiquid / net_worth drop imported-only points" {
|
||||
const points = [_]TimelinePoint{
|
||||
.{ .as_of_date = Date.fromYmd(2026, 1, 1), .net_worth = 0, .liquid = 700, .illiquid = 0, .accounts = &.{}, .tax_types = &.{}, .source = .imported },
|
||||
.{ .as_of_date = Date.fromYmd(2026, 2, 1), .net_worth = 1000, .liquid = 800, .illiquid = 200, .accounts = &.{}, .tax_types = &.{}, .source = .snapshot },
|
||||
.{ .as_of_date = Date.fromYmd(2026, 3, 1), .net_worth = 1200, .liquid = 900, .illiquid = 300, .accounts = &.{}, .tax_types = &.{}, .source = .snapshot },
|
||||
};
|
||||
|
||||
const ill = try extractChartSeries(testing.allocator, &points, .illiquid);
|
||||
defer testing.allocator.free(ill);
|
||||
// Imported row skipped; only the two snapshot rows remain.
|
||||
try testing.expectEqual(@as(usize, 2), ill.len);
|
||||
try testing.expectEqual(@as(f64, 200), ill[0].value);
|
||||
try testing.expectEqual(@as(f64, 300), ill[1].value);
|
||||
|
||||
const nw = try extractChartSeries(testing.allocator, &points, .net_worth);
|
||||
defer testing.allocator.free(nw);
|
||||
try testing.expectEqual(@as(usize, 2), nw.len);
|
||||
try testing.expectEqual(@as(f64, 1000), nw[0].value);
|
||||
try testing.expectEqual(@as(f64, 1200), nw[1].value);
|
||||
}
|
||||
|
||||
test "extractChartSeries: empty input returns empty slice" {
|
||||
const out = try extractChartSeries(testing.allocator, &.{}, .liquid);
|
||||
defer testing.allocator.free(out);
|
||||
try testing.expectEqual(@as(usize, 0), out.len);
|
||||
}
|
||||
|
||||
test "extractNamedSeries accounts: matches + absent days emit 0" {
|
||||
// Build three snapshots: day1 has account A; day2 has account B; day3 has both.
|
||||
// Extracting "A" should see value on day1, 0 on day2, value on day3.
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
//! module. Each chart-bearing command (`quote`, `projections`)
|
||||
//! has a corresponding `export*` function here that:
|
||||
//!
|
||||
//! 1. Calls the relevant `renderToSurface` (in `tui/chart.zig` or
|
||||
//! `tui/projection_chart.zig`) to draw the chart into a z2d
|
||||
//! 1. Calls the relevant `renderToSurface` (in `charts/chart.zig` or
|
||||
//! `charts/projection_chart.zig`) to draw the chart into a z2d
|
||||
//! `Surface`.
|
||||
//! 2. Calls `z2d.png_exporter.writeToPNGFile` to land the surface
|
||||
//! as a PNG file at the user-supplied path.
|
||||
|
|
@ -25,36 +25,48 @@
|
|||
const std = @import("std");
|
||||
const z2d = @import("z2d");
|
||||
const zfin = @import("root.zig");
|
||||
const chart = @import("tui/chart.zig");
|
||||
const projection_chart = @import("tui/projection_chart.zig");
|
||||
const chart = @import("charts/chart.zig");
|
||||
const projection_chart = @import("charts/projection_chart.zig");
|
||||
const line_chart = @import("charts/line_chart.zig");
|
||||
const projections = @import("analytics/projections.zig");
|
||||
const theme = @import("tui/theme.zig");
|
||||
|
||||
/// Default PNG export resolution. Matches `tui/chart.zig`'s
|
||||
/// Default PNG export resolution. Matches `charts/chart.zig`'s
|
||||
/// `ChartConfig.max_width/max_height` defaults so an exported
|
||||
/// image carries the same fidelity as a maximally-sized TUI
|
||||
/// chart.
|
||||
pub const default_width: u32 = 1920;
|
||||
pub const default_height: u32 = 1080;
|
||||
|
||||
/// Export a price+Bollinger+RSI chart for a single symbol.
|
||||
/// Wraps `chart.renderToSurface` + `writeToPNGFile`.
|
||||
/// Export a price+Bollinger+RSI chart for a single symbol, showing the
|
||||
/// most recent `display_count` candles. The overlays are computed with a
|
||||
/// warmup lookback (via `chart.computeIndicatorsWarmup`) so they're valid
|
||||
/// from the first displayed candle. Wraps `renderToSurface` + `writeToPNGFile`.
|
||||
pub fn exportSymbolChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
timeframe: chart.Timeframe,
|
||||
display_count: usize,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var cached = chart.computeIndicatorsWarmup(alloc, candles, display_count, 20) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
};
|
||||
defer cached.deinit(alloc);
|
||||
const n = @min(candles.len, display_count);
|
||||
const display = candles[candles.len - n ..];
|
||||
|
||||
var rendered = chart.renderToSurface(
|
||||
io,
|
||||
alloc,
|
||||
candles,
|
||||
timeframe,
|
||||
display,
|
||||
null,
|
||||
default_width,
|
||||
default_height,
|
||||
theme.default_theme,
|
||||
null,
|
||||
&cached,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
|
|
@ -82,6 +94,34 @@ pub fn exportProjectionChart(
|
|||
default_height,
|
||||
theme.default_theme,
|
||||
actuals,
|
||||
true,
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
};
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try z2d.png_exporter.writeToPNGFile(io, rendered.surface, path, .{});
|
||||
}
|
||||
|
||||
/// Export a single-series portfolio-value timeline as a PNG. Wraps
|
||||
/// `line_chart.renderToSurface` + `writeToPNGFile`. `baseline` selects
|
||||
/// whether the y-axis fits the data or anchors at zero.
|
||||
pub fn exportTimelineChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
points: []const line_chart.LinePoint,
|
||||
baseline: line_chart.Baseline,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
var rendered = line_chart.renderToSurface(
|
||||
io,
|
||||
alloc,
|
||||
points,
|
||||
default_width,
|
||||
default_height,
|
||||
theme.default_theme,
|
||||
.{ .baseline = baseline, .axis_labels = true },
|
||||
) catch |err| switch (err) {
|
||||
error.InsufficientData => return error.InsufficientData,
|
||||
else => return err,
|
||||
|
|
@ -121,7 +161,7 @@ test "exportSymbolChart writes a non-empty PNG file" {
|
|||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_symbol.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportSymbolChart(io, alloc, &candles, .@"6M", path);
|
||||
try exportSymbolChart(io, alloc, &candles, 60, path);
|
||||
|
||||
// Verify the file exists, starts with the PNG magic, and is
|
||||
// big enough to plausibly contain a chart (not just headers).
|
||||
|
|
@ -164,7 +204,7 @@ test "exportSymbolChart returns InsufficientData on too-few candles" {
|
|||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportSymbolChart(io, alloc, &candles, .@"6M", path),
|
||||
exportSymbolChart(io, alloc, &candles, 60, path),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -234,3 +274,58 @@ test "exportProjectionChart returns InsufficientData with single band" {
|
|||
exportProjectionChart(io, alloc, &bands, null, path),
|
||||
);
|
||||
}
|
||||
|
||||
test "exportTimelineChart writes a non-empty PNG file" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
var points: [12]line_chart.LinePoint = undefined;
|
||||
for (0..12) |i| {
|
||||
points[i] = .{
|
||||
.date = Date.fromYmd(2025, 1, 1).addDays(@intCast(i * 30)),
|
||||
.value = 1_000_000.0 + 25_000.0 * @as(f64, @floatFromInt(i)),
|
||||
};
|
||||
}
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportTimelineChart(io, alloc, &points, .fit, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "test_export_timeline.png", .{});
|
||||
defer file.close(io);
|
||||
const size = (try file.stat(io)).size;
|
||||
try std.testing.expect(size > 1024);
|
||||
|
||||
var magic: [8]u8 = undefined;
|
||||
var reader = file.reader(io, &.{});
|
||||
_ = try reader.interface.readSliceShort(&magic);
|
||||
try std.testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
||||
}
|
||||
|
||||
test "exportTimelineChart returns InsufficientData with a single point" {
|
||||
const Date = @import("Date.zig");
|
||||
const alloc = std.testing.allocator;
|
||||
const io = std.testing.io;
|
||||
|
||||
var points: [1]line_chart.LinePoint = .{.{ .date = Date.fromYmd(2025, 1, 1), .value = 1_000_000 }};
|
||||
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const dir_path = path_buf[0..dir_len];
|
||||
const path = try std.fs.path.join(alloc, &.{ dir_path, "test_export_timeline_insufficient.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try std.testing.expectError(
|
||||
error.InsufficientData,
|
||||
exportTimelineChart(io, alloc, &points, .fit, path),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
160
src/charts/axis.zig
Normal file
160
src/charts/axis.zig
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
//! Shared axis-label helpers for the chart exports, layered on top of
|
||||
//! `text.zig` (bitmap-glyph stamping) and `format.fmtLargeNum` (compact
|
||||
//! dollar formatting).
|
||||
//!
|
||||
//! Each renderer reserves margins and calls these to draw y-axis dollar
|
||||
//! ticks and x-axis endpoint labels straight into the surface buffer.
|
||||
//! Kept here (rather than copy-pasted into each chart) so there's one
|
||||
//! source of truth for label formatting, spacing, and tick math.
|
||||
|
||||
const std = @import("std");
|
||||
const z2d = @import("z2d");
|
||||
const text = @import("text.zig");
|
||||
const draw = @import("draw.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
|
||||
const Surface = z2d.Surface;
|
||||
|
||||
/// Pick a glyph scale from the surface height so labels stay legible on
|
||||
/// large exports (1080p -> 3) and shrink on small surfaces (>= 1).
|
||||
pub fn labelScale(height_px: i32) i32 {
|
||||
return @max(1, @divFloor(height_px, 360));
|
||||
}
|
||||
|
||||
/// Glyph cell height in surface pixels at `scale`.
|
||||
pub fn charHeight(scale: i32) f64 {
|
||||
return @floatFromInt(text.glyph_h * scale);
|
||||
}
|
||||
|
||||
/// Gap (pixels) between the plot edge and the nearest label.
|
||||
pub fn labelGap(scale: i32) f64 {
|
||||
return @floatFromInt(4 * scale);
|
||||
}
|
||||
|
||||
/// Bottom margin to reserve so a full x-axis label row fits below the
|
||||
/// plot without clipping: gap + glyph height + a little breathing room.
|
||||
pub fn bottomMargin(scale: i32) f64 {
|
||||
return labelGap(scale) + charHeight(scale) + @as(f64, @floatFromInt(2 * scale));
|
||||
}
|
||||
|
||||
/// Left/right margin (in pixels) to reserve for a column of dollar
|
||||
/// labels at `scale`. Sized for the widest label we emit - an 8-glyph
|
||||
/// comma'd sub-million value like "$999,999" - plus the gap to the plot.
|
||||
pub fn yAxisMargin(scale: i32) f64 {
|
||||
return @as(f64, @floatFromInt(text.measureWidth("$999,999", scale))) + labelGap(scale) + @as(f64, @floatFromInt(2 * scale));
|
||||
}
|
||||
|
||||
/// Format a dollar value for an axis tick. Below a million we use whole
|
||||
/// dollars with thousands separators ("$887,889"); at or above a million
|
||||
/// we switch to compact T/B/M suffixes via `format.fmtLargeNum`
|
||||
/// ("$1.3M", "$370.2M") so large axes stay narrow. Negatives get a
|
||||
/// leading "-". The sub-million path rounds to the nearest whole dollar.
|
||||
pub fn fmtDollar(buf: []u8, value: f64) []const u8 {
|
||||
const sign = if (value < 0) "-" else "";
|
||||
const abs = @abs(value);
|
||||
if (abs >= 1_000_000) {
|
||||
const large = fmt.fmtLargeNum(abs);
|
||||
const trimmed = std.mem.trimEnd(u8, &large, " ");
|
||||
return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, trimmed }) catch "$?";
|
||||
}
|
||||
var nbuf: [20]u8 = undefined;
|
||||
const whole: u64 = @intFromFloat(@round(abs));
|
||||
const commas = fmt.fmtIntCommas(&nbuf, whole);
|
||||
return std.fmt.bufPrint(buf, "{s}${s}", .{ sign, commas }) catch "$?";
|
||||
}
|
||||
|
||||
/// Draw `n + 1` left-aligned dollar labels evenly spaced from
|
||||
/// `value_max` (at `top`) down to `value_min` (at `bottom`), placed in
|
||||
/// the right margin a small pad to the right of `plot_right` (the plot's
|
||||
/// right edge) and vertically centered on each level. Right-side y-axis
|
||||
/// is the financial convention: the latest (rightmost) values sit next
|
||||
/// to the scale as the series grows.
|
||||
pub fn drawYDollarTicks(
|
||||
sfc: *Surface,
|
||||
scale: i32,
|
||||
color: [3]u8,
|
||||
plot_right: f64,
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
value_min: f64,
|
||||
value_max: f64,
|
||||
n: usize,
|
||||
) void {
|
||||
const range = value_max - value_min;
|
||||
const span = bottom - top;
|
||||
const half_h = @divFloor(text.glyph_h * scale, 2);
|
||||
const pad: f64 = labelGap(scale);
|
||||
const lx: i32 = @intFromFloat(plot_right + pad);
|
||||
var i: usize = 0;
|
||||
while (i <= n) : (i += 1) {
|
||||
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n));
|
||||
const val = value_max - frac * range;
|
||||
const y = top + frac * span;
|
||||
var buf: [24]u8 = undefined;
|
||||
const label = fmtDollar(&buf, val);
|
||||
const ly = @as(i32, @intFromFloat(y)) - half_h;
|
||||
text.drawText(sfc, lx, ly, scale, color, label);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw two endpoint labels on baseline `y`: `left_label` left-aligned
|
||||
/// at `left`, `right_label` right-aligned ending at `right`. Used for
|
||||
/// x-axis start/end (dates for time series, offsets for projections).
|
||||
pub fn drawXEndpoints(
|
||||
sfc: *Surface,
|
||||
scale: i32,
|
||||
color: [3]u8,
|
||||
left: f64,
|
||||
right: f64,
|
||||
y: f64,
|
||||
left_label: []const u8,
|
||||
right_label: []const u8,
|
||||
) void {
|
||||
const yi: i32 = @intFromFloat(y);
|
||||
text.drawText(sfc, @as(i32, @intFromFloat(left)), yi, scale, color, left_label);
|
||||
const rw = text.measureWidth(right_label, scale);
|
||||
text.drawText(sfc, @as(i32, @intFromFloat(right)) - rw, yi, scale, color, right_label);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "labelScale grows with height, floored at 1" {
|
||||
try testing.expectEqual(@as(i32, 1), labelScale(100));
|
||||
try testing.expectEqual(@as(i32, 1), labelScale(360));
|
||||
try testing.expectEqual(@as(i32, 3), labelScale(1080));
|
||||
}
|
||||
|
||||
test "fmtDollar: M/B suffixes at/above a million, commas below, sign for negatives" {
|
||||
var buf: [24]u8 = undefined;
|
||||
try testing.expectEqualStrings("$1.3M", fmtDollar(&buf, 1_250_000));
|
||||
try testing.expectEqualStrings("$2.0B", fmtDollar(&buf, 2_000_000_000));
|
||||
try testing.expectEqualStrings("$950,000", fmtDollar(&buf, 950_000));
|
||||
try testing.expectEqualStrings("$887,889", fmtDollar(&buf, 887_889));
|
||||
try testing.expectEqualStrings("$313", fmtDollar(&buf, 313));
|
||||
try testing.expectEqualStrings("-$1.2M", fmtDollar(&buf, -1_200_000));
|
||||
}
|
||||
|
||||
test "drawYDollarTicks stamps labels in the requested color" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 200);
|
||||
defer sfc.deinit(alloc);
|
||||
const color = [3]u8{ 0xCC, 0xCC, 0xCC };
|
||||
try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, color));
|
||||
|
||||
// plot_right=180 leaves room for the labels in the right margin of a
|
||||
// 300px-wide surface.
|
||||
drawYDollarTicks(&sfc, 2, color, 180, 10, 190, 1_000_000, 5_000_000, 5);
|
||||
try testing.expect(draw.countColor(&sfc, color) > 0);
|
||||
}
|
||||
|
||||
test "drawXEndpoints draws both endpoint labels" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 300, 60);
|
||||
defer sfc.deinit(alloc);
|
||||
const color = [3]u8{ 0x40, 0x80, 0xC0 };
|
||||
drawXEndpoints(&sfc, 2, color, 10, 290, 20, "2024-01-01", "2026-12-31");
|
||||
// Both labels contribute pixels; expect a comfortably non-trivial count.
|
||||
try testing.expect(draw.countColor(&sfc, color) > 20);
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@
|
|||
const std = @import("std");
|
||||
const z2d = @import("z2d");
|
||||
const zfin = @import("../root.zig");
|
||||
const theme = @import("theme.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
const draw = @import("draw.zig");
|
||||
const axis = @import("axis.zig");
|
||||
|
||||
const Surface = z2d.Surface;
|
||||
|
||||
|
|
@ -49,6 +51,7 @@ const Pixel = z2d.Pixel;
|
|||
|
||||
/// Chart timeframe selection.
|
||||
pub const Timeframe = enum {
|
||||
@"3M",
|
||||
@"6M",
|
||||
ytd,
|
||||
@"1Y",
|
||||
|
|
@ -57,6 +60,7 @@ pub const Timeframe = enum {
|
|||
|
||||
pub fn label(self: Timeframe) []const u8 {
|
||||
return switch (self) {
|
||||
.@"3M" => "3M",
|
||||
.@"6M" => "6M",
|
||||
.ytd => "YTD",
|
||||
.@"1Y" => "1Y",
|
||||
|
|
@ -67,6 +71,7 @@ pub const Timeframe = enum {
|
|||
|
||||
pub fn tradingDays(self: Timeframe) usize {
|
||||
return switch (self) {
|
||||
.@"3M" => 63,
|
||||
.@"6M" => 126,
|
||||
.ytd => 252, // approximation, we'll clamp
|
||||
.@"1Y" => 252,
|
||||
|
|
@ -77,17 +82,19 @@ pub const Timeframe = enum {
|
|||
|
||||
pub fn next(self: Timeframe) Timeframe {
|
||||
return switch (self) {
|
||||
.@"3M" => .@"6M",
|
||||
.@"6M" => .ytd,
|
||||
.ytd => .@"1Y",
|
||||
.@"1Y" => .@"3Y",
|
||||
.@"3Y" => .@"5Y",
|
||||
.@"5Y" => .@"6M",
|
||||
.@"5Y" => .@"3M",
|
||||
};
|
||||
}
|
||||
|
||||
pub fn prev(self: Timeframe) Timeframe {
|
||||
return switch (self) {
|
||||
.@"6M" => .@"5Y",
|
||||
.@"3M" => .@"5Y",
|
||||
.@"6M" => .@"3M",
|
||||
.ytd => .@"6M",
|
||||
.@"1Y" => .ytd,
|
||||
.@"3Y" => .@"1Y",
|
||||
|
|
@ -173,6 +180,51 @@ pub fn computeIndicators(
|
|||
};
|
||||
}
|
||||
|
||||
/// Like `computeIndicators`, but for a fixed *display count* of the most
|
||||
/// recent candles, computing the Bollinger/RSI series over an extra
|
||||
/// `warmup` candles of lookback so the overlays are valid from the first
|
||||
/// displayed candle (no warm-up gap). Returns owned arrays of length
|
||||
/// `min(candles.len, display_count)`, aligned with the last that many
|
||||
/// candles. Pair with `renderToSurface(candles[len-n..], null, ...)`.
|
||||
pub fn computeIndicatorsWarmup(
|
||||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
display_count: usize,
|
||||
warmup: usize,
|
||||
) !CachedIndicators {
|
||||
if (candles.len < 20) return error.InsufficientData;
|
||||
const n = @min(candles.len, display_count);
|
||||
const m = @min(candles.len, n + warmup);
|
||||
const window = candles[candles.len - m ..];
|
||||
|
||||
const closes_w = try zfin.indicators.closePrices(alloc, window);
|
||||
defer alloc.free(closes_w);
|
||||
const vols_w = try zfin.indicators.volumes(alloc, window);
|
||||
defer alloc.free(vols_w);
|
||||
const bb_w = try zfin.indicators.bollingerBands(alloc, closes_w, 20, 2.0);
|
||||
defer alloc.free(bb_w);
|
||||
const rsi_w = try zfin.indicators.rsi(alloc, closes_w, 14);
|
||||
defer alloc.free(rsi_w);
|
||||
|
||||
// Keep only the last `n` of each (the displayed candles); the leading
|
||||
// `off` were lookback for the overlays. Dupe into clean owned arrays
|
||||
// so the result has a normal `deinit` (no views into freed buffers).
|
||||
const off = m - n;
|
||||
const closes = try alloc.dupe(f64, closes_w[off..]);
|
||||
errdefer alloc.free(closes);
|
||||
const vols = try alloc.dupe(f64, vols_w[off..]);
|
||||
errdefer alloc.free(vols);
|
||||
const bb = try alloc.dupe(?zfin.indicators.BollingerBand, bb_w[off..]);
|
||||
errdefer alloc.free(bb);
|
||||
const rsi_vals = try alloc.dupe(?f64, rsi_w[off..]);
|
||||
return .{
|
||||
.closes = closes,
|
||||
.volumes = vols,
|
||||
.bb = bb,
|
||||
.rsi_vals = rsi_vals,
|
||||
};
|
||||
}
|
||||
|
||||
/// Render a complete financial chart to raw RGB pixel data.
|
||||
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
|
||||
/// If `cached` is provided, uses pre-computed indicators instead of recomputing.
|
||||
|
|
@ -196,17 +248,7 @@ pub const RenderedChart = struct {
|
|||
/// Caller owns the returned slice. The surface is left intact so
|
||||
/// the caller can still call `deinit`.
|
||||
pub fn extractRgb(self: *const RenderedChart, alloc: std.mem.Allocator) ![]u8 {
|
||||
const rgb_buf = switch (self.surface) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
|
||||
for (rgb_buf, 0..) |px, i| {
|
||||
raw[i * 3 + 0] = px.r;
|
||||
raw[i * 3 + 1] = px.g;
|
||||
raw[i * 3 + 2] = px.b;
|
||||
}
|
||||
return raw;
|
||||
return draw.extractRgb(alloc, &self.surface);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -222,17 +264,18 @@ pub fn renderToSurface(
|
|||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
timeframe: Timeframe,
|
||||
timeframe: ?Timeframe,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
cached: ?*const CachedIndicators,
|
||||
axis_labels: bool,
|
||||
) !RenderedChart {
|
||||
if (candles.len < 20) return error.InsufficientData;
|
||||
|
||||
// Slice candles to timeframe
|
||||
const max_days = timeframe.tradingDays();
|
||||
const n = @min(candles.len, max_days);
|
||||
// Render the timeframe's slice, or all of `candles` when the caller
|
||||
// already sliced (timeframe == null - the CLI's date-based path).
|
||||
const n = if (timeframe) |tf| @min(candles.len, tf.tradingDays()) else candles.len;
|
||||
const data = candles[candles.len - n ..];
|
||||
|
||||
// Use cached indicators or compute fresh ones
|
||||
|
|
@ -287,21 +330,23 @@ pub fn renderToSurface(
|
|||
const fheight: f64 = @floatFromInt(height_px);
|
||||
|
||||
// Background
|
||||
ctx.setSourceToPixel(opaqueColor(bg));
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(0, 0);
|
||||
try ctx.lineTo(fwidth, 0);
|
||||
try ctx.lineTo(fwidth, fheight);
|
||||
try ctx.lineTo(0, fheight);
|
||||
try ctx.closePath();
|
||||
try ctx.fill();
|
||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||
|
||||
// Panel dimensions
|
||||
const chart_left = margin_left;
|
||||
const chart_right = fwidth - margin_right;
|
||||
// Panel dimensions. With axis labels we reserve a left margin
|
||||
// (price dollar ticks) and a bottom margin (start/end dates),
|
||||
// scaled to the surface so labels stay legible on large exports.
|
||||
const label_scale: i32 = axis.labelScale(h);
|
||||
const label_char_h: f64 = axis.charHeight(label_scale);
|
||||
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
|
||||
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
|
||||
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
|
||||
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
|
||||
|
||||
const chart_left = m_left;
|
||||
const chart_right = fwidth - m_right;
|
||||
const chart_w = chart_right - chart_left;
|
||||
const chart_top = margin_top;
|
||||
const total_h = fheight - margin_top - margin_bottom;
|
||||
const chart_top = m_top;
|
||||
const total_h = fheight - m_top - m_bottom;
|
||||
|
||||
const price_h = total_h * price_frac;
|
||||
const price_top = chart_top;
|
||||
|
|
@ -504,6 +549,18 @@ pub fn renderToSurface(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Axis labels (export only) ────────────────────────────────
|
||||
if (axis_labels) {
|
||||
// Dollar ticks against the price panel; start/end dates below.
|
||||
axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_right, price_top, price_bottom, price_min, price_max, 4);
|
||||
var fbuf: [12]u8 = undefined;
|
||||
var lbuf: [12]u8 = undefined;
|
||||
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{data[0].date}) catch "";
|
||||
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{data[data.len - 1].date}) catch "";
|
||||
const date_y = rsi_bottom + axis.labelGap(label_scale);
|
||||
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
|
||||
}
|
||||
|
||||
return .{
|
||||
.surface = sfc,
|
||||
.width = @intCast(width_px),
|
||||
|
|
@ -527,7 +584,7 @@ pub fn renderChart(
|
|||
th: theme.Theme,
|
||||
cached: ?*const CachedIndicators,
|
||||
) !ChartResult {
|
||||
var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached);
|
||||
var rendered = try renderToSurface(io, alloc, candles, timeframe, width_px, height_px, th, cached, false);
|
||||
defer rendered.deinit(alloc);
|
||||
const raw = try rendered.extractRgb(alloc);
|
||||
return .{
|
||||
|
|
@ -541,29 +598,14 @@ pub fn renderChart(
|
|||
}
|
||||
|
||||
// ── Drawing helpers ───────────────────────────────────────────────────
|
||||
//
|
||||
// The stateless primitives below are shared with the other chart
|
||||
// renderers and live in `draw.zig`; aliased here so the call sites in
|
||||
// this file stay unchanged.
|
||||
|
||||
fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
|
||||
if (max_val == min_val) return (top_px + bottom_px) / 2;
|
||||
const norm = (value - min_val) / (max_val - min_val);
|
||||
return bottom_px - norm * (bottom_px - top_px);
|
||||
}
|
||||
|
||||
/// Pre-blend a foreground color with alpha against a background color.
|
||||
/// Returns a fully opaque pixel. This avoids z2d's broken src_over compositor.
|
||||
fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
|
||||
const a = @as(f64, @floatFromInt(alpha)) / 255.0;
|
||||
const inv_a = 1.0 - a;
|
||||
return .{ .rgb = .{
|
||||
.r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
|
||||
.g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
|
||||
.b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
|
||||
} };
|
||||
}
|
||||
|
||||
/// Opaque pixel from theme color.
|
||||
fn opaqueColor(c: [3]u8) Pixel {
|
||||
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
|
||||
}
|
||||
const mapY = draw.mapY;
|
||||
const blendColor = draw.blendColor;
|
||||
const opaqueColor = draw.opaqueColor;
|
||||
|
||||
const BandField = enum { upper, middle, lower };
|
||||
|
||||
|
|
@ -606,90 +648,12 @@ fn drawLineSeries(
|
|||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawHorizontalGridLines(
|
||||
ctx: *Context,
|
||||
left: f64,
|
||||
right: f64,
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
n_lines: usize,
|
||||
col: Pixel,
|
||||
) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(0.5);
|
||||
for (1..n_lines) |i| {
|
||||
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
|
||||
const y = top + frac * (bottom - top);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(left, y);
|
||||
try ctx.lineTo(right, y);
|
||||
try ctx.stroke();
|
||||
}
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x1, y);
|
||||
try ctx.lineTo(x2, y);
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x1, y1);
|
||||
try ctx.lineTo(x2, y1);
|
||||
try ctx.lineTo(x2, y2);
|
||||
try ctx.lineTo(x1, y2);
|
||||
try ctx.closePath();
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
|
||||
const drawHLine = draw.drawHLine;
|
||||
const drawRect = draw.drawRect;
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
test "mapY maps value to pixel coordinate" {
|
||||
// value at min -> bottom
|
||||
try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
|
||||
// value at max -> top
|
||||
try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
|
||||
// value at midpoint -> midpoint
|
||||
try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
|
||||
// flat range -> midpoint
|
||||
try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
|
||||
}
|
||||
|
||||
test "blendColor alpha blending" {
|
||||
const white = [3]u8{ 255, 255, 255 };
|
||||
const black = [3]u8{ 0, 0, 0 };
|
||||
|
||||
// Full alpha -> foreground
|
||||
const full = blendColor(white, 255, black);
|
||||
try std.testing.expectEqual(@as(u8, 255), full.rgb.r);
|
||||
try std.testing.expectEqual(@as(u8, 255), full.rgb.g);
|
||||
|
||||
// Zero alpha -> background
|
||||
const zero = blendColor(white, 0, black);
|
||||
try std.testing.expectEqual(@as(u8, 0), zero.rgb.r);
|
||||
|
||||
// Half alpha -> midpoint
|
||||
const half = blendColor(white, 128, black);
|
||||
// 255 * (128/255) + 0 * (127/255) ≈ 128
|
||||
try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
|
||||
}
|
||||
|
||||
test "opaqueColor wraps theme color" {
|
||||
const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f });
|
||||
try std.testing.expectEqual(@as(u8, 0x7f), px.rgb.r);
|
||||
try std.testing.expectEqual(@as(u8, 0xd8), px.rgb.g);
|
||||
try std.testing.expectEqual(@as(u8, 0x8f), px.rgb.b);
|
||||
}
|
||||
|
||||
test "ChartConfig.parse" {
|
||||
// Named modes
|
||||
const auto = ChartConfig.parse("auto").?;
|
||||
|
|
@ -712,22 +676,38 @@ test "ChartConfig.parse" {
|
|||
}
|
||||
|
||||
test "Timeframe next/prev cycle" {
|
||||
// next cycles through all values
|
||||
// next cycles: 3M -> 6M -> ytd -> 1Y -> 3Y -> 5Y -> 3M
|
||||
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"3M".next());
|
||||
try std.testing.expectEqual(Timeframe.ytd, Timeframe.@"6M".next());
|
||||
try std.testing.expectEqual(Timeframe.@"1Y", Timeframe.ytd.next());
|
||||
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.@"5Y".next()); // wraps
|
||||
try std.testing.expectEqual(Timeframe.@"3M", Timeframe.@"5Y".next()); // wraps
|
||||
|
||||
// prev is the reverse
|
||||
try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"6M".prev()); // wraps
|
||||
try std.testing.expectEqual(Timeframe.@"5Y", Timeframe.@"3M".prev()); // wraps
|
||||
try std.testing.expectEqual(Timeframe.@"3M", Timeframe.@"6M".prev());
|
||||
try std.testing.expectEqual(Timeframe.@"6M", Timeframe.ytd.prev());
|
||||
}
|
||||
|
||||
test "Timeframe tradingDays" {
|
||||
try std.testing.expectEqual(@as(usize, 63), Timeframe.@"3M".tradingDays());
|
||||
try std.testing.expectEqual(@as(usize, 126), Timeframe.@"6M".tradingDays());
|
||||
try std.testing.expectEqual(@as(usize, 252), Timeframe.@"1Y".tradingDays());
|
||||
try std.testing.expectEqual(@as(usize, 1260), Timeframe.@"5Y".tradingDays());
|
||||
}
|
||||
|
||||
test "computeIndicatorsWarmup: overlays valid from the first displayed candle" {
|
||||
var candles: [80]zfin.Candle = undefined;
|
||||
buildLinearCandles(&candles, 100.0);
|
||||
var cached = try computeIndicatorsWarmup(test_alloc, &candles, 60, 20);
|
||||
defer cached.deinit(test_alloc);
|
||||
// Displays the last 60; the 20-candle lookback means the first
|
||||
// displayed point already carries a Bollinger band and an RSI value.
|
||||
try std.testing.expectEqual(@as(usize, 60), cached.closes.len);
|
||||
try std.testing.expectEqual(@as(usize, 60), cached.bb.len);
|
||||
try std.testing.expect(cached.bb[0] != null);
|
||||
try std.testing.expect(cached.rsi_vals[0] != null);
|
||||
}
|
||||
|
||||
// ── renderToSurface tests ─────────────────────────────────────────────
|
||||
//
|
||||
// These exercise the actual chart rendering pipeline (z2d surface +
|
||||
|
|
@ -758,7 +738,7 @@ fn buildLinearCandles(arr: []zfin.Candle, start_price: f64) void {
|
|||
test "renderToSurface returns InsufficientData with < 20 candles" {
|
||||
var candles: [10]zfin.Candle = undefined;
|
||||
buildLinearCandles(&candles, 100.0);
|
||||
const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
|
||||
const result = renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
|
||||
try std.testing.expectError(error.InsufficientData, result);
|
||||
}
|
||||
|
||||
|
|
@ -766,7 +746,7 @@ test "renderToSurface produces a populated surface at requested dimensions" {
|
|||
var candles: [30]zfin.Candle = undefined;
|
||||
buildLinearCandles(&candles, 100.0);
|
||||
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
|
||||
defer rendered.deinit(test_alloc);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 200), rendered.width);
|
||||
|
|
@ -782,7 +762,7 @@ test "renderToSurface price range covers the input close range" {
|
|||
var candles: [30]zfin.Candle = undefined;
|
||||
buildLinearCandles(&candles, 100.0); // closes: 100..129
|
||||
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
|
||||
defer rendered.deinit(test_alloc);
|
||||
|
||||
// 5% padding is applied inside renderToSurface, so the recorded
|
||||
|
|
@ -812,7 +792,7 @@ test "renderToSurface uses chartClose so split-day cliffs don't widen the price
|
|||
};
|
||||
}
|
||||
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
|
||||
defer rendered.deinit(test_alloc);
|
||||
|
||||
// With chartClose, max should be near 100 - definitely not 250+.
|
||||
|
|
@ -829,7 +809,7 @@ test "renderToSurface fills background with theme bg" {
|
|||
var th = theme.default_theme;
|
||||
th.bg = .{ 0x12, 0x34, 0x56 };
|
||||
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null);
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, th, null, false);
|
||||
defer rendered.deinit(test_alloc);
|
||||
|
||||
// Pixel at (0, 0) is in the top-left margin - outside the chart
|
||||
|
|
@ -849,9 +829,9 @@ test "renderToSurface is deterministic across two calls with same input" {
|
|||
var candles: [30]zfin.Candle = undefined;
|
||||
buildLinearCandles(&candles, 100.0);
|
||||
|
||||
var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
|
||||
var a = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
|
||||
defer a.deinit(test_alloc);
|
||||
var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null);
|
||||
var b = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 200, 100, theme.default_theme, null, false);
|
||||
defer b.deinit(test_alloc);
|
||||
|
||||
const buf_a = switch (a.surface) {
|
||||
|
|
@ -877,7 +857,7 @@ test "RenderedChart.extractRgb produces 3 bytes per pixel matching surface buffe
|
|||
var candles: [30]zfin.Candle = undefined;
|
||||
buildLinearCandles(&candles, 100.0);
|
||||
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null);
|
||||
var rendered = try renderToSurface(std.testing.io, test_alloc, &candles, .@"6M", 50, 40, theme.default_theme, null, false);
|
||||
defer rendered.deinit(test_alloc);
|
||||
|
||||
const raw = try rendered.extractRgb(test_alloc);
|
||||
308
src/charts/draw.zig
Normal file
308
src/charts/draw.zig
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
//! Shared z2d drawing primitives for the chart renderers in
|
||||
//! `src/charts/` (chart.zig, projection_chart.zig, forecast_chart.zig,
|
||||
//! line_chart.zig).
|
||||
//!
|
||||
//! These helpers were copy-pasted verbatim across all four renderers
|
||||
//! before this module existed; they're consolidated here so there is a
|
||||
//! single source of truth. Everything here is a pure, stateless z2d
|
||||
//! operation parameterized by pixel coordinates and pre-blended colors:
|
||||
//! no theme, domain, or chart-shape knowledge lives here.
|
||||
//!
|
||||
//! The Surface/Context lifetime (the `Surface.init` + `errdefer`/`defer
|
||||
//! deinit` dance and the AA/operator setup) deliberately stays with
|
||||
//! each renderer - that ownership does not extract cleanly - so only
|
||||
//! the stateless drawing belongs in this module.
|
||||
|
||||
const std = @import("std");
|
||||
const z2d = @import("z2d");
|
||||
|
||||
const Surface = z2d.Surface;
|
||||
const Context = z2d.Context;
|
||||
const Pixel = z2d.Pixel;
|
||||
|
||||
/// Map a data value to a y pixel coordinate within `[top_px, bottom_px]`.
|
||||
/// A larger value maps nearer `top_px` (screen space grows downward). A
|
||||
/// degenerate (`min_val == max_val`) range maps to the vertical midpoint.
|
||||
pub fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
|
||||
if (max_val == min_val) return (top_px + bottom_px) / 2;
|
||||
const norm = (value - min_val) / (max_val - min_val);
|
||||
return bottom_px - norm * (bottom_px - top_px);
|
||||
}
|
||||
|
||||
/// Pre-blend a foreground color with `alpha` against a background color
|
||||
/// and return a fully opaque pixel. This sidesteps z2d's src_over
|
||||
/// compositor (which overflows on semi-transparent fills); renderers
|
||||
/// draw with the `.src` operator and pre-blend through here instead.
|
||||
pub fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
|
||||
const a = @as(f64, @floatFromInt(alpha)) / 255.0;
|
||||
const inv_a = 1.0 - a;
|
||||
return .{ .rgb = .{
|
||||
.r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
|
||||
.g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
|
||||
.b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
|
||||
} };
|
||||
}
|
||||
|
||||
/// Opaque pixel from an RGB triple.
|
||||
pub fn opaqueColor(c: [3]u8) Pixel {
|
||||
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
|
||||
}
|
||||
|
||||
/// Fill the whole `width` x `height` surface with an opaque background.
|
||||
/// Mirrors the "Background" layer every renderer paints first.
|
||||
pub fn fillBackground(ctx: *Context, width: f64, height: f64, bg: [3]u8) !void {
|
||||
ctx.setSourceToPixel(opaqueColor(bg));
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(0, 0);
|
||||
try ctx.lineTo(width, 0);
|
||||
try ctx.lineTo(width, height);
|
||||
try ctx.lineTo(0, height);
|
||||
try ctx.closePath();
|
||||
try ctx.fill();
|
||||
}
|
||||
|
||||
/// Draw `n_lines - 1` evenly-spaced horizontal grid lines strictly
|
||||
/// between `top` and `bottom` (the edges themselves are left to the
|
||||
/// panel border). Restores the line width to 2.0 when done so callers
|
||||
/// can keep drawing without re-setting it.
|
||||
pub fn drawHorizontalGridLines(
|
||||
ctx: *Context,
|
||||
left: f64,
|
||||
right: f64,
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
n_lines: usize,
|
||||
col: Pixel,
|
||||
) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(0.5);
|
||||
for (1..n_lines) |i| {
|
||||
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
|
||||
const y = top + frac * (bottom - top);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(left, y);
|
||||
try ctx.lineTo(right, y);
|
||||
try ctx.stroke();
|
||||
}
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
/// Draw a horizontal line at `y` from `x1` to `x2`. Restores the line
|
||||
/// width to 2.0 afterward.
|
||||
pub fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(line_w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x1, y);
|
||||
try ctx.lineTo(x2, y);
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
/// Draw a vertical line at `x` from `y1` to `y2`. Restores the line
|
||||
/// width to 2.0 afterward.
|
||||
pub fn drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(line_w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x, y1);
|
||||
try ctx.lineTo(x, y2);
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
/// Stroke an axis-aligned rectangle outline. Restores the line width to
|
||||
/// 2.0 afterward.
|
||||
pub fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(line_w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x1, y1);
|
||||
try ctx.lineTo(x2, y1);
|
||||
try ctx.lineTo(x2, y2);
|
||||
try ctx.lineTo(x1, y2);
|
||||
try ctx.closePath();
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
/// Extract a flat `[]u8` of R,G,B triplets from an `image_surface_rgb`.
|
||||
/// Caller owns the returned slice; the surface is left intact. Every
|
||||
/// renderer transmits/encodes pixels through this same shape (Kitty
|
||||
/// graphics RGB and z2d PNG export both want tightly-packed RGB bytes).
|
||||
pub fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 {
|
||||
const rgb_buf = switch (sfc.*) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
|
||||
for (rgb_buf, 0..) |px, i| {
|
||||
raw[i * 3 + 0] = px.r;
|
||||
raw[i * 3 + 1] = px.g;
|
||||
raw[i * 3 + 2] = px.b;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// Build a fresh RGB drawing context backed by `sfc`, configured the way
|
||||
/// every renderer configures it (AA off, `.src` operator). Caller owns
|
||||
/// both and must `ctx.deinit()` / `sfc.deinit(alloc)`.
|
||||
fn testContext(sfc: *Surface) Context {
|
||||
var ctx = Context.init(testing.io, testing.allocator, sfc);
|
||||
ctx.setAntiAliasingMode(.none);
|
||||
ctx.setOperator(.src);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/// Count pixels in the surface that exactly match the given RGB. A
|
||||
/// shared test helper for the chart modules in this directory (draw,
|
||||
/// text, axis) that need to assert glyphs/lines actually landed in a
|
||||
/// known color. `pub` only so the sibling test files can reuse it.
|
||||
pub fn countColor(sfc: *const Surface, color: [3]u8) usize {
|
||||
const buf = switch (sfc.*) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
var n: usize = 0;
|
||||
for (buf) |px| {
|
||||
if (px.r == color[0] and px.g == color[1] and px.b == color[2]) n += 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
test "mapY maps value to pixel coordinate" {
|
||||
// value at min -> bottom
|
||||
try testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
|
||||
// value at max -> top
|
||||
try testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
|
||||
// value at midpoint -> midpoint
|
||||
try testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
|
||||
// flat range -> midpoint
|
||||
try testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
|
||||
}
|
||||
|
||||
test "blendColor alpha blending" {
|
||||
const white = [3]u8{ 255, 255, 255 };
|
||||
const black = [3]u8{ 0, 0, 0 };
|
||||
|
||||
// Full alpha -> foreground.
|
||||
const full = blendColor(white, 255, black);
|
||||
try testing.expectEqual(@as(u8, 255), full.rgb.r);
|
||||
try testing.expectEqual(@as(u8, 255), full.rgb.g);
|
||||
try testing.expectEqual(@as(u8, 255), full.rgb.b);
|
||||
|
||||
// Zero alpha -> background.
|
||||
const zero = blendColor(white, 0, black);
|
||||
try testing.expectEqual(@as(u8, 0), zero.rgb.r);
|
||||
|
||||
// Half alpha -> midpoint (255 * 128/255 ~= 128).
|
||||
const half = blendColor(white, 128, black);
|
||||
try testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
|
||||
|
||||
// Zero alpha blends toward a non-black background, not just black.
|
||||
const onto_gray = blendColor(white, 0, .{ 40, 50, 60 });
|
||||
try testing.expectEqual(@as(u8, 40), onto_gray.rgb.r);
|
||||
try testing.expectEqual(@as(u8, 50), onto_gray.rgb.g);
|
||||
try testing.expectEqual(@as(u8, 60), onto_gray.rgb.b);
|
||||
}
|
||||
|
||||
test "opaqueColor wraps an RGB triple" {
|
||||
const px = opaqueColor(.{ 0x7f, 0xd8, 0x8f });
|
||||
try testing.expectEqual(@as(u8, 0x7f), px.rgb.r);
|
||||
try testing.expectEqual(@as(u8, 0xd8), px.rgb.g);
|
||||
try testing.expectEqual(@as(u8, 0x8f), px.rgb.b);
|
||||
}
|
||||
|
||||
test "fillBackground paints every pixel the bg color" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 8, 6);
|
||||
defer sfc.deinit(alloc);
|
||||
var ctx = testContext(&sfc);
|
||||
defer ctx.deinit();
|
||||
|
||||
try fillBackground(&ctx, 8, 6, .{ 0x11, 0x22, 0x33 });
|
||||
|
||||
const buf = switch (sfc) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
for (buf) |px| {
|
||||
try testing.expectEqual(@as(u8, 0x11), px.r);
|
||||
try testing.expectEqual(@as(u8, 0x22), px.g);
|
||||
try testing.expectEqual(@as(u8, 0x33), px.b);
|
||||
}
|
||||
}
|
||||
|
||||
test "extractRgb yields 3 interleaved bytes per pixel" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 4, 4);
|
||||
defer sfc.deinit(alloc);
|
||||
var ctx = testContext(&sfc);
|
||||
defer ctx.deinit();
|
||||
try fillBackground(&ctx, 4, 4, .{ 0xde, 0xad, 0xbe });
|
||||
|
||||
const raw = try extractRgb(alloc, &sfc);
|
||||
defer alloc.free(raw);
|
||||
|
||||
const buf = switch (sfc) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
try testing.expectEqual(buf.len * 3, raw.len);
|
||||
// First pixel round-trips as (R, G, B) at indices 0, 1, 2.
|
||||
try testing.expectEqual(@as(u8, 0xde), raw[0]);
|
||||
try testing.expectEqual(@as(u8, 0xad), raw[1]);
|
||||
try testing.expectEqual(@as(u8, 0xbe), raw[2]);
|
||||
}
|
||||
|
||||
test "drawHLine strokes a line in the requested color" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24);
|
||||
defer sfc.deinit(alloc);
|
||||
var ctx = testContext(&sfc);
|
||||
defer ctx.deinit();
|
||||
try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 });
|
||||
|
||||
try drawHLine(&ctx, 2, 38, 12, opaqueColor(.{ 0xff, 0x00, 0x00 }), 1.0);
|
||||
try testing.expect(countColor(&sfc, .{ 0xff, 0x00, 0x00 }) > 0);
|
||||
}
|
||||
|
||||
test "drawVLine strokes a line in the requested color" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24);
|
||||
defer sfc.deinit(alloc);
|
||||
var ctx = testContext(&sfc);
|
||||
defer ctx.deinit();
|
||||
try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 });
|
||||
|
||||
try drawVLine(&ctx, 20, 2, 22, opaqueColor(.{ 0x00, 0xff, 0x00 }), 1.0);
|
||||
try testing.expect(countColor(&sfc, .{ 0x00, 0xff, 0x00 }) > 0);
|
||||
}
|
||||
|
||||
test "drawRect strokes a rectangle outline in the requested color" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 24);
|
||||
defer sfc.deinit(alloc);
|
||||
var ctx = testContext(&sfc);
|
||||
defer ctx.deinit();
|
||||
try fillBackground(&ctx, 40, 24, .{ 0, 0, 0 });
|
||||
|
||||
try drawRect(&ctx, 4, 4, 36, 20, opaqueColor(.{ 0x00, 0x00, 0xff }), 1.0);
|
||||
try testing.expect(countColor(&sfc, .{ 0x00, 0x00, 0xff }) > 0);
|
||||
}
|
||||
|
||||
test "drawHorizontalGridLines strokes lines in the requested color" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 40, 40);
|
||||
defer sfc.deinit(alloc);
|
||||
var ctx = testContext(&sfc);
|
||||
defer ctx.deinit();
|
||||
try fillBackground(&ctx, 40, 40, .{ 0, 0, 0 });
|
||||
|
||||
try drawHorizontalGridLines(&ctx, 2, 38, 2, 38, 5, opaqueColor(.{ 0x33, 0x66, 0x99 }));
|
||||
try testing.expect(countColor(&sfc, .{ 0x33, 0x66, 0x99 }) > 0);
|
||||
}
|
||||
|
|
@ -27,9 +27,10 @@
|
|||
|
||||
const std = @import("std");
|
||||
const z2d = @import("z2d");
|
||||
const theme = @import("theme.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
const forecast = @import("../analytics/forecast_evaluation.zig");
|
||||
const Date = @import("../Date.zig");
|
||||
const draw = @import("draw.zig");
|
||||
|
||||
const Surface = z2d.Surface;
|
||||
const Context = z2d.Context;
|
||||
|
|
@ -92,14 +93,7 @@ pub fn renderConvergenceChart(
|
|||
const fheight: f64 = @floatFromInt(height_px);
|
||||
|
||||
// Background
|
||||
ctx.setSourceToPixel(opaqueColor(bg));
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(0, 0);
|
||||
try ctx.lineTo(fwidth, 0);
|
||||
try ctx.lineTo(fwidth, fheight);
|
||||
try ctx.lineTo(0, fheight);
|
||||
try ctx.closePath();
|
||||
try ctx.fill();
|
||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||
|
||||
const chart_left = margin_left;
|
||||
const chart_right = fwidth - margin_right;
|
||||
|
|
@ -244,14 +238,7 @@ pub fn renderBacktestChart(
|
|||
const fheight: f64 = @floatFromInt(height_px);
|
||||
|
||||
// Background
|
||||
ctx.setSourceToPixel(opaqueColor(bg));
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(0, 0);
|
||||
try ctx.lineTo(fwidth, 0);
|
||||
try ctx.lineTo(fwidth, fheight);
|
||||
try ctx.lineTo(0, fheight);
|
||||
try ctx.closePath();
|
||||
try ctx.fill();
|
||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||
|
||||
const chart_left = margin_left;
|
||||
const chart_right = fwidth - margin_right;
|
||||
|
|
@ -492,90 +479,20 @@ fn fillCircle(ctx: *Context, cx: f64, cy: f64, r: f64) !void {
|
|||
try ctx.fill();
|
||||
}
|
||||
|
||||
// ── Shared helpers (mirrors of projection_chart's privates) ───
|
||||
// ── Shared helpers ────────────────────────────────────────────────────
|
||||
//
|
||||
// The stateless primitives below are shared with the other chart
|
||||
// renderers and live in `draw.zig`; aliased here so the call sites in
|
||||
// this file stay unchanged. The chart-specific helpers above
|
||||
// (drawSeries/strokeSegment/drawDashedLine/fillCircle) stay local.
|
||||
|
||||
fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
|
||||
if (max_val == min_val) return (top_px + bottom_px) / 2;
|
||||
const norm = (value - min_val) / (max_val - min_val);
|
||||
return bottom_px - norm * (bottom_px - top_px);
|
||||
}
|
||||
|
||||
fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
|
||||
const a = @as(f64, @floatFromInt(alpha)) / 255.0;
|
||||
const inv_a = 1.0 - a;
|
||||
return .{ .rgb = .{
|
||||
.r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
|
||||
.g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
|
||||
.b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
|
||||
} };
|
||||
}
|
||||
|
||||
fn opaqueColor(c: [3]u8) Pixel {
|
||||
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
|
||||
}
|
||||
|
||||
fn drawHorizontalGridLines(
|
||||
ctx: *Context,
|
||||
left: f64,
|
||||
right: f64,
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
n_lines: usize,
|
||||
col: Pixel,
|
||||
) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(0.5);
|
||||
for (1..n_lines) |i| {
|
||||
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
|
||||
const y = top + frac * (bottom - top);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(left, y);
|
||||
try ctx.lineTo(right, y);
|
||||
try ctx.stroke();
|
||||
}
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(line_w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x1, y);
|
||||
try ctx.lineTo(x2, y);
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(line_w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x1, y1);
|
||||
try ctx.lineTo(x2, y1);
|
||||
try ctx.lineTo(x2, y2);
|
||||
try ctx.lineTo(x1, y2);
|
||||
try ctx.closePath();
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
/// Extract raw RGB bytes from an `image_surface_rgb`. Mirrors the
|
||||
/// inline pattern in `projection_chart.zig` so both renderers
|
||||
/// produce the same on-the-wire shape for Kitty graphics
|
||||
/// transmission. Caller owns the returned slice.
|
||||
fn extractRgb(alloc: std.mem.Allocator, sfc: *const Surface) ![]u8 {
|
||||
const rgb_buf = switch (sfc.*) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
const out = try alloc.alloc(u8, rgb_buf.len * 3);
|
||||
for (rgb_buf, 0..) |px, i| {
|
||||
out[i * 3 + 0] = px.r;
|
||||
out[i * 3 + 1] = px.g;
|
||||
out[i * 3 + 2] = px.b;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const mapY = draw.mapY;
|
||||
const blendColor = draw.blendColor;
|
||||
const opaqueColor = draw.opaqueColor;
|
||||
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
|
||||
const drawHLine = draw.drawHLine;
|
||||
const drawRect = draw.drawRect;
|
||||
const extractRgb = draw.extractRgb;
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────
|
||||
|
||||
429
src/charts/line_chart.zig
Normal file
429
src/charts/line_chart.zig
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
//! Single-series line chart renderer using z2d.
|
||||
//!
|
||||
//! A deliberately slim sibling of `projection_chart.zig`: it draws one
|
||||
//! value-over-time series (e.g. portfolio Liquid / Net Worth) as a
|
||||
//! filled, trend-colored line - no percentile bands, no Bollinger, no
|
||||
//! RSI, no volume. It exists so the `history` command can render its
|
||||
//! portfolio-value timeline as a real bitmap chart (inline Kitty
|
||||
//! graphics or a PNG via `--export-chart`) instead of only braille,
|
||||
//! reusing the same `renderToSurface` -> RGB / PNG seam the other
|
||||
//! charts use.
|
||||
//!
|
||||
//! Visual layers (bottom to top):
|
||||
//! - Background
|
||||
//! - Horizontal grid lines
|
||||
//! - Filled area under the line (faint, trend-colored)
|
||||
//! - Zero reference line (only when the y-range straddles zero)
|
||||
//! - The value line (solid, trend-colored: green if the series ended
|
||||
//! above where it started, red otherwise)
|
||||
//! - Panel border
|
||||
//!
|
||||
//! X positions are date-proportional: each point sits at
|
||||
//! `(date - first_date) / (last_date - first_date)` across the chart
|
||||
//! width, so irregular snapshot spacing (daily recent, weekly/monthly
|
||||
//! for older imported history) renders to scale rather than evenly by
|
||||
//! index. If every point shares a date (degenerate input) the renderer
|
||||
//! falls back to even index spacing.
|
||||
|
||||
const std = @import("std");
|
||||
const z2d = @import("z2d");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
const Date = @import("../Date.zig");
|
||||
const draw = @import("draw.zig");
|
||||
const axis = @import("axis.zig");
|
||||
|
||||
const Surface = z2d.Surface;
|
||||
const Context = z2d.Context;
|
||||
|
||||
/// Margins in pixels.
|
||||
const margin_left: f64 = 4;
|
||||
const margin_right: f64 = 4;
|
||||
const margin_top: f64 = 4;
|
||||
const margin_bottom: f64 = 4;
|
||||
|
||||
/// How the y-axis lower bound is chosen.
|
||||
pub const Baseline = enum {
|
||||
/// Fit the data: lower bound is the series minimum minus a small
|
||||
/// pad. Best for reading week-to-week variation in a large,
|
||||
/// slowly-moving balance.
|
||||
fit,
|
||||
/// Anchor the lower bound at zero (clamped to the data minimum if
|
||||
/// the series itself dips below zero). Shows absolute scale at the
|
||||
/// cost of flattening the visible variation.
|
||||
zero,
|
||||
};
|
||||
|
||||
/// One (date, value) point on the series. Leaf-level: the chart module
|
||||
/// deliberately does not depend on `analytics/timeline.zig`. Callers
|
||||
/// convert their domain points (e.g. `timeline.MetricPoint`) into this
|
||||
/// shape - same fields, distinct type - the way `projection_chart`
|
||||
/// keeps its own `ActualsPoint`.
|
||||
pub const LinePoint = struct {
|
||||
date: Date,
|
||||
value: f64,
|
||||
};
|
||||
|
||||
/// Render options.
|
||||
pub const Options = struct {
|
||||
baseline: Baseline = .fit,
|
||||
/// Draw in-image axis labels (y-axis dollar ticks + x-axis start/end
|
||||
/// dates) into reserved margins. Off by default: the TUI inline path
|
||||
/// draws its own terminal-text labels beside the image and wants the
|
||||
/// bare bitmap. The PNG export turns this on so the standalone file
|
||||
/// is self-describing.
|
||||
axis_labels: bool = false,
|
||||
};
|
||||
|
||||
/// Line chart render result (raw RGB), produced by `renderLineChart`.
|
||||
pub const LineChartResult = struct {
|
||||
/// Raw RGB pixel data (3 bytes per pixel, row-major).
|
||||
rgb_data: []const u8,
|
||||
width: u16,
|
||||
height: u16,
|
||||
/// Value range for external label rendering.
|
||||
value_min: f64,
|
||||
value_max: f64,
|
||||
};
|
||||
|
||||
/// Owned by the caller - call `result.deinit(alloc)` after using it.
|
||||
/// The shared mid-stage between RGB extraction (Kitty graphics) and PNG
|
||||
/// export (`--export-chart`). See `renderToSurface`.
|
||||
pub const RenderedLineChart = struct {
|
||||
surface: Surface,
|
||||
width: u16,
|
||||
height: u16,
|
||||
value_min: f64,
|
||||
value_max: f64,
|
||||
|
||||
pub fn deinit(self: *RenderedLineChart, alloc: std.mem.Allocator) void {
|
||||
self.surface.deinit(alloc);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
/// Extract a flat []u8 of R,G,B triplets from the surface buffer.
|
||||
/// Caller owns the returned slice. The surface is left intact.
|
||||
pub fn extractRgb(self: *const RenderedLineChart, alloc: std.mem.Allocator) ![]u8 {
|
||||
return draw.extractRgb(alloc, &self.surface);
|
||||
}
|
||||
};
|
||||
|
||||
/// Render a single-series line chart into a `Surface` and return both.
|
||||
/// Caller owns the result and must call `deinit`.
|
||||
///
|
||||
/// Two consumers:
|
||||
/// - `renderLineChart` wraps this for the CLI's inline Kitty graphics
|
||||
/// path (extracts RGB, frees surface).
|
||||
/// - `chart_export.exportTimelineChart` wraps this for PNG export via
|
||||
/// `z2d.png_exporter.writeToPNGFile`.
|
||||
pub fn renderToSurface(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
points: []const LinePoint,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
opts: Options,
|
||||
) !RenderedLineChart {
|
||||
if (points.len < 2) return error.InsufficientData;
|
||||
|
||||
const w: i32 = @intCast(width_px);
|
||||
const h: i32 = @intCast(height_px);
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, w, h);
|
||||
errdefer sfc.deinit(alloc);
|
||||
|
||||
var ctx = Context.init(io, alloc, &sfc);
|
||||
defer ctx.deinit();
|
||||
|
||||
ctx.setAntiAliasingMode(.none);
|
||||
ctx.setOperator(.src);
|
||||
|
||||
const bg = th.bg;
|
||||
const fwidth: f64 = @floatFromInt(width_px);
|
||||
const fheight: f64 = @floatFromInt(height_px);
|
||||
|
||||
// Background
|
||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||
|
||||
// Chart area. When axis labels are enabled we reserve a left margin
|
||||
// (y-axis dollar ticks) and a bottom margin (start/end dates), scaled
|
||||
// with the surface so labels stay legible on large exports.
|
||||
const label_scale: i32 = axis.labelScale(h);
|
||||
const label_char_h: f64 = axis.charHeight(label_scale);
|
||||
const m_left: f64 = if (opts.axis_labels) label_char_h else margin_left;
|
||||
const m_right: f64 = if (opts.axis_labels) axis.yAxisMargin(label_scale) else margin_right;
|
||||
const m_top: f64 = if (opts.axis_labels) (label_char_h / 2 + 4) else margin_top;
|
||||
const m_bottom: f64 = if (opts.axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
|
||||
|
||||
const chart_left = m_left;
|
||||
const chart_right = fwidth - m_right;
|
||||
const chart_w = chart_right - chart_left;
|
||||
const chart_top = m_top;
|
||||
const chart_bottom = fheight - m_bottom;
|
||||
|
||||
// ── Value range ──────────────────────────────────────────────
|
||||
var data_min: f64 = points[0].value;
|
||||
var data_max: f64 = points[0].value;
|
||||
for (points) |p| {
|
||||
if (p.value < data_min) data_min = p.value;
|
||||
if (p.value > data_max) data_max = p.value;
|
||||
}
|
||||
const pad = (data_max - data_min) * 0.05;
|
||||
const value_max: f64 = data_max + pad;
|
||||
const value_min: f64 = switch (opts.baseline) {
|
||||
.fit => data_min - pad,
|
||||
// Anchor at zero, but never crop a series that genuinely dips
|
||||
// below zero (e.g. a negative net worth).
|
||||
.zero => @min(0, data_min),
|
||||
};
|
||||
|
||||
// ── X mapping (date-proportional) ─────────────────────────────
|
||||
const first_days: f64 = @floatFromInt(points[0].date.days);
|
||||
const last_days: f64 = @floatFromInt(points[points.len - 1].date.days);
|
||||
const span_days: f64 = last_days - first_days;
|
||||
// Degenerate input (all points share a date): fall back to even
|
||||
// index spacing so we still draw something sensible.
|
||||
const use_dates = span_days > 0;
|
||||
const index_step = chart_w / @as(f64, @floatFromInt(points.len - 1));
|
||||
|
||||
const mapX = struct {
|
||||
fn at(i: usize, p: LinePoint, left: f64, cw: f64, fd: f64, span: f64, by_date: bool, istep: f64) f64 {
|
||||
if (by_date) {
|
||||
const d: f64 = @floatFromInt(p.date.days);
|
||||
return left + ((d - fd) / span) * cw;
|
||||
}
|
||||
return left + @as(f64, @floatFromInt(i)) * istep;
|
||||
}
|
||||
}.at;
|
||||
|
||||
// ── Grid lines ────────────────────────────────────────────────
|
||||
const grid_color = blendColor(th.text_muted, 40, bg);
|
||||
try drawHorizontalGridLines(&ctx, chart_left, chart_right, chart_top, chart_bottom, 5, grid_color);
|
||||
|
||||
// ── Filled area under the line (faint, trend-colored) ─────────
|
||||
const trend_up = points[points.len - 1].value >= points[0].value;
|
||||
const trend_color = if (trend_up) th.positive else th.negative;
|
||||
{
|
||||
const fill_color = blendColor(trend_color, 30, bg);
|
||||
ctx.setSourceToPixel(fill_color);
|
||||
ctx.resetPath();
|
||||
for (points, 0..) |p, i| {
|
||||
const x = mapX(i, p, chart_left, chart_w, first_days, span_days, use_dates, index_step);
|
||||
const y = mapY(p.value, value_min, value_max, chart_top, chart_bottom);
|
||||
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
|
||||
}
|
||||
const last_x = mapX(points.len - 1, points[points.len - 1], chart_left, chart_w, first_days, span_days, use_dates, index_step);
|
||||
try ctx.lineTo(last_x, chart_bottom);
|
||||
try ctx.lineTo(chart_left, chart_bottom);
|
||||
try ctx.closePath();
|
||||
try ctx.fill();
|
||||
}
|
||||
|
||||
// ── Zero reference line (only when the range straddles zero) ──
|
||||
if (value_min < 0 and value_max > 0) {
|
||||
const zero_y = mapY(0, value_min, value_max, chart_top, chart_bottom);
|
||||
const zero_color = blendColor(th.text_muted, 100, bg);
|
||||
try drawHLine(&ctx, chart_left, chart_right, zero_y, zero_color, 1.0);
|
||||
}
|
||||
|
||||
// ── Value line (solid, trend-colored, on top) ─────────────────
|
||||
{
|
||||
ctx.setSourceToPixel(opaqueColor(trend_color));
|
||||
ctx.setLineWidth(2.0);
|
||||
ctx.resetPath();
|
||||
for (points, 0..) |p, i| {
|
||||
const x = mapX(i, p, chart_left, chart_w, first_days, span_days, use_dates, index_step);
|
||||
const y = mapY(p.value, value_min, value_max, chart_top, chart_bottom);
|
||||
if (i == 0) try ctx.moveTo(x, y) else try ctx.lineTo(x, y);
|
||||
}
|
||||
try ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Panel border ──────────────────────────────────────────────
|
||||
{
|
||||
const border_color = blendColor(th.border, 80, bg);
|
||||
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0);
|
||||
}
|
||||
|
||||
// ── Axis labels (export only; drawn directly into the buffer) ──
|
||||
if (opts.axis_labels) {
|
||||
axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, value_min, value_max, 5);
|
||||
var fbuf: [12]u8 = undefined;
|
||||
var lbuf: [12]u8 = undefined;
|
||||
const first_s = std.fmt.bufPrint(&fbuf, "{f}", .{points[0].date}) catch "";
|
||||
const last_s = std.fmt.bufPrint(&lbuf, "{f}", .{points[points.len - 1].date}) catch "";
|
||||
const date_y = chart_bottom + axis.labelGap(label_scale);
|
||||
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, date_y, first_s, last_s);
|
||||
}
|
||||
|
||||
return .{
|
||||
.surface = sfc,
|
||||
.width = @intCast(width_px),
|
||||
.height = @intCast(height_px),
|
||||
.value_min = value_min,
|
||||
.value_max = value_max,
|
||||
};
|
||||
}
|
||||
|
||||
/// Render a single-series line chart to raw RGB pixel data. The returned
|
||||
/// `rgb_data` is allocated with `alloc` and must be freed by the caller.
|
||||
pub fn renderLineChart(
|
||||
io: std.Io,
|
||||
alloc: std.mem.Allocator,
|
||||
points: []const LinePoint,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
opts: Options,
|
||||
) !LineChartResult {
|
||||
var rendered = try renderToSurface(io, alloc, points, width_px, height_px, th, opts);
|
||||
defer rendered.deinit(alloc);
|
||||
const raw = try rendered.extractRgb(alloc);
|
||||
return .{
|
||||
.rgb_data = raw,
|
||||
.width = rendered.width,
|
||||
.height = rendered.height,
|
||||
.value_min = rendered.value_min,
|
||||
.value_max = rendered.value_max,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Drawing helpers ───────────────────────────────────────────────────
|
||||
//
|
||||
// The stateless primitives below are shared with the other chart
|
||||
// renderers and live in `draw.zig`; aliased here so the call sites in
|
||||
// this file stay unchanged.
|
||||
|
||||
const mapY = draw.mapY;
|
||||
const blendColor = draw.blendColor;
|
||||
const opaqueColor = draw.opaqueColor;
|
||||
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
|
||||
const drawHLine = draw.drawHLine;
|
||||
const drawRect = draw.drawRect;
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const test_th = theme.default_theme;
|
||||
|
||||
fn pt(y: i16, m: u8, d: u8, v: f64) LinePoint {
|
||||
return .{ .date = Date.fromYmd(y, m, d), .value = v };
|
||||
}
|
||||
|
||||
test "renderToSurface returns InsufficientData with < 2 points" {
|
||||
const alloc = std.testing.allocator;
|
||||
const one = [_]LinePoint{pt(2026, 1, 1, 100)};
|
||||
try std.testing.expectError(error.InsufficientData, renderToSurface(std.testing.io, alloc, &one, 200, 100, test_th, .{}));
|
||||
const none = [_]LinePoint{};
|
||||
try std.testing.expectError(error.InsufficientData, renderToSurface(std.testing.io, alloc, &none, 200, 100, test_th, .{}));
|
||||
}
|
||||
|
||||
test "renderToSurface returns a populated RGB surface at requested dimensions" {
|
||||
const alloc = std.testing.allocator;
|
||||
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120), pt(2026, 3, 1, 110) };
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{});
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 200), rendered.width);
|
||||
try std.testing.expectEqual(@as(u16, 100), rendered.height);
|
||||
switch (rendered.surface) {
|
||||
.image_surface_rgb => {},
|
||||
else => try std.testing.expect(false),
|
||||
}
|
||||
}
|
||||
|
||||
test "renderToSurface fills background with theme bg" {
|
||||
const alloc = std.testing.allocator;
|
||||
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120) };
|
||||
var th = theme.default_theme;
|
||||
th.bg = .{ 0xab, 0xcd, 0xef };
|
||||
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &points, 100, 50, th, .{});
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
const buf = switch (rendered.surface) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
// Pixel (0,0) is in the top-left margin - outside the plotted area.
|
||||
try std.testing.expectEqual(@as(u8, 0xab), buf[0].r);
|
||||
try std.testing.expectEqual(@as(u8, 0xcd), buf[0].g);
|
||||
try std.testing.expectEqual(@as(u8, 0xef), buf[0].b);
|
||||
}
|
||||
|
||||
test "renderToSurface fit baseline keeps value_min at or below the data minimum" {
|
||||
const alloc = std.testing.allocator;
|
||||
// All values well above zero; fit must NOT anchor at zero.
|
||||
const points = [_]LinePoint{ pt(2026, 1, 1, 1_000_000), pt(2026, 2, 1, 1_050_000), pt(2026, 3, 1, 1_020_000) };
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .fit });
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try std.testing.expect(rendered.value_min <= 1_000_000);
|
||||
try std.testing.expect(rendered.value_min > 0); // far from zero
|
||||
try std.testing.expect(rendered.value_max >= 1_050_000);
|
||||
}
|
||||
|
||||
test "renderToSurface zero baseline anchors value_min at zero for positive data" {
|
||||
const alloc = std.testing.allocator;
|
||||
const points = [_]LinePoint{ pt(2026, 1, 1, 1_000_000), pt(2026, 2, 1, 1_050_000) };
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .zero });
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try std.testing.expectEqual(@as(f64, 0), rendered.value_min);
|
||||
try std.testing.expect(rendered.value_max > 1_000_000);
|
||||
}
|
||||
|
||||
test "renderToSurface zero baseline still includes a negative data minimum" {
|
||||
const alloc = std.testing.allocator;
|
||||
// Net worth dips below zero - zero baseline must not crop it.
|
||||
const points = [_]LinePoint{ pt(2026, 1, 1, -50_000), pt(2026, 2, 1, 10_000) };
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &points, 200, 100, test_th, .{ .baseline = .zero });
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try std.testing.expect(rendered.value_min <= -50_000);
|
||||
}
|
||||
|
||||
test "renderToSurface is deterministic across calls with same input" {
|
||||
const alloc = std.testing.allocator;
|
||||
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 140), pt(2026, 3, 1, 90) };
|
||||
|
||||
var a = try renderToSurface(std.testing.io, alloc, &points, 120, 60, test_th, .{});
|
||||
defer a.deinit(alloc);
|
||||
var b = try renderToSurface(std.testing.io, alloc, &points, 120, 60, test_th, .{});
|
||||
defer b.deinit(alloc);
|
||||
|
||||
const buf_a = switch (a.surface) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
const buf_b = switch (b.surface) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
try std.testing.expectEqual(buf_a.len, buf_b.len);
|
||||
var i: usize = 0;
|
||||
while (i < buf_a.len) : (i += 50) {
|
||||
try std.testing.expectEqual(buf_a[i].r, buf_b[i].r);
|
||||
try std.testing.expectEqual(buf_a[i].g, buf_b[i].g);
|
||||
try std.testing.expectEqual(buf_a[i].b, buf_b[i].b);
|
||||
}
|
||||
}
|
||||
|
||||
test "renderToSurface handles degenerate single-date input without crashing" {
|
||||
const alloc = std.testing.allocator;
|
||||
// Both points share a date -> span is zero -> index-spacing fallback.
|
||||
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 1, 1, 120) };
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &points, 100, 50, test_th, .{});
|
||||
defer rendered.deinit(alloc);
|
||||
try std.testing.expectEqual(@as(u16, 100), rendered.width);
|
||||
}
|
||||
|
||||
test "renderLineChart wraps renderToSurface and produces RGB triplets" {
|
||||
const alloc = std.testing.allocator;
|
||||
const points = [_]LinePoint{ pt(2026, 1, 1, 100), pt(2026, 2, 1, 120), pt(2026, 3, 1, 110) };
|
||||
const result = try renderLineChart(std.testing.io, alloc, &points, 50, 40, test_th, .{});
|
||||
defer alloc.free(result.rgb_data);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 50), result.width);
|
||||
try std.testing.expectEqual(@as(u16, 40), result.height);
|
||||
try std.testing.expectEqual(@as(usize, 50 * 40 * 3), result.rgb_data.len);
|
||||
try std.testing.expect(result.value_max > result.value_min);
|
||||
}
|
||||
|
|
@ -16,12 +16,13 @@
|
|||
|
||||
const std = @import("std");
|
||||
const z2d = @import("z2d");
|
||||
const theme = @import("theme.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
const projections = @import("../analytics/projections.zig");
|
||||
const draw = @import("draw.zig");
|
||||
const axis = @import("axis.zig");
|
||||
|
||||
const Surface = z2d.Surface;
|
||||
const Context = z2d.Context;
|
||||
const Pixel = z2d.Pixel;
|
||||
|
||||
/// Margins in pixels.
|
||||
const margin_left: f64 = 4;
|
||||
|
|
@ -77,17 +78,7 @@ pub const RenderedProjection = struct {
|
|||
/// Extract a flat []u8 of R,G,B triplets from the surface buffer.
|
||||
/// Caller owns the returned slice. The surface is left intact.
|
||||
pub fn extractRgb(self: *const RenderedProjection, alloc: std.mem.Allocator) ![]u8 {
|
||||
const rgb_buf = switch (self.surface) {
|
||||
.image_surface_rgb => |s| s.buf,
|
||||
else => unreachable,
|
||||
};
|
||||
const raw = try alloc.alloc(u8, rgb_buf.len * 3);
|
||||
for (rgb_buf, 0..) |px, i| {
|
||||
raw[i * 3 + 0] = px.r;
|
||||
raw[i * 3 + 1] = px.g;
|
||||
raw[i * 3 + 2] = px.b;
|
||||
}
|
||||
return raw;
|
||||
return draw.extractRgb(alloc, &self.surface);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -107,6 +98,7 @@ pub fn renderToSurface(
|
|||
height_px: u32,
|
||||
th: theme.Theme,
|
||||
actuals: ?ActualsOverlay,
|
||||
axis_labels: bool,
|
||||
) !RenderedProjection {
|
||||
if (bands.len < 2) return error.InsufficientData;
|
||||
|
||||
|
|
@ -126,21 +118,21 @@ pub fn renderToSurface(
|
|||
const fheight: f64 = @floatFromInt(height_px);
|
||||
|
||||
// Background
|
||||
ctx.setSourceToPixel(opaqueColor(bg));
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(0, 0);
|
||||
try ctx.lineTo(fwidth, 0);
|
||||
try ctx.lineTo(fwidth, fheight);
|
||||
try ctx.lineTo(0, fheight);
|
||||
try ctx.closePath();
|
||||
try ctx.fill();
|
||||
try draw.fillBackground(&ctx, fwidth, fheight, bg);
|
||||
|
||||
// Chart area
|
||||
const chart_left = margin_left;
|
||||
const chart_right = fwidth - margin_right;
|
||||
// Chart area. With axis labels we reserve a left margin (dollar
|
||||
// ticks) and a bottom margin (year endpoints), scaled to the surface.
|
||||
const label_scale: i32 = axis.labelScale(h);
|
||||
const label_char_h: f64 = axis.charHeight(label_scale);
|
||||
const m_left: f64 = if (axis_labels) label_char_h else margin_left;
|
||||
const m_right: f64 = if (axis_labels) axis.yAxisMargin(label_scale) else margin_right;
|
||||
const m_top: f64 = if (axis_labels) (label_char_h / 2 + 4) else margin_top;
|
||||
const m_bottom: f64 = if (axis_labels) axis.bottomMargin(label_scale) else margin_bottom;
|
||||
const chart_left = m_left;
|
||||
const chart_right = fwidth - m_right;
|
||||
const chart_w = chart_right - chart_left;
|
||||
const chart_top = margin_top;
|
||||
const chart_bottom = fheight - margin_bottom;
|
||||
const chart_top = m_top;
|
||||
const chart_bottom = fheight - m_bottom;
|
||||
|
||||
// Compute value range from all bands
|
||||
var value_min: f64 = bands[0].p10;
|
||||
|
|
@ -317,6 +309,17 @@ pub fn renderToSurface(
|
|||
try drawRect(&ctx, chart_left, chart_top, chart_right, chart_bottom, border_color, 1.0);
|
||||
}
|
||||
|
||||
// ── Axis labels (export only) ────────────────────────────────
|
||||
if (axis_labels) {
|
||||
axis.drawYDollarTicks(&sfc, label_scale, th.text_muted, chart_right, chart_top, chart_bottom, value_min, value_max, 5);
|
||||
var fbuf: [8]u8 = undefined;
|
||||
var lbuf: [8]u8 = undefined;
|
||||
const first_s = std.fmt.bufPrint(&fbuf, "{d}", .{bands[0].year}) catch "";
|
||||
const last_s = std.fmt.bufPrint(&lbuf, "{d}", .{bands[bands.len - 1].year}) catch "";
|
||||
const yr_y = chart_bottom + axis.labelGap(label_scale);
|
||||
axis.drawXEndpoints(&sfc, label_scale, th.text_muted, chart_left, chart_right, yr_y, first_s, last_s);
|
||||
}
|
||||
|
||||
return .{
|
||||
.surface = sfc,
|
||||
.width = @intCast(width_px),
|
||||
|
|
@ -341,7 +344,7 @@ pub fn renderProjectionChart(
|
|||
th: theme.Theme,
|
||||
actuals: ?ActualsOverlay,
|
||||
) !ProjectionChartResult {
|
||||
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals);
|
||||
var rendered = try renderToSurface(io, alloc, bands, width_px, height_px, th, actuals, false);
|
||||
defer rendered.deinit(alloc);
|
||||
const raw = try rendered.extractRgb(alloc);
|
||||
return .{
|
||||
|
|
@ -354,105 +357,21 @@ pub fn renderProjectionChart(
|
|||
}
|
||||
|
||||
// ── Drawing helpers ───────────────────────────────────────────────────
|
||||
//
|
||||
// The stateless primitives below are shared with the other chart
|
||||
// renderers and live in `draw.zig`; aliased here so the call sites in
|
||||
// this file stay unchanged.
|
||||
|
||||
fn mapY(value: f64, min_val: f64, max_val: f64, top_px: f64, bottom_px: f64) f64 {
|
||||
if (max_val == min_val) return (top_px + bottom_px) / 2;
|
||||
const norm = (value - min_val) / (max_val - min_val);
|
||||
return bottom_px - norm * (bottom_px - top_px);
|
||||
}
|
||||
|
||||
fn blendColor(fg: [3]u8, alpha: u8, bg_color: [3]u8) Pixel {
|
||||
const a = @as(f64, @floatFromInt(alpha)) / 255.0;
|
||||
const inv_a = 1.0 - a;
|
||||
return .{ .rgb = .{
|
||||
.r = @intFromFloat(@as(f64, @floatFromInt(fg[0])) * a + @as(f64, @floatFromInt(bg_color[0])) * inv_a),
|
||||
.g = @intFromFloat(@as(f64, @floatFromInt(fg[1])) * a + @as(f64, @floatFromInt(bg_color[1])) * inv_a),
|
||||
.b = @intFromFloat(@as(f64, @floatFromInt(fg[2])) * a + @as(f64, @floatFromInt(bg_color[2])) * inv_a),
|
||||
} };
|
||||
}
|
||||
|
||||
fn opaqueColor(c: [3]u8) Pixel {
|
||||
return .{ .rgb = .{ .r = c[0], .g = c[1], .b = c[2] } };
|
||||
}
|
||||
|
||||
fn drawHorizontalGridLines(
|
||||
ctx: *Context,
|
||||
left: f64,
|
||||
right: f64,
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
n_lines: usize,
|
||||
col: Pixel,
|
||||
) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(0.5);
|
||||
for (1..n_lines) |i| {
|
||||
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
|
||||
const y = top + frac * (bottom - top);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(left, y);
|
||||
try ctx.lineTo(right, y);
|
||||
try ctx.stroke();
|
||||
}
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, line_w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(line_w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x1, y);
|
||||
try ctx.lineTo(x2, y);
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawVLine(ctx: *Context, x: f64, y1: f64, y2: f64, col: Pixel, line_w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(line_w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x, y1);
|
||||
try ctx.lineTo(x, y2);
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, line_w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(line_w);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(x1, y1);
|
||||
try ctx.lineTo(x2, y1);
|
||||
try ctx.lineTo(x2, y2);
|
||||
try ctx.lineTo(x1, y2);
|
||||
try ctx.closePath();
|
||||
try ctx.stroke();
|
||||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
const mapY = draw.mapY;
|
||||
const blendColor = draw.blendColor;
|
||||
const opaqueColor = draw.opaqueColor;
|
||||
const drawHorizontalGridLines = draw.drawHorizontalGridLines;
|
||||
const drawHLine = draw.drawHLine;
|
||||
const drawVLine = draw.drawVLine;
|
||||
const drawRect = draw.drawRect;
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
test "mapY maps value to pixel coordinate" {
|
||||
try std.testing.expectEqual(@as(f64, 500.0), mapY(0, 0, 100, 100, 500));
|
||||
try std.testing.expectEqual(@as(f64, 100.0), mapY(100, 0, 100, 100, 500));
|
||||
try std.testing.expectEqual(@as(f64, 300.0), mapY(50, 0, 100, 100, 500));
|
||||
try std.testing.expectEqual(@as(f64, 300.0), mapY(42, 42, 42, 100, 500));
|
||||
}
|
||||
|
||||
test "blendColor alpha blending" {
|
||||
const white = [3]u8{ 255, 255, 255 };
|
||||
const black = [3]u8{ 0, 0, 0 };
|
||||
|
||||
const full = blendColor(white, 255, black);
|
||||
try std.testing.expectEqual(@as(u8, 255), full.rgb.r);
|
||||
|
||||
const zero = blendColor(white, 0, black);
|
||||
try std.testing.expectEqual(@as(u8, 0), zero.rgb.r);
|
||||
|
||||
const half = blendColor(white, 128, black);
|
||||
try std.testing.expect(half.rgb.r >= 127 and half.rgb.r <= 129);
|
||||
}
|
||||
|
||||
test "renderProjectionChart produces valid output" {
|
||||
const alloc = std.testing.allocator;
|
||||
const bands = [_]projections.YearPercentiles{
|
||||
|
|
@ -461,7 +380,7 @@ test "renderProjectionChart produces valid output" {
|
|||
.{ .year = 20, .p10 = 3000000, .p25 = 9000000, .p50 = 18000000, .p75 = 30000000, .p90 = 50000000 },
|
||||
};
|
||||
|
||||
const th = @import("theme.zig").default_theme;
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
|
||||
defer alloc.free(result.rgb_data);
|
||||
|
||||
|
|
@ -477,7 +396,7 @@ test "renderProjectionChart insufficient data" {
|
|||
.{ .year = 0, .p10 = 8000000, .p25 = 8000000, .p50 = 8000000, .p75 = 8000000, .p90 = 8000000 },
|
||||
};
|
||||
|
||||
const th = @import("theme.zig").default_theme;
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, null);
|
||||
try std.testing.expectError(error.InsufficientData, result);
|
||||
}
|
||||
|
|
@ -496,7 +415,7 @@ test "renderProjectionChart with overlay produces valid output" {
|
|||
};
|
||||
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
|
||||
|
||||
const th = @import("theme.zig").default_theme;
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
|
||||
defer alloc.free(result.rgb_data);
|
||||
|
||||
|
|
@ -519,7 +438,7 @@ test "renderProjectionChart overlay expands y-range when actuals exceed bands" {
|
|||
};
|
||||
const overlay: ActualsOverlay = .{ .points = &points, .today_years = 1.0 };
|
||||
|
||||
const th = @import("theme.zig").default_theme;
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
|
||||
defer alloc.free(result.rgb_data);
|
||||
|
||||
|
|
@ -536,7 +455,7 @@ test "renderProjectionChart overlay with no points renders without crash" {
|
|||
};
|
||||
const overlay: ActualsOverlay = .{ .points = &.{}, .today_years = 0.5 };
|
||||
|
||||
const th = @import("theme.zig").default_theme;
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
const result = try renderProjectionChart(std.testing.io, alloc, &bands, 200, 100, th, overlay);
|
||||
defer alloc.free(result.rgb_data);
|
||||
try std.testing.expect(result.rgb_data.len > 0);
|
||||
|
|
@ -554,8 +473,8 @@ test "renderToSurface returns a populated RGB surface at requested dimensions" {
|
|||
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
|
||||
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
|
||||
};
|
||||
const th = @import("theme.zig").default_theme;
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null);
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 150, 80, th, null, false);
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
try std.testing.expectEqual(@as(u16, 150), rendered.width);
|
||||
|
|
@ -572,10 +491,10 @@ test "renderToSurface fills background with theme bg" {
|
|||
.{ .year = 0, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
|
||||
.{ .year = 1, .p10 = 100, .p25 = 110, .p50 = 120, .p75 = 130, .p90 = 140 },
|
||||
};
|
||||
var th = @import("theme.zig").default_theme;
|
||||
var th = @import("../tui/theme.zig").default_theme;
|
||||
th.bg = .{ 0xab, 0xcd, 0xef };
|
||||
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null);
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 50, th, null, false);
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
const buf = switch (rendered.surface) {
|
||||
|
|
@ -594,11 +513,11 @@ test "renderToSurface is deterministic across calls with same input" {
|
|||
.{ .year = 5, .p10 = 90, .p25 = 110, .p50 = 130, .p75 = 160, .p90 = 200 },
|
||||
.{ .year = 10, .p10 = 80, .p25 = 120, .p50 = 160, .p75 = 220, .p90 = 300 },
|
||||
};
|
||||
const th = @import("theme.zig").default_theme;
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
|
||||
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
|
||||
var a = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
|
||||
defer a.deinit(alloc);
|
||||
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
|
||||
var b = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
|
||||
defer b.deinit(alloc);
|
||||
|
||||
const buf_a = switch (a.surface) {
|
||||
|
|
@ -624,8 +543,8 @@ test "RenderedProjection.extractRgb produces 3 bytes per pixel" {
|
|||
.{ .year = 0, .p10 = 1, .p25 = 2, .p50 = 3, .p75 = 4, .p90 = 5 },
|
||||
.{ .year = 1, .p10 = 2, .p25 = 3, .p50 = 4, .p75 = 5, .p90 = 6 },
|
||||
};
|
||||
const th = @import("theme.zig").default_theme;
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null);
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 50, 40, th, null, false);
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
const raw = try rendered.extractRgb(alloc);
|
||||
|
|
@ -649,8 +568,8 @@ test "renderToSurface clamps value_min to zero when bands include negatives" {
|
|||
.{ .year = 0, .p10 = -100, .p25 = -50, .p50 = 0, .p75 = 50, .p90 = 100 },
|
||||
.{ .year = 1, .p10 = -200, .p25 = -100, .p50 = 0, .p75 = 100, .p90 = 200 },
|
||||
};
|
||||
const th = @import("theme.zig").default_theme;
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null);
|
||||
const th = @import("../tui/theme.zig").default_theme;
|
||||
var rendered = try renderToSurface(std.testing.io, alloc, &bands, 100, 60, th, null, false);
|
||||
defer rendered.deinit(alloc);
|
||||
|
||||
// After 5% padding and the `if (value_min < 0) value_min = 0`
|
||||
211
src/charts/text.zig
Normal file
211
src/charts/text.zig
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//! A tiny 5x7 bitmap font for drawing axis labels directly into a z2d
|
||||
//! `image_surface_rgb` pixel buffer.
|
||||
//!
|
||||
//! Why a hand-rolled bitmap font instead of `z2d.text` + a TTF?
|
||||
//! 1. The chart renderers in this directory deliberately draw with
|
||||
//! anti-aliasing OFF and the `.src` operator, pre-blending colors
|
||||
//! against the background to sidestep z2d's `src_over` compositor
|
||||
//! overflow on semi-transparent fills. Glyph outline rasterization
|
||||
//! needs alpha blending for its edges and would hit that same bug.
|
||||
//! Solid 1-bit pixels avoid it entirely.
|
||||
//! 2. It keeps a ~hundreds-of-KB TTF (and its license) out of the repo.
|
||||
//!
|
||||
//! The glyph set is intentionally minimal - just what axis labels need:
|
||||
//! digits, `$`, `.`, `,`, `-`, and the `T`/`B`/`M` magnitude suffixes
|
||||
//! emitted by `format.fmtLargeNum`, plus space. Unknown chars render blank.
|
||||
//!
|
||||
//! Coordinates are in surface pixels; `scale` multiplies the 5x7 cell
|
||||
//! (so `scale = 3` renders 15x21 glyphs). Drawing is clipped to the
|
||||
//! surface bounds. Callers own pixel layout (margins, alignment); this
|
||||
//! module only stamps glyphs.
|
||||
|
||||
const std = @import("std");
|
||||
const z2d = @import("z2d");
|
||||
const draw = @import("draw.zig");
|
||||
|
||||
const Surface = z2d.Surface;
|
||||
const RGB = z2d.pixel.RGB;
|
||||
|
||||
/// Glyph cell dimensions, in font pixels (pre-scale).
|
||||
pub const glyph_w: i32 = 5;
|
||||
pub const glyph_h: i32 = 7;
|
||||
/// Horizontal advance per glyph including the 1px inter-glyph gap.
|
||||
pub const advance: i32 = glyph_w + 1;
|
||||
|
||||
/// Each glyph is 7 rows; the low 5 bits of each row are the pixels,
|
||||
/// bit 4 (0b10000) = leftmost column. Row 0 is the top.
|
||||
const Glyph = [7]u8;
|
||||
|
||||
const blank: Glyph = .{ 0, 0, 0, 0, 0, 0, 0 };
|
||||
|
||||
const digits = [10]Glyph{
|
||||
.{ 0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E }, // 0
|
||||
.{ 0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E }, // 1
|
||||
.{ 0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F }, // 2
|
||||
.{ 0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E }, // 3
|
||||
.{ 0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02 }, // 4
|
||||
.{ 0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E }, // 5
|
||||
.{ 0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E }, // 6
|
||||
.{ 0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08 }, // 7
|
||||
.{ 0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E }, // 8
|
||||
.{ 0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C }, // 9
|
||||
};
|
||||
|
||||
const glyph_dollar: Glyph = .{ 0x04, 0x0E, 0x14, 0x0E, 0x05, 0x0E, 0x04 };
|
||||
const glyph_period: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06 };
|
||||
const glyph_comma: Glyph = .{ 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0x08 };
|
||||
const glyph_minus: Glyph = .{ 0x00, 0x00, 0x00, 0x0E, 0x00, 0x00, 0x00 };
|
||||
const glyph_T: Glyph = .{ 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04 };
|
||||
const glyph_B: Glyph = .{ 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E };
|
||||
const glyph_M: Glyph = .{ 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11 };
|
||||
|
||||
/// Look up the bitmap for a character. Unknown characters (including
|
||||
/// space) render blank.
|
||||
fn glyphFor(ch: u8) Glyph {
|
||||
return switch (ch) {
|
||||
'0'...'9' => digits[ch - '0'],
|
||||
'$' => glyph_dollar,
|
||||
'.' => glyph_period,
|
||||
',' => glyph_comma,
|
||||
'-' => glyph_minus,
|
||||
'T' => glyph_T,
|
||||
'B' => glyph_B,
|
||||
'M' => glyph_M,
|
||||
else => blank,
|
||||
};
|
||||
}
|
||||
|
||||
/// Width in surface pixels that `drawText` will occupy for `text` at
|
||||
/// `scale` (excludes the trailing inter-glyph gap). Zero for empty text.
|
||||
/// Useful for right-aligning a label against a known x.
|
||||
pub fn measureWidth(text: []const u8, scale: i32) i32 {
|
||||
if (text.len == 0) return 0;
|
||||
const n: i32 = @intCast(text.len);
|
||||
// n full glyphs + (n-1) gaps = n*advance - 1, times scale.
|
||||
return (n * advance - 1) * scale;
|
||||
}
|
||||
|
||||
/// Stamp `text` onto the surface's RGB buffer with its top-left at
|
||||
/// `(x, y)`, each font pixel drawn as a `scale` x `scale` solid block in
|
||||
/// `color`. No-op for non-RGB surfaces. Pixels outside the surface are
|
||||
/// clipped.
|
||||
pub fn drawText(sfc: *Surface, x: i32, y: i32, scale: i32, color: [3]u8, text: []const u8) void {
|
||||
if (scale <= 0) return;
|
||||
const img = switch (sfc.*) {
|
||||
.image_surface_rgb => |s| s,
|
||||
else => return,
|
||||
};
|
||||
const w = img.width;
|
||||
const h = img.height;
|
||||
const px = RGB{ .r = color[0], .g = color[1], .b = color[2] };
|
||||
|
||||
var cursor_x = x;
|
||||
for (text) |ch| {
|
||||
const glyph = glyphFor(ch);
|
||||
for (0..@intCast(glyph_h)) |gr| {
|
||||
const bits = glyph[gr];
|
||||
for (0..@intCast(glyph_w)) |gc| {
|
||||
const mask: u8 = @as(u8, 1) << @intCast(glyph_w - 1 - @as(i32, @intCast(gc)));
|
||||
if (bits & mask == 0) continue;
|
||||
fillBlock(
|
||||
img.buf,
|
||||
w,
|
||||
h,
|
||||
cursor_x + @as(i32, @intCast(gc)) * scale,
|
||||
y + @as(i32, @intCast(gr)) * scale,
|
||||
scale,
|
||||
px,
|
||||
);
|
||||
}
|
||||
}
|
||||
cursor_x += advance * scale;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill a `size` x `size` block of pixels at `(bx, by)`, clipped to the
|
||||
/// `[0,w) x [0,h)` surface bounds.
|
||||
fn fillBlock(buf: []RGB, w: i32, h: i32, bx: i32, by: i32, size: i32, px: RGB) void {
|
||||
var dy: i32 = 0;
|
||||
while (dy < size) : (dy += 1) {
|
||||
const yy = by + dy;
|
||||
if (yy < 0 or yy >= h) continue;
|
||||
var dx: i32 = 0;
|
||||
while (dx < size) : (dx += 1) {
|
||||
const xx = bx + dx;
|
||||
if (xx < 0 or xx >= w) continue;
|
||||
buf[@intCast(yy * w + xx)] = px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "measureWidth: empty is zero, scales with length" {
|
||||
try testing.expectEqual(@as(i32, 0), measureWidth("", 3));
|
||||
// One glyph = glyph_w * scale (no trailing gap).
|
||||
try testing.expectEqual(@as(i32, glyph_w * 2), measureWidth("1", 2));
|
||||
// Two glyphs = (2*advance - 1) * scale = (2*6 - 1)*2 = 22.
|
||||
try testing.expectEqual(@as(i32, 22), measureWidth("12", 2));
|
||||
}
|
||||
|
||||
test "drawText stamps glyph pixels in the requested color" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16);
|
||||
defer sfc.deinit(alloc);
|
||||
|
||||
// Surface starts zeroed (black). Draw white text.
|
||||
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
|
||||
try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, white));
|
||||
|
||||
drawText(&sfc, 1, 1, 1, white, "1");
|
||||
// '1' has 10 set pixels in the 5x7 bitmap; at scale 1 that's 10 white px.
|
||||
try testing.expectEqual(@as(usize, 10), draw.countColor(&sfc, white));
|
||||
}
|
||||
|
||||
test "drawText scale multiplies the stamped pixel count" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 32);
|
||||
defer sfc.deinit(alloc);
|
||||
|
||||
const c = [3]u8{ 0x10, 0x20, 0x30 };
|
||||
drawText(&sfc, 0, 0, 2, c, "1");
|
||||
// 10 set font-pixels, each a 2x2 block -> 10 * 4 = 40 colored px.
|
||||
try testing.expectEqual(@as(usize, 40), draw.countColor(&sfc, c));
|
||||
}
|
||||
|
||||
test "drawText clips out-of-bounds without writing past the buffer" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 8, 8);
|
||||
defer sfc.deinit(alloc);
|
||||
|
||||
const c = [3]u8{ 0xAA, 0xBB, 0xCC };
|
||||
// Draw partly off the right/bottom edge and fully off-screen; must
|
||||
// not crash or panic (bounds-checked writes).
|
||||
drawText(&sfc, 6, 6, 3, c, "8");
|
||||
drawText(&sfc, -50, -50, 4, c, "8");
|
||||
drawText(&sfc, 100, 100, 4, c, "8");
|
||||
// Some pixels of the first (partly-visible) glyph may have landed.
|
||||
try testing.expect(draw.countColor(&sfc, c) <= 8 * 9);
|
||||
}
|
||||
|
||||
test "drawText ignores unknown glyphs (renders blank)" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 64, 16);
|
||||
defer sfc.deinit(alloc);
|
||||
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
|
||||
// '?' and space are not in the set -> nothing drawn.
|
||||
drawText(&sfc, 1, 1, 2, white, " ?");
|
||||
try testing.expectEqual(@as(usize, 0), draw.countColor(&sfc, white));
|
||||
}
|
||||
|
||||
test "drawText renders the comma glyph (so thousands separators show)" {
|
||||
const alloc = testing.allocator;
|
||||
var sfc = try Surface.init(.image_surface_rgb, alloc, 16, 16);
|
||||
defer sfc.deinit(alloc);
|
||||
const white = [3]u8{ 0xFF, 0xFF, 0xFF };
|
||||
drawText(&sfc, 1, 1, 1, white, ",");
|
||||
// The comma bitmap (rows 0x06,0x06,0x08) has 5 set pixels.
|
||||
try testing.expectEqual(@as(usize, 5), draw.countColor(&sfc, white));
|
||||
}
|
||||
|
|
@ -443,6 +443,10 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D
|
|||
if (std.ascii.eqlIgnoreCase(s, "live") or std.ascii.eqlIgnoreCase(s, "now")) {
|
||||
return null;
|
||||
}
|
||||
// Year-to-date: Jan 1 of the reference year.
|
||||
if (std.ascii.eqlIgnoreCase(s, "ytd")) {
|
||||
return zfin.Date.fromYmd(as_of.year(), 1, 1);
|
||||
}
|
||||
|
||||
// Explicit YYYY-MM-DD.
|
||||
if (s.len == 10 and s[4] == '-' and s[7] == '-') {
|
||||
|
|
@ -477,7 +481,7 @@ pub fn parseAsOfDate(input: []const u8, as_of: zfin.Date) AsOfParseError!?zfin.D
|
|||
/// caller is responsible for formatting the surrounding message.
|
||||
pub fn fmtAsOfParseError(buf: []u8, input: []const u8, err: AsOfParseError) []const u8 {
|
||||
return switch (err) {
|
||||
error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), or 'live'.", .{input}) catch input,
|
||||
error.InvalidFormat => std.fmt.bufPrint(buf, "Invalid as-of value: {s}. Expected YYYY-MM-DD, N[WMQY] (e.g. 1M, 3Q, 2Y), 'ytd', or 'live'.", .{input}) catch input,
|
||||
error.EmptyUnit => std.fmt.bufPrint(buf, "As-of value {s} is missing a unit. Expected one of W, M, Q, Y.", .{input}) catch input,
|
||||
error.UnknownUnit => std.fmt.bufPrint(buf, "As-of value {s} has an unknown unit. Expected one of W (weeks), M (months), Q (quarters), Y (years).", .{input}) catch input,
|
||||
error.ZeroQuantity => std.fmt.bufPrint(buf, "As-of quantity must be at least 1 (got {s}).", .{input}) catch input,
|
||||
|
|
@ -1032,6 +1036,12 @@ test "parseAsOfDate: literal 'live' and 'now' (case-insensitive)" {
|
|||
try std.testing.expect((try parseAsOfDate("Now", today)) == null);
|
||||
}
|
||||
|
||||
test "parseAsOfDate: 'ytd' is Jan 1 of the reference year (case-insensitive)" {
|
||||
const today = zfin.Date.fromYmd(2026, 4, 2);
|
||||
try std.testing.expect((try parseAsOfDate("ytd", today)).?.eql(zfin.Date.fromYmd(2026, 1, 1)));
|
||||
try std.testing.expect((try parseAsOfDate("YTD", today)).?.eql(zfin.Date.fromYmd(2026, 1, 1)));
|
||||
}
|
||||
|
||||
test "parseAsOfDate: explicit YYYY-MM-DD" {
|
||||
const today = zfin.Date.fromYmd(2026, 4, 2);
|
||||
const r = try parseAsOfDate("2026-03-13", today);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@
|
|||
const std = @import("std");
|
||||
const zfin = @import("../root.zig");
|
||||
const validator = @import("../comptime_validator.zig");
|
||||
const chart = @import("../charts/chart.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
|
||||
// ── Group taxonomy ────────────────────────────────────────────
|
||||
|
||||
|
|
@ -206,6 +208,9 @@ pub const Globals = struct {
|
|||
watchlist_path: ?[]const u8 = null,
|
||||
/// Cache-freshness policy from `--refresh-data=<value>`.
|
||||
refresh_policy: RefreshPolicy = .auto,
|
||||
/// Chart graphics mode from `--chart` (auto / braille / WxH). Default
|
||||
/// is auto: kitty graphics when the terminal supports it, else braille.
|
||||
chart_config: chart.ChartConfig = .{},
|
||||
};
|
||||
|
||||
// ── RunCtx ────────────────────────────────────────────────────
|
||||
|
|
@ -250,6 +255,10 @@ pub const RunCtx = struct {
|
|||
now_s: i64,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
/// Detected inline-graphics capability (kitty) + cell size, captured
|
||||
/// once at invocation entry. Commands consult this together with
|
||||
/// `globals.chart_config` to choose kitty-graphics vs braille output.
|
||||
graphics_caps: term_query.Caps = .{},
|
||||
|
||||
/// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
|
||||
/// the default `portfolio*.srf` pattern) through cwd -> ZFIN_HOME.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@
|
|||
//! Defaults to auto: daily ≤90d, weekly ≤730d,
|
||||
//! else monthly.
|
||||
//! --limit <N> cap the "Recent snapshots" table to N rows
|
||||
//! --export-chart <path> render the focused-metric timeline as a
|
||||
//! PNG to <path> and exit (honors
|
||||
//! --since / --until / --metric / --baseline)
|
||||
//! --baseline <name> exported-chart y-axis: fit (default) | zero
|
||||
//! --rebuild-rollup (re)write history/rollup.srf and exit
|
||||
//!
|
||||
//! Portfolio layout, top-to-bottom:
|
||||
|
|
@ -38,6 +42,12 @@ const timeline = @import("../analytics/timeline.zig");
|
|||
const history = @import("../history.zig");
|
||||
const snapshot_model = @import("../models/snapshot.zig");
|
||||
const view = @import("../views/history.zig");
|
||||
const chart_export = @import("../chart_export.zig");
|
||||
const line_chart = @import("../charts/line_chart.zig");
|
||||
const chart = @import("../charts/chart.zig");
|
||||
const term_graphics = @import("../term_graphics.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
const fmt = cli.fmt;
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
|
|
@ -69,7 +79,8 @@ pub const meta: framework.Meta = .{
|
|||
\\shows the last 30 trading days of candles for that symbol.
|
||||
\\Portfolio mode (no positional, optionally with flags) reads
|
||||
\\`history/*-portfolio.srf` snapshots and renders rolling-windows
|
||||
\\returns + a braille chart + a recent-snapshots table.
|
||||
\\returns + a chart (an inline Kitty image when the terminal
|
||||
\\supports it, braille otherwise) + a recent-snapshots table.
|
||||
\\
|
||||
\\Portfolio-mode flags:
|
||||
\\ --since <DATE> earliest as-of date (inclusive)
|
||||
|
|
@ -78,12 +89,14 @@ pub const meta: framework.Meta = .{
|
|||
\\ --resolution <name> daily | weekly | monthly | auto
|
||||
\\ (auto: daily ≤90d, weekly ≤730d, else monthly)
|
||||
\\ --limit <N> cap recent-snapshots table to N rows (default 40)
|
||||
\\ --export-chart <path> write the focused-metric timeline as a PNG and exit
|
||||
\\ --baseline <name> exported-chart y-axis: fit (default) or zero
|
||||
\\ --rebuild-rollup regenerate history/rollup.srf and exit
|
||||
\\
|
||||
\\DATE accepts YYYY-MM-DD or relative shortcuts (1W/1M/1Q/1Y).
|
||||
\\
|
||||
,
|
||||
.user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution },
|
||||
.user_errors = error{ UnexpectedArg, MissingFlagValue, InvalidFlagValue, UnknownMetric, UnknownResolution, UnknownBaseline },
|
||||
};
|
||||
|
||||
pub const Error = error{
|
||||
|
|
@ -92,6 +105,7 @@ pub const Error = error{
|
|||
MissingFlagValue,
|
||||
UnknownMetric,
|
||||
UnknownResolution,
|
||||
UnknownBaseline,
|
||||
};
|
||||
|
||||
/// Parsed portfolio-mode options. Separated from `run` so the parser
|
||||
|
|
@ -114,6 +128,15 @@ pub const PortfolioOpts = struct {
|
|||
/// Max rows shown in the recent-snapshots table. Null means default (40).
|
||||
limit: ?usize = null,
|
||||
rebuild_rollup: bool = false,
|
||||
/// When set, render the focused-metric timeline as a PNG to this
|
||||
/// path and exit, instead of printing the normal timeline output.
|
||||
/// Honors `--since` / `--until` / `--metric`.
|
||||
export_chart: ?[]const u8 = null,
|
||||
/// Y-axis baseline for the exported chart: `.fit` fits the data
|
||||
/// range; `.zero` anchors the floor at zero (clamped to the data
|
||||
/// minimum if the series itself dips negative). Only consulted when
|
||||
/// `--export-chart` is given.
|
||||
baseline: line_chart.Baseline = .fit,
|
||||
};
|
||||
|
||||
/// Parse the arg list for portfolio-mode flags. Pure function - no IO.
|
||||
|
|
@ -155,6 +178,14 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port
|
|||
opts.limit = std.fmt.parseInt(usize, args[i], 10) catch return error.InvalidFlagValue;
|
||||
} else if (std.mem.eql(u8, a, "--rebuild-rollup")) {
|
||||
opts.rebuild_rollup = true;
|
||||
} else if (std.mem.eql(u8, a, "--export-chart")) {
|
||||
i += 1;
|
||||
if (i >= args.len) return error.MissingFlagValue;
|
||||
opts.export_chart = args[i];
|
||||
} else if (std.mem.eql(u8, a, "--baseline")) {
|
||||
i += 1;
|
||||
if (i >= args.len) return error.MissingFlagValue;
|
||||
opts.baseline = std.meta.stringToEnum(line_chart.Baseline, args[i]) orelse return error.UnknownBaseline;
|
||||
} else {
|
||||
return error.UnexpectedArg;
|
||||
}
|
||||
|
|
@ -177,6 +208,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
error.InvalidFlagValue => cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"),
|
||||
error.UnknownMetric => cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"),
|
||||
error.UnknownResolution => cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"),
|
||||
error.UnknownBaseline => cli.stderrPrint(ctx.io, "Error: unknown --baseline. Valid: fit, zero.\n"),
|
||||
}
|
||||
return err;
|
||||
};
|
||||
|
|
@ -193,7 +225,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
.portfolio => |opts| {
|
||||
const pf = ctx.resolvePortfolioPath();
|
||||
defer pf.deinit(ctx.allocator);
|
||||
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out);
|
||||
try runPortfolio(ctx.io, ctx.allocator, pf.path, opts, ctx.color, ctx.out, ctx.globals.chart_config, ctx.graphics_caps);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -263,6 +295,8 @@ fn runPortfolio(
|
|||
opts: PortfolioOpts,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
chart_config: chart.ChartConfig,
|
||||
caps: term_query.Caps,
|
||||
) !void {
|
||||
var tl = try history.loadTimeline(io, allocator, portfolio_path);
|
||||
defer tl.deinit();
|
||||
|
|
@ -289,6 +323,26 @@ fn runPortfolio(
|
|||
return;
|
||||
}
|
||||
|
||||
// --export-chart short-circuits: render the focused-metric timeline
|
||||
// as a PNG (honoring --since / --until / --metric / --baseline) and
|
||||
// exit without printing the normal timeline output.
|
||||
if (opts.export_chart) |path| {
|
||||
exportMetricChart(io, allocator, filtered, opts.metric, opts.baseline, path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(io, "Error: need at least 2 snapshots in the selected range to render a chart.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
cli.stderrPrint(io, "Error exporting chart: ");
|
||||
cli.stderrPrint(io, @errorName(err));
|
||||
cli.stderrPrint(io, "\n");
|
||||
return;
|
||||
},
|
||||
};
|
||||
try out.print("Chart exported to {s}\n", .{path});
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the effective resolution:
|
||||
// - explicit `--resolution daily/weekly/monthly/cascading` ->
|
||||
// use as-is.
|
||||
|
|
@ -301,7 +355,18 @@ fn runPortfolio(
|
|||
timeline.selectResolution(filtered)
|
||||
else
|
||||
.cascading;
|
||||
try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40);
|
||||
|
||||
// Resolve how to draw the inline chart: kitty graphics when the
|
||||
// terminal supports it (or it's forced via `--chart <WxH>`), else
|
||||
// braille. `--chart braille` always forces braille.
|
||||
const k: KittyChart = .{ .io = io, .caps = caps, .baseline = opts.baseline };
|
||||
const chart_render: ChartRender = switch (chart_config.mode) {
|
||||
.braille => .braille,
|
||||
.kitty => .{ .kitty = k },
|
||||
.auto => if (caps.kitty) .{ .kitty = k } else .braille,
|
||||
};
|
||||
|
||||
try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40, chart_render);
|
||||
}
|
||||
|
||||
/// Regenerate `history/rollup.srf` from `snapshots`. Uses
|
||||
|
|
@ -367,6 +432,7 @@ pub fn renderPortfolio(
|
|||
resolution: timeline.Resolution,
|
||||
resolution_override: ?timeline.Resolution,
|
||||
row_limit: usize,
|
||||
chart_render: ChartRender,
|
||||
) !void {
|
||||
try cli.printBold(out, color, "\nPortfolio Timeline: {s}\n", .{focus_metric.label()});
|
||||
try out.print("========================================\n", .{});
|
||||
|
|
@ -377,9 +443,9 @@ pub fn renderPortfolio(
|
|||
defer ws.deinit();
|
||||
try renderWindowsBlock(out, color, ws);
|
||||
|
||||
// ── Chart (synthetic candles from focused-metric values) ─
|
||||
// ── Chart (inline kitty graphics or braille fallback) ─────
|
||||
try out.print("\n", .{});
|
||||
try renderBrailleChart(allocator, out, color, points, focus_metric);
|
||||
try renderTimelineChart(allocator, out, color, points, focus_metric, chart_render);
|
||||
|
||||
// ── Table ────────────────────────────────────────────────
|
||||
if (resolution == .cascading) {
|
||||
|
|
@ -459,7 +525,103 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet)
|
|||
}
|
||||
}
|
||||
|
||||
fn renderBrailleChart(
|
||||
/// Convert timeline `MetricPoint`s into the chart module's `LinePoint`
|
||||
/// shape. Same fields, distinct type: the chart module deliberately
|
||||
/// does not depend on `analytics/timeline.zig`, so the CLI does the
|
||||
/// conversion at the boundary (mirrors how `projections` converts its
|
||||
/// overlay points). Caller owns the returned slice.
|
||||
fn metricLinePoints(allocator: std.mem.Allocator, series: []const timeline.MetricPoint) ![]line_chart.LinePoint {
|
||||
const out = try allocator.alloc(line_chart.LinePoint, series.len);
|
||||
for (series, 0..) |mp, i| out[i] = .{ .date = mp.date, .value = mp.value };
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Render the focused-metric portfolio timeline to a PNG at `path`.
|
||||
/// Extracts the metric series via `timeline.extractChartSeries` (the
|
||||
/// single home for the "skip imported-only points for derived metrics"
|
||||
/// rule), converts to `LinePoint`s, and hands off to
|
||||
/// `chart_export.exportTimelineChart`. Propagates `error.InsufficientData`
|
||||
/// when fewer than 2 points remain after extraction.
|
||||
fn exportMetricChart(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
points: []const timeline.TimelinePoint,
|
||||
metric: timeline.Metric,
|
||||
baseline: line_chart.Baseline,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
const series = try timeline.extractChartSeries(allocator, points, metric);
|
||||
defer allocator.free(series);
|
||||
const lps = try metricLinePoints(allocator, series);
|
||||
defer allocator.free(lps);
|
||||
try chart_export.exportTimelineChart(io, allocator, lps, baseline, path);
|
||||
}
|
||||
|
||||
/// How `renderPortfolio` draws the timeline chart.
|
||||
const ChartRender = union(enum) {
|
||||
/// Terminal-agnostic braille - the universal fallback.
|
||||
braille,
|
||||
/// Inline kitty graphics, for capable terminals (or forced via
|
||||
/// `--chart <WxH>`).
|
||||
kitty: KittyChart,
|
||||
};
|
||||
|
||||
const KittyChart = struct {
|
||||
io: std.Io,
|
||||
caps: term_query.Caps,
|
||||
baseline: line_chart.Baseline,
|
||||
};
|
||||
|
||||
/// Draw the focused-metric timeline. Dispatches to inline kitty graphics
|
||||
/// or braille; a kitty render that can't produce a bitmap (too few
|
||||
/// points) falls back to braille rather than drawing nothing.
|
||||
fn renderTimelineChart(
|
||||
allocator: std.mem.Allocator,
|
||||
out: *std.Io.Writer,
|
||||
color: bool,
|
||||
points: []const timeline.TimelinePoint,
|
||||
metric: timeline.Metric,
|
||||
render: ChartRender,
|
||||
) !void {
|
||||
switch (render) {
|
||||
.braille => try renderBraille(allocator, out, color, points, metric),
|
||||
.kitty => |k| emitTimelineKitty(allocator, out, points, metric, k) catch |err| switch (err) {
|
||||
error.InsufficientData => try renderBraille(allocator, out, color, points, metric),
|
||||
else => return err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the timeline as a kitty-graphics line chart (labeled, sized to
|
||||
/// `term_graphics.history_cols`) and emit it at the cursor, advancing
|
||||
/// below it. Returns `error.InsufficientData` when there aren't enough
|
||||
/// points to draw, so the caller can fall back to braille.
|
||||
fn emitTimelineKitty(
|
||||
allocator: std.mem.Allocator,
|
||||
out: *std.Io.Writer,
|
||||
points: []const timeline.TimelinePoint,
|
||||
metric: timeline.Metric,
|
||||
k: KittyChart,
|
||||
) !void {
|
||||
const series = try timeline.extractChartSeries(allocator, points, metric);
|
||||
defer allocator.free(series);
|
||||
if (series.len < 2) return error.InsufficientData;
|
||||
const lps = try metricLinePoints(allocator, series);
|
||||
defer allocator.free(lps);
|
||||
|
||||
const cols = term_graphics.history_cols;
|
||||
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h);
|
||||
const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h);
|
||||
|
||||
var rendered = try line_chart.renderToSurface(k.io, allocator, lps, dims.width, dims.height, theme.default_theme, .{ .baseline = k.baseline, .axis_labels = true });
|
||||
defer rendered.deinit(allocator);
|
||||
const rgb = try rendered.extractRgb(allocator);
|
||||
defer allocator.free(rgb);
|
||||
|
||||
try term_graphics.placeInline(out, allocator, rgb, dims.width, dims.height, cols, rows);
|
||||
}
|
||||
|
||||
fn renderBraille(
|
||||
allocator: std.mem.Allocator,
|
||||
out: *std.Io.Writer,
|
||||
color: bool,
|
||||
|
|
@ -468,37 +630,34 @@ fn renderBrailleChart(
|
|||
) !void {
|
||||
if (points.len < 2) return;
|
||||
|
||||
// Synthesize candles from the focused metric's value. For
|
||||
// illiquid / net_worth, skip imported-only points so the
|
||||
// line is visually absent in the imported-only range rather
|
||||
// than hugging zero.
|
||||
// Extract the focused-metric series via the shared
|
||||
// `extractChartSeries` rule (skips imported-only points for the
|
||||
// derived metrics so the line is visually absent in the
|
||||
// imported-only range rather than hugging zero), then synthesize
|
||||
// flat candles for the braille renderer.
|
||||
const series = try timeline.extractChartSeries(allocator, points, metric);
|
||||
defer allocator.free(series);
|
||||
if (series.len < 2) return;
|
||||
|
||||
var candles_list: std.ArrayList(zfin.Candle) = .empty;
|
||||
defer candles_list.deinit(allocator);
|
||||
try candles_list.ensureTotalCapacity(allocator, points.len);
|
||||
const skip_imported = (metric == .illiquid) or (metric == .net_worth);
|
||||
for (points) |p| {
|
||||
if (skip_imported and p.source == .imported) continue;
|
||||
const v = switch (metric) {
|
||||
.net_worth => p.net_worth,
|
||||
.liquid => p.liquid,
|
||||
.illiquid => p.illiquid,
|
||||
};
|
||||
try candles_list.append(allocator, .{
|
||||
.date = p.as_of_date,
|
||||
.open = v,
|
||||
.high = v,
|
||||
.low = v,
|
||||
.close = v,
|
||||
.adj_close = v,
|
||||
try candles_list.ensureTotalCapacity(allocator, series.len);
|
||||
for (series) |mp| {
|
||||
candles_list.appendAssumeCapacity(.{
|
||||
.date = mp.date,
|
||||
.open = mp.value,
|
||||
.high = mp.value,
|
||||
.low = mp.value,
|
||||
.close = mp.value,
|
||||
.adj_close = mp.value,
|
||||
.volume = 0,
|
||||
});
|
||||
}
|
||||
const candles = candles_list.items;
|
||||
if (candles.len < 2) return;
|
||||
|
||||
var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||
defer chart.deinit(allocator);
|
||||
try fmt.writeBrailleAnsi(out, &chart, color, cli.CLR_MUTED, false);
|
||||
var braille_chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||
defer braille_chart.deinit(allocator);
|
||||
try fmt.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
|
||||
}
|
||||
|
||||
fn renderTable(
|
||||
|
|
@ -791,6 +950,29 @@ test "parsePortfolioOpts: unknown flag / value errors" {
|
|||
try testing.expectError(error.InvalidFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--limit", "not-a-number" }));
|
||||
}
|
||||
|
||||
test "parsePortfolioOpts: --export-chart captures the path, baseline defaults to fit" {
|
||||
const args = [_][]const u8{ "--export-chart", "timeline.png" };
|
||||
const o = try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &args);
|
||||
try testing.expectEqualStrings("timeline.png", o.export_chart.?);
|
||||
try testing.expectEqual(line_chart.Baseline.fit, o.baseline);
|
||||
}
|
||||
|
||||
test "parsePortfolioOpts: --baseline parses fit and zero" {
|
||||
const af = [_][]const u8{ "--baseline", "fit" };
|
||||
try testing.expectEqual(line_chart.Baseline.fit, (try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &af)).baseline);
|
||||
|
||||
const az = [_][]const u8{ "--baseline", "zero" };
|
||||
try testing.expectEqual(line_chart.Baseline.zero, (try parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &az)).baseline);
|
||||
}
|
||||
|
||||
test "parsePortfolioOpts: chart flag errors" {
|
||||
// Unknown baseline value.
|
||||
try testing.expectError(error.UnknownBaseline, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{ "--baseline", "bogus" }));
|
||||
// Missing flag values.
|
||||
try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{"--export-chart"}));
|
||||
try testing.expectError(error.MissingFlagValue, parsePortfolioOpts(zfin.Date.fromYmd(2026, 5, 8), &[_][]const u8{"--baseline"}));
|
||||
}
|
||||
|
||||
// ── renderPortfolio (end-to-end) ─────────────────────────────
|
||||
|
||||
fn makeTimelinePoint(y: i16, m: u8, d: u8, liq: f64, ill: f64, nw: f64) timeline.TimelinePoint {
|
||||
|
|
@ -812,7 +994,7 @@ test "renderPortfolio: shows header, windows block, chart, and table" {
|
|||
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
|
||||
makeTimelinePoint(2026, 4, 21, 800, 400, 1200),
|
||||
};
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40);
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40, .braille);
|
||||
const out = w.buffered();
|
||||
|
||||
// Header
|
||||
|
|
@ -860,7 +1042,7 @@ test "renderPortfolio: auto resolution shows '(auto - <effective>)' label" {
|
|||
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
|
||||
};
|
||||
// resolution_override = null -> auto. Effective is daily (span ≤ 90d).
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40);
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, null, 40, .braille);
|
||||
const out = w.buffered();
|
||||
try testing.expect(std.mem.indexOf(u8, out, "(auto - daily)") != null);
|
||||
}
|
||||
|
|
@ -872,7 +1054,7 @@ test "renderPortfolio: color mode emits ANSI" {
|
|||
makeTimelinePoint(2026, 4, 17, 700, 300, 1000),
|
||||
makeTimelinePoint(2026, 4, 18, 750, 350, 1100),
|
||||
};
|
||||
try renderPortfolio(testing.allocator, &w, true, &pts, .liquid, .daily, .daily, 40);
|
||||
try renderPortfolio(testing.allocator, &w, true, &pts, .liquid, .daily, .daily, 40, .braille);
|
||||
try testing.expect(std.mem.indexOf(u8, w.buffered(), "\x1b[") != null);
|
||||
}
|
||||
|
||||
|
|
@ -882,7 +1064,7 @@ test "renderPortfolio: single point renders without crashing" {
|
|||
const pts = [_]timeline.TimelinePoint{
|
||||
makeTimelinePoint(2026, 4, 17, 700, 300, 1000),
|
||||
};
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40);
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 40, .braille);
|
||||
const out = w.buffered();
|
||||
try testing.expect(std.mem.indexOf(u8, out, "2026-04-17") != null);
|
||||
// Chart requires >= 2 points; confirm no crash, table shows one row.
|
||||
|
|
@ -901,7 +1083,7 @@ test "renderPortfolio: row_limit caps table rows" {
|
|||
makeTimelinePoint(2026, 4, 20, 770, 370, 1140),
|
||||
makeTimelinePoint(2026, 4, 21, 780, 380, 1160),
|
||||
};
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 2);
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .daily, .daily, 2, .braille);
|
||||
const out = w.buffered();
|
||||
// 5 snapshots total, 2 shown.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "5 snapshots") != null);
|
||||
|
|
@ -920,7 +1102,7 @@ test "renderPortfolio: monthly resolution labels the table accordingly" {
|
|||
makeTimelinePoint(2026, 3, 31, 800, 400, 1200),
|
||||
makeTimelinePoint(2026, 4, 21, 900, 500, 1400),
|
||||
};
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .monthly, .monthly, 40);
|
||||
try renderPortfolio(testing.allocator, &w, false, &pts, .liquid, .monthly, .monthly, 40, .braille);
|
||||
const out = w.buffered();
|
||||
try testing.expect(std.mem.indexOf(u8, out, "(monthly)") != null);
|
||||
}
|
||||
|
|
@ -952,6 +1134,62 @@ test "displaySymbol empty candles" {
|
|||
try testing.expect(std.mem.indexOf(u8, out, "0 trading days") != null);
|
||||
}
|
||||
|
||||
// ── chart export ─────────────────────────────────────────────
|
||||
|
||||
test "metricLinePoints converts MetricPoints preserving order and values" {
|
||||
const series = [_]timeline.MetricPoint{
|
||||
.{ .date = Date.fromYmd(2026, 1, 1), .value = 100 },
|
||||
.{ .date = Date.fromYmd(2026, 2, 1), .value = 250 },
|
||||
};
|
||||
const lps = try metricLinePoints(testing.allocator, &series);
|
||||
defer testing.allocator.free(lps);
|
||||
try testing.expectEqual(@as(usize, 2), lps.len);
|
||||
try testing.expect(lps[0].date.eql(Date.fromYmd(2026, 1, 1)));
|
||||
try testing.expectEqual(@as(f64, 100), lps[0].value);
|
||||
try testing.expect(lps[1].date.eql(Date.fromYmd(2026, 2, 1)));
|
||||
try testing.expectEqual(@as(f64, 250), lps[1].value);
|
||||
}
|
||||
|
||||
test "exportMetricChart writes a PNG for a multi-point timeline" {
|
||||
const io = std.testing.io;
|
||||
const alloc = testing.allocator;
|
||||
const pts = [_]timeline.TimelinePoint{
|
||||
makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000),
|
||||
makeTimelinePoint(2026, 2, 1, 1_050_000, 210_000, 1_260_000),
|
||||
makeTimelinePoint(2026, 3, 1, 1_030_000, 205_000, 1_235_000),
|
||||
};
|
||||
|
||||
var tmp = testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||
const path = try std.fs.path.join(alloc, &.{ path_buf[0..dir_len], "history_timeline.png" });
|
||||
defer alloc.free(path);
|
||||
|
||||
try exportMetricChart(io, alloc, &pts, .liquid, .fit, path);
|
||||
|
||||
var file = try tmp.dir.openFile(io, "history_timeline.png", .{});
|
||||
defer file.close(io);
|
||||
const size = (try file.stat(io)).size;
|
||||
try testing.expect(size > 1024);
|
||||
|
||||
var magic: [8]u8 = undefined;
|
||||
var reader = file.reader(io, &.{});
|
||||
_ = try reader.interface.readSliceShort(&magic);
|
||||
try testing.expectEqualSlices(u8, "\x89PNG\x0D\x0A\x1A\x0A", &magic);
|
||||
}
|
||||
|
||||
test "exportMetricChart returns InsufficientData with fewer than 2 points" {
|
||||
const io = std.testing.io;
|
||||
const alloc = testing.allocator;
|
||||
// Single point -> extractChartSeries yields 1 point -> renderToSurface
|
||||
// rejects it before any file is opened, so `path` is never written.
|
||||
const pts = [_]timeline.TimelinePoint{
|
||||
makeTimelinePoint(2026, 1, 1, 1_000_000, 200_000, 1_200_000),
|
||||
};
|
||||
try testing.expectError(error.InsufficientData, exportMetricChart(io, alloc, &pts, .liquid, .fit, "unused.png"));
|
||||
}
|
||||
|
||||
// ── rebuildRollup ────────────────────────────────────────────
|
||||
|
||||
fn makeFixtureSnapshot(
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ const forecast = @import("../analytics/forecast_evaluation.zig");
|
|||
const milestones = @import("../analytics/milestones.zig");
|
||||
const shiller = @import("../data/shiller.zig");
|
||||
const chart_export = @import("../chart_export.zig");
|
||||
const projection_chart = @import("../charts/projection_chart.zig");
|
||||
const term_graphics = @import("../term_graphics.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
|
||||
/// Tagged-union args for the four projection sub-modes. Mutually-
|
||||
/// exclusive flag combos (--convergence with --vs, --real with
|
||||
|
|
@ -279,6 +283,14 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
// total. Snapshot-only as-of paths ignore it.
|
||||
var live = try loadLiveData(ctx, today, color);
|
||||
defer if (live) |*l| l.deinit(allocator);
|
||||
// Inline kitty band chart when supported (or forced). There's
|
||||
// no braille fallback for projections - non-kitty terminals
|
||||
// keep the table-only output.
|
||||
const kitty_caps: ?term_query.Caps = switch (ctx.globals.chart_config.mode) {
|
||||
.braille => null,
|
||||
.kitty => ctx.graphics_caps,
|
||||
.auto => if (ctx.graphics_caps.kitty) ctx.graphics_caps else null,
|
||||
};
|
||||
try runBands(
|
||||
io,
|
||||
allocator,
|
||||
|
|
@ -295,6 +307,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
},
|
||||
color,
|
||||
out,
|
||||
kitty_caps,
|
||||
);
|
||||
},
|
||||
}
|
||||
|
|
@ -541,6 +554,42 @@ pub fn anyImportedOnly(
|
|||
return now_res.source == .imported;
|
||||
}
|
||||
|
||||
/// Render the percentile-band chart (longest horizon, with the actuals
|
||||
/// overlay when present) as kitty graphics at `term_graphics.projection_cols`
|
||||
/// wide and emit it inline. Returns `error.InsufficientData` when bands
|
||||
/// aren't available so the caller can skip the chart - projections has
|
||||
/// no braille fallback. All allocations come from the arena `va`.
|
||||
fn emitBandsKitty(
|
||||
io: std.Io,
|
||||
va: std.mem.Allocator,
|
||||
ctx: *const view.ProjectionContext,
|
||||
caps: term_query.Caps,
|
||||
out: *std.Io.Writer,
|
||||
) !void {
|
||||
const horizons = ctx.config.getHorizons();
|
||||
if (horizons.len == 0) return error.InsufficientData;
|
||||
const bands_ec = ctx.data.bands[horizons.len - 1] orelse return error.InsufficientData;
|
||||
|
||||
// Translate the view-layer overlay (if any) into the chart module's
|
||||
// ActualsPoint shape - same conversion as the PNG export path. The
|
||||
// arena owns the buffer; it lives as long as `overlay_input`.
|
||||
const overlay_input = blk: {
|
||||
const ov = ctx.overlay_actuals orelse break :blk @as(?projection_chart.ActualsOverlay, null);
|
||||
const buf = va.alloc(projection_chart.ActualsPoint, ov.points.len) catch break :blk @as(?projection_chart.ActualsOverlay, null);
|
||||
for (ov.points, 0..) |p, i| buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
|
||||
break :blk projection_chart.ActualsOverlay{ .points = buf, .today_years = ov.today_years };
|
||||
};
|
||||
|
||||
const cols = term_graphics.projection_cols;
|
||||
const rows = term_graphics.rowsForWidth(cols, caps.cell_w, caps.cell_h);
|
||||
const dims = term_graphics.pixelDims(cols, rows, caps.cell_w, caps.cell_h);
|
||||
|
||||
var rendered = try projection_chart.renderToSurface(io, va, bands_ec, dims.width, dims.height, theme.default_theme, overlay_input, true);
|
||||
defer rendered.deinit(va);
|
||||
const rgb = try rendered.extractRgb(va);
|
||||
try term_graphics.placeInline(out, va, rgb, dims.width, dims.height, cols, rows);
|
||||
}
|
||||
|
||||
pub fn runBands(
|
||||
io: std.Io,
|
||||
allocator: std.mem.Allocator,
|
||||
|
|
@ -549,6 +598,7 @@ pub fn runBands(
|
|||
opts: BandsOptions,
|
||||
color: bool,
|
||||
out: *std.Io.Writer,
|
||||
kitty_caps: ?term_query.Caps,
|
||||
) !void {
|
||||
// Single arena for all view/render allocations. Same lifetime
|
||||
// regardless of live vs. as-of path.
|
||||
|
|
@ -665,16 +715,16 @@ pub fn runBands(
|
|||
// Translate the view-layer overlay points (if any) into the
|
||||
// chart-module's ActualsPoint shape. Same conversion the TUI
|
||||
// does in `projections_tab.drawWithKittyChart`.
|
||||
var overlay_buf: ?[]@import("../tui/projection_chart.zig").ActualsPoint = null;
|
||||
var overlay_buf: ?[]@import("../charts/projection_chart.zig").ActualsPoint = null;
|
||||
defer if (overlay_buf) |ob| va.free(ob);
|
||||
const overlay_input = blk: {
|
||||
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
|
||||
const buf = va.alloc(@import("../tui/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../tui/projection_chart.zig").ActualsOverlay, null);
|
||||
const ov = ctx.overlay_actuals orelse break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
|
||||
const buf = va.alloc(@import("../charts/projection_chart.zig").ActualsPoint, ov.points.len) catch break :blk @as(?@import("../charts/projection_chart.zig").ActualsOverlay, null);
|
||||
for (ov.points, 0..) |p, i| {
|
||||
buf[i] = .{ .years_from_as_of = p.years_from_as_of, .liquid = p.liquid };
|
||||
}
|
||||
overlay_buf = buf;
|
||||
break :blk @import("../tui/projection_chart.zig").ActualsOverlay{
|
||||
break :blk @import("../charts/projection_chart.zig").ActualsOverlay{
|
||||
.points = buf,
|
||||
.today_years = ov.today_years,
|
||||
};
|
||||
|
|
@ -709,6 +759,17 @@ pub fn runBands(
|
|||
}
|
||||
try out.print("========================================\n", .{});
|
||||
|
||||
// Headline percentile-band chart, inline via kitty graphics when
|
||||
// supported (or forced). No braille fallback - non-kitty terminals
|
||||
// keep the table-only view below.
|
||||
if (kitty_caps) |kc| {
|
||||
try out.print("\n", .{});
|
||||
emitBandsKitty(io, va, &ctx, kc, out) catch |err| switch (err) {
|
||||
error.InsufficientData => {}, // no bands yet; fall through to the table
|
||||
else => return err,
|
||||
};
|
||||
}
|
||||
|
||||
// If auto-snapped, print a muted note so the user knows the
|
||||
// requested date wasn't an exact hit. The wording reflects the
|
||||
// resolution source - "nearest snapshot" vs "nearest imported
|
||||
|
|
@ -1902,7 +1963,7 @@ test "runBands: imported-only as_of scales today's composition and renders body"
|
|||
.today = today,
|
||||
.overlay_actuals = false,
|
||||
.live = &ld,
|
||||
}, false, &stream);
|
||||
}, false, &stream, null);
|
||||
|
||||
const out = stream.buffered();
|
||||
// Header reflects the imported source, and the caveat explains
|
||||
|
|
@ -1936,7 +1997,7 @@ test "runBands: imported-only as_of without live data returns cleanly" {
|
|||
.today = Date.fromYmd(2026, 3, 13),
|
||||
.overlay_actuals = false,
|
||||
.live = null,
|
||||
}, false, &stream);
|
||||
}, false, &stream, null);
|
||||
|
||||
// The helper printed a clear stderr message (swallowed by
|
||||
// cli.stderrPrint) and returned without body output.
|
||||
|
|
@ -2129,7 +2190,7 @@ test "run: as_of with no snapshots returns without error (stderr-only)" {
|
|||
var stream = std.Io.Writer.fixed(&buf);
|
||||
|
||||
const d = Date.fromYmd(2026, 3, 13);
|
||||
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
|
||||
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream, null);
|
||||
|
||||
// No body output because the resolution failed - the stderr
|
||||
// message is swallowed by `cli.stderrPrint` and doesn't land in
|
||||
|
|
@ -2161,7 +2222,7 @@ test "run: as_of with matching snapshot produces body output" {
|
|||
|
||||
var buf: [32_768]u8 = undefined;
|
||||
var stream = std.Io.Writer.fixed(&buf);
|
||||
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream);
|
||||
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = d, .from_snapshot = true, .today = d, .overlay_actuals = false }, false, &stream, null);
|
||||
|
||||
const out = stream.buffered();
|
||||
// Header should call out the as-of date explicitly.
|
||||
|
|
@ -2192,7 +2253,7 @@ test "run: as_of auto-snap surfaces muted 'nearest' note" {
|
|||
var stream = std.Io.Writer.fixed(&buf);
|
||||
|
||||
const requested = Date.fromYmd(2026, 3, 13);
|
||||
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = false }, false, &stream);
|
||||
try runBands(io, testing.allocator, &svc, pf, .{ .events_enabled = false, .as_of = requested, .from_snapshot = true, .today = requested, .overlay_actuals = false }, false, &stream, null);
|
||||
|
||||
const out = stream.buffered();
|
||||
try testing.expect(std.mem.indexOf(u8, out, "as of 2026-03-12") != null);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ const framework = @import("framework.zig");
|
|||
const fmt = cli.fmt;
|
||||
const Money = @import("../Money.zig");
|
||||
const chart_export = @import("../chart_export.zig");
|
||||
const tui_chart = @import("../tui/chart.zig");
|
||||
const tui_chart = @import("../charts/chart.zig");
|
||||
const term_graphics = @import("../term_graphics.zig");
|
||||
const term_query = @import("../term_query.zig");
|
||||
const theme = @import("../tui/theme.zig");
|
||||
|
||||
pub const ParsedArgs = struct {
|
||||
symbol: []const u8,
|
||||
|
|
@ -14,6 +17,14 @@ pub const ParsedArgs = struct {
|
|||
/// z2d-rendered pixel buffers for the TUI; this flag just lands
|
||||
/// the same pixels in a file via z2d's PNG exporter.
|
||||
export_chart: ?[]const u8 = null,
|
||||
/// Chart start date from `--since` (resolved at parse time). Null
|
||||
/// means the default window (last ~3 months). Accepts the same
|
||||
/// grammar as `history --since`: `YYYY-MM-DD`, `N[WMQY]`, or `ytd`.
|
||||
since: ?zfin.Date = null,
|
||||
/// The raw `--since` token as the user typed it (e.g. "1Y", "ytd",
|
||||
/// "2025-01-15"), kept only to label the "Change (<span>)" detail
|
||||
/// row. Null when `--since` was omitted.
|
||||
since_raw: ?[]const u8 = null,
|
||||
};
|
||||
|
||||
pub const meta: framework.Meta = .{
|
||||
|
|
@ -22,17 +33,25 @@ pub const meta: framework.Meta = .{
|
|||
.synopsis = "Show latest quote with chart and 20-day history",
|
||||
.uppercase_first_arg = true,
|
||||
.help =
|
||||
\\Usage: zfin quote <SYMBOL> [--export-chart <PATH>]
|
||||
\\Usage: zfin quote <SYMBOL> [--since <WHEN>] [--export-chart <PATH>]
|
||||
\\
|
||||
\\Show the latest real-time quote for a symbol (Yahoo / TwelveData)
|
||||
\\plus a braille price chart of the last 60 candles and a table
|
||||
\\of the last 20 trading days.
|
||||
\\plus a price chart over a recent window (an inline Kitty image
|
||||
\\when the terminal supports it, braille otherwise) and a table
|
||||
\\of the last 20 trading days. The chart spans the last ~3 months
|
||||
\\by default; use --since to widen or narrow it.
|
||||
\\
|
||||
\\If real-time fetch fails, falls back to the cached close. The
|
||||
\\Yahoo path is free and unauthenticated; TwelveData requires
|
||||
\\TWELVEDATA_API_KEY.
|
||||
\\
|
||||
\\Options:
|
||||
\\ --since <WHEN> Start the price chart at WHEN instead of
|
||||
\\ the default last ~3 months. Applies to
|
||||
\\ both the inline chart and --export-chart.
|
||||
\\ Accepts YYYY-MM-DD, a relative shortcut
|
||||
\\ (1W/1M/1Q/1Y), or 'ytd'. The 20-day
|
||||
\\ history table is unaffected.
|
||||
\\ --export-chart <PATH> Render the price+Bollinger+RSI chart
|
||||
\\ to a PNG file at the given path
|
||||
\\ (1920x1080) and exit. No text output
|
||||
|
|
@ -42,10 +61,11 @@ pub const meta: framework.Meta = .{
|
|||
\\Examples:
|
||||
\\ zfin quote AAPL
|
||||
\\ zfin quote spy # symbols are case-insensitive
|
||||
\\ zfin quote AAPL --since 1Y
|
||||
\\ zfin quote AAPL --export-chart aapl.png
|
||||
\\
|
||||
,
|
||||
.user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue },
|
||||
.user_errors = error{ MissingSymbol, UnexpectedArg, MissingFlagValue, InvalidDate },
|
||||
};
|
||||
|
||||
/// Quote data extracted from the real-time API (or synthesized from candles).
|
||||
|
|
@ -62,12 +82,18 @@ pub const QuoteData = struct {
|
|||
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
|
||||
var symbol: ?[]const u8 = null;
|
||||
var export_chart: ?[]const u8 = null;
|
||||
var since: ?zfin.Date = null;
|
||||
var since_raw: ?[]const u8 = null;
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < cmd_args.len) : (i += 1) {
|
||||
const a = cmd_args[i];
|
||||
if (std.mem.eql(u8, a, "--export-chart")) {
|
||||
export_chart = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
} else if (std.mem.eql(u8, a, "--since")) {
|
||||
const value = try cli.requireFlagValue(ctx.io, cmd_args, &i, a);
|
||||
since = cli.parseRequiredDateOrStderr(ctx.io, value, ctx.today, "--since") catch return error.InvalidDate;
|
||||
since_raw = value;
|
||||
} else if (a.len > 0 and a[0] == '-') {
|
||||
// Reject ANY leading-dash token we don't recognize,
|
||||
// including single-dash ones like `-x`. Previously only
|
||||
|
|
@ -90,7 +116,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr
|
|||
cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n");
|
||||
return error.MissingSymbol;
|
||||
}
|
||||
return .{ .symbol = symbol.?, .export_chart = export_chart };
|
||||
return .{ .symbol = symbol.?, .export_chart = export_chart, .since = since, .since_raw = since_raw };
|
||||
}
|
||||
|
||||
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
||||
|
|
@ -110,19 +136,15 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
defer candle_result.deinit();
|
||||
const candles = candle_result.data;
|
||||
|
||||
// PNG export short-circuits all text rendering. Use the
|
||||
// longest timeframe the candle history can support - falling
|
||||
// back to shorter ones until one fits - so the user gets the
|
||||
// most chart context without having to think about it.
|
||||
// Chart window: candles on/after the `--since` date (default: the
|
||||
// last ~3 months). `display_count` is how many recent candles get
|
||||
// drawn; the overlays warm up over extra lookback (see emitQuoteKitty).
|
||||
const since_date = parsed.since orelse ctx.today.subtractMonths(3);
|
||||
const display_count = fmt.filterCandlesFrom(candles, since_date).len;
|
||||
|
||||
// PNG export short-circuits all text rendering.
|
||||
if (parsed.export_chart) |path| {
|
||||
const tf: tui_chart.Timeframe = blk: {
|
||||
const candidates = [_]tui_chart.Timeframe{ .@"5Y", .@"3Y", .@"1Y", .@"6M" };
|
||||
for (candidates) |c| {
|
||||
if (candles.len >= c.tradingDays()) break :blk c;
|
||||
}
|
||||
break :blk .@"6M"; // fallback; renderToSurface enforces >= 20 candles
|
||||
};
|
||||
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) {
|
||||
chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, display_count, path) catch |err| switch (err) {
|
||||
error.InsufficientData => {
|
||||
cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n");
|
||||
return;
|
||||
|
|
@ -179,7 +201,23 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
|
|||
} else |_| {}
|
||||
}
|
||||
|
||||
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out);
|
||||
const k: KittyChart = .{ .io = ctx.io, .caps = ctx.graphics_caps };
|
||||
const chart_render: ChartRender = switch (ctx.globals.chart_config.mode) {
|
||||
.braille => .braille,
|
||||
.kitty => .{ .kitty = k },
|
||||
.auto => if (ctx.graphics_caps.kitty) .{ .kitty = k } else .braille,
|
||||
};
|
||||
|
||||
// Short label for the "Change (<span>)" row: echo the --since token
|
||||
// (upper-cased so "1y" -> "1Y", "ytd" -> "YTD") or "3M" for the
|
||||
// default window. Backed by a stack buffer that outlives `display`.
|
||||
var wl_buf: [16]u8 = undefined;
|
||||
const window_label: []const u8 = if (parsed.since_raw) |raw| blk: {
|
||||
const len = @min(raw.len, wl_buf.len);
|
||||
break :blk std.ascii.upperString(wl_buf[0..len], raw[0..len]);
|
||||
} else "3M";
|
||||
|
||||
try display(ctx.allocator, candles, quote, parsed.symbol, name, ctx.today, ctx.color, ctx.out, display_count, window_label, chart_render);
|
||||
}
|
||||
|
||||
/// Copy `s` (clamped to `buf`'s capacity) into `buf` and return the
|
||||
|
|
@ -220,7 +258,52 @@ fn loadClassificationMap(ctx: *framework.RunCtx) ?zfin.classification.Classifica
|
|||
return zfin.classification.parseClassificationFile(ctx.allocator, meta_data) catch null;
|
||||
}
|
||||
|
||||
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer) !void {
|
||||
/// How `display` draws the price chart.
|
||||
const ChartRender = union(enum) {
|
||||
/// Braille price line - the universal fallback.
|
||||
braille,
|
||||
/// Inline kitty graphics (price + Bollinger + volume + RSI).
|
||||
kitty: KittyChart,
|
||||
};
|
||||
|
||||
const KittyChart = struct {
|
||||
io: std.Io,
|
||||
caps: term_query.Caps,
|
||||
};
|
||||
|
||||
/// Braille price chart of the most recent `display_count` candles (the
|
||||
/// fallback path, used when kitty graphics aren't available).
|
||||
fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle, display_count: usize) !void {
|
||||
const n = @min(candles.len, display_count);
|
||||
const data = candles[candles.len - n ..];
|
||||
var ch = fmt.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
|
||||
defer ch.deinit(allocator);
|
||||
try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
|
||||
}
|
||||
|
||||
/// Render the price+Bollinger+volume+RSI chart for the most recent
|
||||
/// `display_count` candles as kitty graphics at `term_graphics.quote_cols`
|
||||
/// wide and emit it inline. The overlays are computed with a warmup
|
||||
/// lookback (`chart.computeIndicatorsWarmup`) so they're valid from the
|
||||
/// first displayed candle at any window size. Returns
|
||||
/// `error.InsufficientData` when there's too little history (< 20 candles)
|
||||
/// so the caller can fall back to braille.
|
||||
fn emitQuoteKitty(allocator: std.mem.Allocator, out: *std.Io.Writer, candles: []const zfin.Candle, display_count: usize, k: KittyChart) !void {
|
||||
var cached = try tui_chart.computeIndicatorsWarmup(allocator, candles, display_count, 20);
|
||||
defer cached.deinit(allocator);
|
||||
const n = @min(candles.len, display_count);
|
||||
const display_data = candles[candles.len - n ..];
|
||||
const cols = term_graphics.quote_cols;
|
||||
const rows = term_graphics.rowsForWidth(cols, k.caps.cell_w, k.caps.cell_h);
|
||||
const dims = term_graphics.pixelDims(cols, rows, k.caps.cell_w, k.caps.cell_h);
|
||||
var rendered = try tui_chart.renderToSurface(k.io, allocator, display_data, null, dims.width, dims.height, theme.default_theme, &cached, true);
|
||||
defer rendered.deinit(allocator);
|
||||
const rgb = try rendered.extractRgb(allocator);
|
||||
defer allocator.free(rgb);
|
||||
try term_graphics.placeInline(out, allocator, rgb, dims.width, dims.height, cols, rows);
|
||||
}
|
||||
|
||||
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, name: ?[]const u8, as_of: zfin.Date, color: bool, out: *std.Io.Writer, display_count: usize, window_label: []const u8, chart_render: ChartRender) !void {
|
||||
const has_quote = quote != null;
|
||||
|
||||
// Header. The security name (when resolved) renders between the
|
||||
|
|
@ -258,23 +341,33 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
|
|||
try out.print(" Low: ${d:.2}\n", .{low_val});
|
||||
try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)});
|
||||
|
||||
if (prev_close > 0) {
|
||||
const change = price - prev_close;
|
||||
const pct = (change / prev_close) * 100.0;
|
||||
if (fmt.pctChange(price, prev_close)) |dc| {
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
try cli.printGainLoss(out, color, change, " Change: {s}\n", .{fmt.fmtPriceChange(&chg_buf, change, pct)});
|
||||
try cli.printGainLoss(out, color, dc.change, " {s:<14} {s}\n", .{ "Change (1D):", fmt.fmtPriceChange(&chg_buf, dc.change, dc.pct) });
|
||||
}
|
||||
|
||||
// Change over the chart window: the first visible candle's close
|
||||
// (the chart's left edge) vs the current price. The `(<span>)`
|
||||
// mirrors the --since window, disambiguating it from the 1-day
|
||||
// change above.
|
||||
if (fmt.windowChange(candles, display_count, price)) |wc| {
|
||||
var wbuf: [64]u8 = undefined;
|
||||
var lbl_buf: [32]u8 = undefined;
|
||||
const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{window_label}) catch "Change:";
|
||||
try cli.printGainLoss(out, color, wc.change, " {s:<14} {s}\n", .{ lbl, fmt.fmtPriceChange(&wbuf, wc.change, wc.pct) });
|
||||
}
|
||||
}
|
||||
|
||||
// Braille chart (60 columns, 10 rows)
|
||||
// Chart: inline kitty graphics when supported, else a braille price
|
||||
// chart over the selected window (`display_count` recent candles).
|
||||
if (candles.len >= 2) {
|
||||
try out.print("\n", .{});
|
||||
const chart_days: usize = @min(candles.len, 60);
|
||||
const chart_data = candles[candles.len - chart_days ..];
|
||||
var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
|
||||
if (chart) |*ch| {
|
||||
defer ch.deinit(allocator);
|
||||
try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED, false);
|
||||
switch (chart_render) {
|
||||
.braille => try renderBrailleCandles(allocator, out, color, candles, display_count),
|
||||
.kitty => |k| emitQuoteKitty(allocator, out, candles, display_count, k) catch |err| switch (err) {
|
||||
error.InsufficientData => try renderBrailleCandles(allocator, out, color, candles, display_count),
|
||||
else => return err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -352,6 +445,49 @@ test "parseArgs: --export-chart followed by a flag does not swallow the flag" {
|
|||
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --since accepts an explicit YYYY-MM-DD" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
|
||||
const args = [_][]const u8{ "AAPL", "--since", "2025-01-15" };
|
||||
const parsed = try parseArgs(&ctx, &args);
|
||||
try std.testing.expect(parsed.since.?.eql(zfin.Date.fromYmd(2025, 1, 15)));
|
||||
}
|
||||
|
||||
test "parseArgs: --since accepts a relative shortcut" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
|
||||
const args = [_][]const u8{ "AAPL", "--since", "1Y" };
|
||||
const parsed = try parseArgs(&ctx, &args);
|
||||
// 1Y back from 2026-05-08 is 2025-05-08 (calendar-year subtraction).
|
||||
try std.testing.expect(parsed.since.?.eql(zfin.Date.fromYmd(2025, 5, 8)));
|
||||
}
|
||||
|
||||
test "parseArgs: --since defaults to null when omitted" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
const args = [_][]const u8{"AAPL"};
|
||||
const parsed = try parseArgs(&ctx, &args);
|
||||
try std.testing.expect(parsed.since == null);
|
||||
}
|
||||
|
||||
test "parseArgs: --since without a value is rejected" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
|
||||
const args = [_][]const u8{ "AAPL", "--since" };
|
||||
try std.testing.expectError(error.MissingFlagValue, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "parseArgs: --since with an invalid value is rejected" {
|
||||
var ctx: framework.RunCtx = undefined;
|
||||
ctx.io = std.testing.io;
|
||||
ctx.today = zfin.Date.fromYmd(2026, 5, 8);
|
||||
const args = [_][]const u8{ "AAPL", "--since", "garbage" };
|
||||
try std.testing.expectError(error.InvalidDate, parseArgs(&ctx, &args));
|
||||
}
|
||||
|
||||
test "display with candles only" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -359,7 +495,7 @@ test "display with candles only" {
|
|||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
.{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
|
||||
|
|
@ -367,6 +503,24 @@ test "display with candles only" {
|
|||
try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days shown") != null);
|
||||
}
|
||||
|
||||
test "display renders both the 1D and window change rows" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 100.0, .adj_close = 100.0, .volume = 1_000_000 },
|
||||
.{ .date = .{ .days = 20001 }, .open = 100.0, .high = 112.0, .low = 100.0, .close = 110.0, .adj_close = 110.0, .volume = 1_200_000 },
|
||||
.{ .date = .{ .days = 20002 }, .open = 110.0, .high = 130.0, .low = 108.0, .close = 125.0, .adj_close = 125.0, .volume = 1_500_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "1Y", .braille);
|
||||
const out = w.buffered();
|
||||
// 1-day change is the last close (125) vs the prior close (110).
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change (1D):") != null);
|
||||
// Window change carries the supplied span label and measures from
|
||||
// the first visible candle's close (100) to the current price (125).
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change (1Y):") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "+$25.00 (+25.00%)") != null);
|
||||
}
|
||||
|
||||
test "display with quote data" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -380,7 +534,7 @@ test "display with quote data" {
|
|||
.prev_close = 172.00,
|
||||
.date = .{ .days = 20001 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, quote, "AAPL", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
|
||||
|
|
@ -394,7 +548,7 @@ test "display renders the security name when provided" {
|
|||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "Apple Inc.", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
// Name appears between the symbol and the price.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL Apple Inc.") != null);
|
||||
|
|
@ -406,7 +560,7 @@ test "display omits an empty name" {
|
|||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, null, "AAPL", "", zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
// No double-space orphan where the name would have gone.
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL $") != null);
|
||||
|
|
@ -418,7 +572,7 @@ test "display no ANSI without color" {
|
|||
const candles = [_]zfin.Candle{
|
||||
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
|
||||
};
|
||||
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w);
|
||||
try display(std.testing.allocator, &candles, null, "SPY", null, zfin.Date.fromYmd(2026, 5, 8), false, &w, 60, "3M", .braille);
|
||||
const out = w.buffered();
|
||||
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -512,6 +512,37 @@ pub fn filterCandlesFrom(candles: []const Candle, from: Date) []const Candle {
|
|||
return candles[lo..];
|
||||
}
|
||||
|
||||
/// An absolute and percentage price change.
|
||||
pub const PctChange = struct {
|
||||
/// `price - base`, in price units.
|
||||
change: f64,
|
||||
/// `(price - base) / base * 100`, a percentage.
|
||||
pct: f64,
|
||||
};
|
||||
|
||||
/// Absolute and percentage change from `base` to `price`. Returns null
|
||||
/// when `base <= 0`, where a percentage isn't meaningful (e.g. a missing
|
||||
/// previous close). The single source of truth for the day-over-day and
|
||||
/// chart-window change figures shared by the `quote` CLI command and the
|
||||
/// TUI quote tab.
|
||||
pub fn pctChange(price: f64, base: f64) ?PctChange {
|
||||
if (base <= 0) return null;
|
||||
const change = price - base;
|
||||
return .{ .change = change, .pct = (change / base) * 100.0 };
|
||||
}
|
||||
|
||||
/// Change from the first candle of the most-recent `window_count` candles
|
||||
/// (the chart's left edge) to `price` (the current price). Used to label
|
||||
/// the "Change (<span>)" row in both the `quote` CLI command and the TUI
|
||||
/// quote tab. `window_count` is clamped to the available candles. Returns
|
||||
/// null when the window holds fewer than 2 candles or its first close is
|
||||
/// non-positive - i.e. there's no meaningful window to measure.
|
||||
pub fn windowChange(candles: []const Candle, window_count: usize, price: f64) ?PctChange {
|
||||
const win_n = @min(candles.len, window_count);
|
||||
if (win_n < 2) return null;
|
||||
return pctChange(price, candles[candles.len - win_n].close);
|
||||
}
|
||||
|
||||
// ── Options helpers ──────────────────────────────────────────
|
||||
|
||||
/// Filter options contracts to +/- N strikes from ATM.
|
||||
|
|
@ -1305,6 +1336,64 @@ test "filterCandlesFrom" {
|
|||
try std.testing.expectEqual(@as(usize, 0), from_empty.len);
|
||||
}
|
||||
|
||||
fn testCandle(close: f64) Candle {
|
||||
return .{ .date = Date.fromYmd(2024, 1, 2), .open = close, .high = close, .low = close, .close = close, .adj_close = close, .volume = 1000 };
|
||||
}
|
||||
|
||||
test "pctChange: up and down moves" {
|
||||
const up = pctChange(110.0, 100.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10.0), up.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 10.0), up.pct, 1e-9);
|
||||
|
||||
const down = pctChange(90.0, 100.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, -10.0), down.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, -10.0), down.pct, 1e-9);
|
||||
}
|
||||
|
||||
test "pctChange: non-positive base returns null" {
|
||||
try std.testing.expect(pctChange(100.0, 0.0) == null);
|
||||
try std.testing.expect(pctChange(100.0, -5.0) == null);
|
||||
}
|
||||
|
||||
test "windowChange: measures from the first candle in the window" {
|
||||
const candles = [_]Candle{ testCandle(100.0), testCandle(110.0), testCandle(125.0) };
|
||||
// Full window: base = first close (100), price 125 -> +25 (+25%).
|
||||
const full = windowChange(&candles, 3, 125.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 25.0), full.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 25.0), full.pct, 1e-9);
|
||||
}
|
||||
|
||||
test "windowChange: window_count clamps to available candles" {
|
||||
const candles = [_]Candle{ testCandle(100.0), testCandle(110.0) };
|
||||
// Asking for 60 but only 2 exist -> base is the earliest (100).
|
||||
const wc = windowChange(&candles, 60, 121.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 21.0), wc.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 21.0), wc.pct, 1e-9);
|
||||
}
|
||||
|
||||
test "windowChange: sub-window picks candles[len-window_count] as base" {
|
||||
const candles = [_]Candle{ testCandle(50.0), testCandle(100.0), testCandle(120.0) };
|
||||
// window_count=2 -> base is candles[len-2] = 100, not 50.
|
||||
const wc = windowChange(&candles, 2, 120.0).?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 20.0), wc.change, 1e-9);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 20.0), wc.pct, 1e-9);
|
||||
}
|
||||
|
||||
test "windowChange: fewer than 2 candles in the window returns null" {
|
||||
const one = [_]Candle{testCandle(100.0)};
|
||||
try std.testing.expect(windowChange(&one, 60, 110.0) == null);
|
||||
const empty: []const Candle = &.{};
|
||||
try std.testing.expect(windowChange(empty, 60, 110.0) == null);
|
||||
// window_count < 2 yields null even with enough candles.
|
||||
const two = [_]Candle{ testCandle(100.0), testCandle(110.0) };
|
||||
try std.testing.expect(windowChange(&two, 1, 110.0) == null);
|
||||
}
|
||||
|
||||
test "windowChange: non-positive base close returns null" {
|
||||
const candles = [_]Candle{ testCandle(0.0), testCandle(110.0) };
|
||||
try std.testing.expect(windowChange(&candles, 2, 110.0) == null);
|
||||
}
|
||||
|
||||
test "filterNearMoney" {
|
||||
const exp = Date.fromYmd(2024, 3, 15);
|
||||
const contracts = [_]OptionContract{
|
||||
|
|
|
|||
20
src/main.zig
20
src/main.zig
|
|
@ -3,6 +3,8 @@ const zfin = @import("root.zig");
|
|||
const tui = @import("tui.zig");
|
||||
const cli = @import("commands/common.zig");
|
||||
const cmd_framework = @import("commands/framework.zig");
|
||||
const chart = @import("charts/chart.zig");
|
||||
const term_query = @import("term_query.zig");
|
||||
|
||||
/// Comptime registry of CLI commands. Field name is the user-facing
|
||||
/// subcommand name; value is the imported module struct. Order
|
||||
|
|
@ -86,12 +88,15 @@ const usage_footer =
|
|||
\\ the same directory as the first resolved
|
||||
\\ portfolio file.
|
||||
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
|
||||
\\ --chart <MODE> Inline chart graphics for history / quote /
|
||||
\\ projections: auto (Kitty image when the
|
||||
\\ terminal supports it, else braille),
|
||||
\\ braille, or WxH (Kitty at that resolution)
|
||||
\\
|
||||
\\Interactive command options:
|
||||
\\ -s, --symbol <SYMBOL> Pre-load a symbol and open on the
|
||||
\\ Quote tab. Without this flag, the TUI
|
||||
\\ opens on the Portfolio tab.
|
||||
\\ --chart <MODE> Chart graphics: auto, braille, or WxH
|
||||
\\ --default-keys Print default keybindings
|
||||
\\ --default-theme Print default theme
|
||||
\\
|
||||
|
|
@ -159,6 +164,8 @@ const Globals = struct {
|
|||
/// Default: `.auto` (TTL-respecting). Other values: `.force`
|
||||
/// (re-fetch regardless of TTL) and `.never` (offline mode).
|
||||
refresh_policy: cmd_framework.RefreshPolicy = .auto,
|
||||
/// Chart graphics mode from `--chart` (auto / braille / WxH).
|
||||
chart_config: chart.ChartConfig = .{},
|
||||
/// Index into args of the first post-global token (the subcommand).
|
||||
cursor: usize,
|
||||
};
|
||||
|
|
@ -168,6 +175,8 @@ const GlobalParseError = error{
|
|||
UnknownGlobalFlag,
|
||||
/// `--refresh-data=<value>` got something other than auto/force/never.
|
||||
InvalidRefreshDataValue,
|
||||
/// `--chart=<value>` got something other than auto/braille/WxH.
|
||||
InvalidChartValue,
|
||||
/// Multiple `.srf` files appeared as a single -p argument, almost
|
||||
/// certainly because the shell expanded an unquoted glob. We
|
||||
/// surface this as a dedicated error so the user gets a friendly
|
||||
|
|
@ -284,6 +293,12 @@ fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalPa
|
|||
// space). Surface the shape mismatch explicitly.
|
||||
return error.MissingValue;
|
||||
}
|
||||
if (std.mem.eql(u8, a, "--chart")) {
|
||||
if (i + 1 >= args.len) return error.MissingValue;
|
||||
g.chart_config = chart.ChartConfig.parse(args[i + 1]) orelse return error.InvalidChartValue;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Help flags are subcommand-like tokens, stop scanning.
|
||||
if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) break;
|
||||
|
||||
|
|
@ -355,6 +370,7 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
|
||||
},
|
||||
error.InvalidRefreshDataValue => cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
|
||||
error.InvalidChartValue => cli.stderrPrint(io, "Error: --chart requires one of: auto, braille, or WxH (e.g. 1920x1080).\n"),
|
||||
error.UnquotedGlobLikely => {
|
||||
cli.stderrPrint(io,
|
||||
\\Error: -p was given a single value followed by additional .srf files.
|
||||
|
|
@ -499,11 +515,13 @@ fn runCli(init: std.process.Init) !u8 {
|
|||
.portfolio_patterns = globals.portfolio_patterns,
|
||||
.watchlist_path = globals.watchlist_path,
|
||||
.refresh_policy = globals.refresh_policy,
|
||||
.chart_config = globals.chart_config,
|
||||
},
|
||||
.today = today,
|
||||
.now_s = now_s,
|
||||
.color = color,
|
||||
.out = out,
|
||||
.graphics_caps = term_query.detect(io, init.environ_map),
|
||||
};
|
||||
const dispatched_args = if (comptime Module.meta.uppercase_first_arg)
|
||||
try cmd_framework.normalizeFirstArg(allocator, cmd_args)
|
||||
|
|
|
|||
186
src/market.zig
186
src/market.zig
|
|
@ -26,6 +26,8 @@ const std = @import("std");
|
|||
const zeit = @import("zeit");
|
||||
const Date = @import("Date.zig");
|
||||
|
||||
const log = std.log.scoped(.market);
|
||||
|
||||
/// POSIX TZ spec for US Eastern: EST (UTC-5) / EDT (UTC-4) with the
|
||||
/// current US DST rule (2nd Sunday March -> 1st Sunday November).
|
||||
const eastern_posix_spec = "EST5EDT,M3.2.0,M11.1.0";
|
||||
|
|
@ -351,6 +353,137 @@ fn etLocalToUtc(d: Date, tod_s: i64) i64 {
|
|||
return utc;
|
||||
}
|
||||
|
||||
// ── Wall-clock display + intraday session ────────────────────────────
|
||||
|
||||
/// Re-export so callers can name the resolved-zone type without
|
||||
/// importing zeit directly - the zeit dependency stays an
|
||||
/// implementation detail behind this module.
|
||||
pub const TimeZone = zeit.TimeZone;
|
||||
|
||||
/// Resolve the user's local timezone, for rendering user-action
|
||||
/// timestamps (e.g. the TUI "refreshed at" stamp). Honors a `$TZ`
|
||||
/// override when `tz_override` is a non-empty string (a POSIX spec,
|
||||
/// `:/abs/path`, or `:Area/City`); otherwise reads the system default
|
||||
/// (`/etc/localtime`). Does I/O and may allocate - call once at the
|
||||
/// top of a unit of work (App init) and thread the result as a value.
|
||||
/// The returned zone owns memory when it resolves from tzinfo; call
|
||||
/// `.deinit()` on it at teardown (a no-op for the Eastern fallback,
|
||||
/// which borrows a static spec).
|
||||
///
|
||||
/// Lifetime note: when `tz_override` is a POSIX spec, the returned
|
||||
/// zone *borrows* `tz_override`, so the caller must keep it alive for
|
||||
/// the zone's lifetime. Passing `config.environ_map.get("TZ")`
|
||||
/// satisfies this - the environ map is owned by main and outlives the
|
||||
/// App.
|
||||
///
|
||||
/// On failure (no zoneinfo on the system, unparseable tz, etc.) we
|
||||
/// fall back to US Eastern - the app's market zone - so a stamp still
|
||||
/// renders rather than the whole readout failing.
|
||||
pub fn localTimeZone(alloc: std.mem.Allocator, io: std.Io, tz_override: ?[]const u8) TimeZone {
|
||||
// An empty $TZ ("TZ=") is not a valid spec for zeit (it asserts
|
||||
// non-empty); treat it as unset and fall through to the system
|
||||
// default.
|
||||
const tz: ?[]const u8 = if (tz_override) |t| (if (t.len > 0) t else null) else null;
|
||||
return zeit.local(alloc, io, .{ .tz = tz }) catch |err| {
|
||||
log.debug("local timezone resolve failed ({t}); falling back to ET", .{err});
|
||||
return eastern;
|
||||
};
|
||||
}
|
||||
|
||||
/// 12-hour clock components decomposed from a count of seconds since
|
||||
/// local midnight (`tod`, expected in [0, 86400)).
|
||||
const Clock12 = struct {
|
||||
hour: u8, // 1..12
|
||||
minute: u8, // 0..59
|
||||
am: bool,
|
||||
|
||||
fn ampm(self: Clock12) []const u8 {
|
||||
return if (self.am) "AM" else "PM";
|
||||
}
|
||||
};
|
||||
|
||||
fn clock12FromTod(tod: i64) Clock12 {
|
||||
const hour24 = @divFloor(tod, std.time.s_per_hour);
|
||||
// tod is non-negative, so hour/minute are in range; cast to
|
||||
// unsigned so `{d:0>2}` doesn't emit a sign placeholder.
|
||||
const minute: u8 = @intCast(@divFloor(@mod(tod, std.time.s_per_hour), std.time.s_per_min));
|
||||
const h: u8 = @intCast(@mod(hour24, 12));
|
||||
return .{ .hour = if (h == 0) 12 else h, .minute = minute, .am = hour24 < 12 };
|
||||
}
|
||||
|
||||
/// Render a Unix-seconds instant as a US Eastern wall-clock time,
|
||||
/// 12-hour with an "ET" suffix: "2:34 PM ET", "9:05 AM ET",
|
||||
/// "12:00 PM ET" (noon), "12:00 AM ET" (midnight). Writes into `buf`
|
||||
/// (needs >= 11 bytes) and returns the slice.
|
||||
///
|
||||
/// Uses the same hardcoded Eastern TZ as the rest of this module, so
|
||||
/// the label is always "ET" (market time) regardless of the caller's
|
||||
/// locale - the honest stamp for market data. Pure given `unix_s`.
|
||||
pub fn fmtClockET(buf: []u8, unix_s: i64) []const u8 {
|
||||
const local = eastern.adjust(unix_s).timestamp;
|
||||
const d = Date.fromEpoch(local);
|
||||
const c = clock12FromTod(local - d.toEpoch());
|
||||
return std.fmt.bufPrint(buf, "{d}:{d:0>2} {s} ET", .{ c.hour, c.minute, c.ampm() }) catch "?";
|
||||
}
|
||||
|
||||
/// Render a "refreshed at" wall-clock stamp for instant `at_s` in
|
||||
/// timezone `tz`, using the zone's own abbreviation (e.g. "PST",
|
||||
/// "EDT"). When `at_s` falls on the same local calendar day as
|
||||
/// `now_s`, only the time is shown ("2:34 PM PST"); otherwise a short
|
||||
/// date is prefixed ("Jun 25, 2:34 PM PST") so a stamp left on an
|
||||
/// idle screen overnight stays unambiguous. Writes into `buf` (needs
|
||||
/// >= 24 bytes) and returns the slice.
|
||||
///
|
||||
/// Unlike `fmtClockET`, this renders in the caller-supplied zone
|
||||
/// (typically the user's local zone from `localTimeZone`) because a
|
||||
/// "when did I refresh" readout is about the user's wall clock, not
|
||||
/// market time.
|
||||
pub fn fmtStamp(buf: []u8, tz: TimeZone, at_s: i64, now_s: i64) []const u8 {
|
||||
const at = tz.adjust(at_s);
|
||||
const d = Date.fromEpoch(at.timestamp);
|
||||
const c = clock12FromTod(at.timestamp - d.toEpoch());
|
||||
const now_day = Date.fromEpoch(tz.adjust(now_s).timestamp);
|
||||
if (d.eql(now_day)) {
|
||||
return std.fmt.bufPrint(buf, "{d}:{d:0>2} {s} {s}", .{ c.hour, c.minute, c.ampm(), at.designation }) catch "?";
|
||||
}
|
||||
return std.fmt.bufPrint(buf, "{s} {d}, {d}:{d:0>2} {s} {s}", .{
|
||||
Date.monthShort(d.month()), d.day(), c.hour, c.minute, c.ampm(), at.designation,
|
||||
}) catch "?";
|
||||
}
|
||||
|
||||
/// The US equity market session at a given instant.
|
||||
pub const MarketSession = enum {
|
||||
/// Regular trading hours: 09:30-16:00 ET on a trading day.
|
||||
open,
|
||||
/// Before the open on a trading day (00:00-09:30 ET).
|
||||
premarket,
|
||||
/// At/after the close on a trading day (16:00-24:00 ET).
|
||||
afterhours,
|
||||
/// Not a trading day at all - weekend or modeled holiday.
|
||||
closed,
|
||||
};
|
||||
|
||||
/// Regular trading-session bounds (seconds since ET-local midnight):
|
||||
/// 09:30 open, 16:00 close. Distinct from the freshness `*_target_s`
|
||||
/// boundaries above, which sit after the close for cache timing.
|
||||
const regular_open_s: i64 = 9 * std.time.s_per_hour + 30 * std.time.s_per_min;
|
||||
const regular_close_s: i64 = 16 * std.time.s_per_hour;
|
||||
|
||||
/// Classify the US equity market session at `now_s`. Pure given
|
||||
/// `now_s` (the wall clock is read by the caller and threaded in),
|
||||
/// so it is fully deterministic for tests. Holidays follow the same
|
||||
/// modeled-only policy as `isHoliday` (an un-modeled closure like
|
||||
/// Good Friday reports `.open`).
|
||||
pub fn marketSession(now_s: i64) MarketSession {
|
||||
const local = eastern.adjust(now_s).timestamp;
|
||||
const today_et = Date.fromEpoch(local);
|
||||
if (!isTradingDay(today_et)) return .closed;
|
||||
const tod = local - today_et.toEpoch();
|
||||
if (tod < regular_open_s) return .premarket;
|
||||
if (tod >= regular_close_s) return .afterhours;
|
||||
return .open;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
|
@ -603,3 +736,56 @@ test "candleFreshness mutual_fund: late NAV is lagging" {
|
|||
// Cache already has Monday -> nothing newer is due -> current.
|
||||
try testing.expectEqual(CandleFreshness.current, candleFreshness(now, .mutual_fund, Date.fromYmd(2025, 6, 16)));
|
||||
}
|
||||
|
||||
test "fmtClockET: 12-hour rendering with EST/EDT and AM/PM" {
|
||||
var buf: [16]u8 = undefined;
|
||||
// Afternoon (EST, winter): 14:34 ET.
|
||||
try testing.expectEqualStrings("2:34 PM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 1, 15), 14 * std.time.s_per_hour + 34 * std.time.s_per_min)));
|
||||
// Morning (EDT, summer): 09:05 ET, minute zero-padded.
|
||||
try testing.expectEqualStrings("9:05 AM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 7, 15), 9 * std.time.s_per_hour + 5 * std.time.s_per_min)));
|
||||
// Noon and midnight are the 12-hour edge cases.
|
||||
try testing.expectEqualStrings("12:00 PM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 7, 15), 12 * std.time.s_per_hour)));
|
||||
try testing.expectEqualStrings("12:00 AM ET", fmtClockET(&buf, etLocalToUtc(Date.fromYmd(2025, 7, 15), 0)));
|
||||
}
|
||||
|
||||
test "marketSession: trading-day hours, pre/after, weekend, holiday" {
|
||||
const wed = Date.fromYmd(2025, 6, 11); // a regular trading Wednesday
|
||||
// 10:00 ET -> open.
|
||||
try testing.expectEqual(MarketSession.open, marketSession(etLocalToUtc(wed, 10 * std.time.s_per_hour)));
|
||||
// 09:30 ET exactly -> open (inclusive lower bound).
|
||||
try testing.expectEqual(MarketSession.open, marketSession(etLocalToUtc(wed, regular_open_s)));
|
||||
// 09:29 ET -> premarket.
|
||||
try testing.expectEqual(MarketSession.premarket, marketSession(etLocalToUtc(wed, regular_open_s - std.time.s_per_min)));
|
||||
// 16:00 ET exactly -> afterhours (inclusive upper bound).
|
||||
try testing.expectEqual(MarketSession.afterhours, marketSession(etLocalToUtc(wed, regular_close_s)));
|
||||
// Saturday -> closed regardless of clock.
|
||||
try testing.expectEqual(MarketSession.closed, marketSession(etLocalToUtc(Date.fromYmd(2025, 6, 14), 12 * std.time.s_per_hour)));
|
||||
// Thanksgiving 2025-11-27 -> closed (modeled holiday).
|
||||
try testing.expectEqual(MarketSession.closed, marketSession(etLocalToUtc(Date.fromYmd(2025, 11, 27), 12 * std.time.s_per_hour)));
|
||||
}
|
||||
|
||||
test "fmtStamp: time-only same local day, date-prefixed across days" {
|
||||
var buf: [32]u8 = undefined;
|
||||
// Tested against the Eastern zone so the designation is deterministic.
|
||||
const at = etLocalToUtc(Date.fromYmd(2025, 1, 15), 14 * std.time.s_per_hour + 34 * std.time.s_per_min);
|
||||
// Same local day as `at` -> time + zone abbreviation only.
|
||||
const same_day_now = etLocalToUtc(Date.fromYmd(2025, 1, 15), 18 * std.time.s_per_hour);
|
||||
try testing.expectEqualStrings("2:34 PM EST", fmtStamp(&buf, eastern, at, same_day_now));
|
||||
// `now` is the following day -> short date prefix keeps an
|
||||
// overnight-idle stamp unambiguous.
|
||||
const next_day_now = etLocalToUtc(Date.fromYmd(2025, 1, 16), 9 * std.time.s_per_hour);
|
||||
try testing.expectEqualStrings("Jan 15, 2:34 PM EST", fmtStamp(&buf, eastern, at, next_day_now));
|
||||
}
|
||||
|
||||
test "localTimeZone: honors a POSIX $TZ override" {
|
||||
// A POSIX TZ spec resolves without touching the filesystem (zeit
|
||||
// parses it directly), so this exercises the $TZ path
|
||||
// deterministically. Pacific in January -> PST.
|
||||
const tz = localTimeZone(testing.allocator, testing.io, "PST8PDT,M3.2.0,M11.1.0");
|
||||
defer tz.deinit();
|
||||
var buf: [32]u8 = undefined;
|
||||
// 2025-01-15 20:00 UTC == 12:00 PST. Same instant for `now`, so the
|
||||
// stamp is time-only and must carry the override zone's "PST", not ET.
|
||||
const at = utcSeconds(2025, 1, 15, 20, 0);
|
||||
try testing.expectEqualStrings("12:00 PM PST", fmtStamp(&buf, tz, at, at));
|
||||
}
|
||||
|
|
|
|||
190
src/term_graphics.zig
Normal file
190
src/term_graphics.zig
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
//! Kitty graphics protocol emission for the plain CLI commands.
|
||||
//!
|
||||
//! The TUI renders bitmap charts through vaxis, which speaks the kitty
|
||||
//! graphics protocol for us. The non-interactive CLI (`zfin history`,
|
||||
//! `quote`, `projections`) has no vaxis loop, so it emits the protocol
|
||||
//! directly: this module turns a raw RGB buffer into the APC escape
|
||||
//! sequence that a kitty-capable terminal renders inline at the cursor.
|
||||
//!
|
||||
//! It also centralizes the inline-chart sizing: the on-screen size is
|
||||
//! expressed in terminal *columns* (the headline projections/quote
|
||||
//! charts are wider than the history timeline), and the pixel render
|
||||
//! size is derived from the terminal's cell pixel dimensions.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Default cell pixel size assumed when the terminal hasn't reported one.
|
||||
/// Kitty scales the transmitted image to the requested `c`=cols,`r`=rows
|
||||
/// footprint, so an imperfect assumption only affects render crispness,
|
||||
/// not on-screen layout.
|
||||
pub const default_cell_w: u32 = 8;
|
||||
pub const default_cell_h: u32 = 16;
|
||||
|
||||
/// Inline-chart target widths in terminal columns. The headline charts
|
||||
/// (projections, quote) are wider than the history timeline; tuned to
|
||||
/// roughly match-and-exceed the braille chart's on-screen footprint.
|
||||
pub const history_cols: u16 = 80;
|
||||
pub const projection_cols: u16 = 120;
|
||||
pub const quote_cols: u16 = 120;
|
||||
|
||||
/// Maximum base64 payload bytes per APC escape, per the kitty protocol.
|
||||
const chunk_max: usize = 4096;
|
||||
|
||||
pub const PixelDims = struct { width: u32, height: u32 };
|
||||
|
||||
/// Pixel dimensions for a chart occupying `cols` x `rows` cells.
|
||||
pub fn pixelDims(cols: u16, rows: u16, cell_w: u32, cell_h: u32) PixelDims {
|
||||
return .{ .width = @as(u32, cols) * cell_w, .height = @as(u32, rows) * cell_h };
|
||||
}
|
||||
|
||||
/// Rows for a chart `cols` wide that preserve a ~3:1 width:height pixel
|
||||
/// aspect (matching the braille chart's proportions), given the cell
|
||||
/// pixel size. Always at least 1.
|
||||
pub fn rowsForWidth(cols: u16, cell_w: u32, cell_h: u32) u16 {
|
||||
const width_px = @as(u32, cols) * cell_w;
|
||||
const height_px = width_px / 3;
|
||||
const rows = (height_px + cell_h - 1) / cell_h; // round up
|
||||
return @intCast(@max(rows, @as(u32, 1)));
|
||||
}
|
||||
|
||||
/// Emit `rgb` (length must be `width_px * height_px * 3`) as a kitty
|
||||
/// graphics image displayed at the cursor, occupying `cols` x `rows`
|
||||
/// terminal cells. The payload is base64-encoded and split across as
|
||||
/// many APC escapes as needed (<= 4096 base64 bytes each). `q=2`
|
||||
/// suppresses the terminal's OK/error replies since the CLI isn't
|
||||
/// reading them back; `C=1` keeps the terminal from moving the cursor
|
||||
/// when the image is placed.
|
||||
///
|
||||
/// Because of `C=1` the cursor stays at the image's top-left, so callers
|
||||
/// MUST print `rows` newlines afterward to advance below the chart. This
|
||||
/// makes the vertical advance deterministic across terminals (rather
|
||||
/// than relying on each terminal's default cursor-movement policy).
|
||||
pub fn emitKittyRGB(
|
||||
writer: *std.Io.Writer,
|
||||
alloc: std.mem.Allocator,
|
||||
rgb: []const u8,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) !void {
|
||||
const Encoder = std.base64.standard.Encoder;
|
||||
const b64 = try alloc.alloc(u8, Encoder.calcSize(rgb.len));
|
||||
defer alloc.free(b64);
|
||||
_ = Encoder.encode(b64, rgb);
|
||||
|
||||
var off: usize = 0;
|
||||
var first = true;
|
||||
while (true) {
|
||||
const end = @min(off + chunk_max, b64.len);
|
||||
const more = end < b64.len;
|
||||
try writer.writeAll("\x1b_G");
|
||||
if (first) {
|
||||
// First escape carries the image metadata + control keys.
|
||||
try writer.print(
|
||||
"a=T,q=2,C=1,f=24,s={d},v={d},c={d},r={d},m={d}",
|
||||
.{ width_px, height_px, cols, rows, @intFromBool(more) },
|
||||
);
|
||||
first = false;
|
||||
} else {
|
||||
try writer.print("m={d}", .{@intFromBool(more)});
|
||||
}
|
||||
try writer.writeAll(";");
|
||||
try writer.writeAll(b64[off..end]);
|
||||
try writer.writeAll("\x1b\\");
|
||||
off = end;
|
||||
if (!more) break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit `rgb` as a kitty image (see `emitKittyRGB`), then advance the
|
||||
/// cursor `rows` lines so following output lands directly below it.
|
||||
/// Centralizes the pairing of the `C=1` (no cursor move) emission with
|
||||
/// its matching vertical advance - callers should use this rather than
|
||||
/// emitting + advancing by hand.
|
||||
pub fn placeInline(
|
||||
writer: *std.Io.Writer,
|
||||
alloc: std.mem.Allocator,
|
||||
rgb: []const u8,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) !void {
|
||||
try emitKittyRGB(writer, alloc, rgb, width_px, height_px, cols, rows);
|
||||
var i: u16 = 0;
|
||||
while (i < rows) : (i += 1) try writer.writeByte('\n');
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "pixelDims multiplies cells by cell size" {
|
||||
const d = pixelDims(80, 14, 8, 16);
|
||||
try testing.expectEqual(@as(u32, 640), d.width);
|
||||
try testing.expectEqual(@as(u32, 224), d.height);
|
||||
}
|
||||
|
||||
test "rowsForWidth keeps a ~3:1 pixel aspect" {
|
||||
// 8x16 cells: rows ~= cols/6, rounded up.
|
||||
try testing.expectEqual(@as(u16, 14), rowsForWidth(80, 8, 16)); // 640/3/16 = 13.3 -> 14
|
||||
try testing.expectEqual(@as(u16, 20), rowsForWidth(120, 8, 16)); // 960/3/16 = 20
|
||||
// Never zero, even for a tiny width.
|
||||
try testing.expect(rowsForWidth(1, 8, 16) >= 1);
|
||||
}
|
||||
|
||||
test "emitKittyRGB: single chunk carries control keys + decodable payload" {
|
||||
const alloc = testing.allocator;
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
|
||||
const rgb = [_]u8{ 0xAA, 0xBB, 0xCC, 0x11, 0x22, 0x33 }; // 2x1 RGB
|
||||
try emitKittyRGB(&aw.writer, alloc, &rgb, 2, 1, 4, 2);
|
||||
const out = aw.written();
|
||||
|
||||
try testing.expect(std.mem.startsWith(u8, out, "\x1b_G"));
|
||||
try testing.expect(std.mem.endsWith(u8, out, "\x1b\\"));
|
||||
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,f=24,s=2,v=1,c=4,r=2,m=0") != null);
|
||||
// Exactly one APC escape (one terminator).
|
||||
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\x1b\\"));
|
||||
|
||||
// Payload (between ';' and the ST) round-trips back to the input.
|
||||
const semi = std.mem.indexOfScalar(u8, out, ';').?;
|
||||
const payload = out[semi + 1 .. out.len - 2];
|
||||
var decoded: [6]u8 = undefined;
|
||||
try std.base64.standard.Decoder.decode(&decoded, payload);
|
||||
try testing.expectEqualSlices(u8, &rgb, &decoded);
|
||||
}
|
||||
|
||||
test "emitKittyRGB: large payload is chunked with m=1 then a final m=0" {
|
||||
const alloc = testing.allocator;
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
|
||||
// 64x64 RGB = 12288 bytes -> base64 16384 bytes -> exactly 4 chunks.
|
||||
const rgb = try alloc.alloc(u8, 64 * 64 * 3);
|
||||
defer alloc.free(rgb);
|
||||
@memset(rgb, 0x7F);
|
||||
|
||||
try emitKittyRGB(&aw.writer, alloc, rgb, 64, 64, 10, 5);
|
||||
const out = aw.written();
|
||||
|
||||
try testing.expectEqual(@as(usize, 4), std.mem.count(u8, out, "\x1b_G"));
|
||||
// First escape: control keys + m=1 (more chunks follow).
|
||||
try testing.expect(std.mem.indexOf(u8, out, "a=T,q=2,C=1,f=24,s=64,v=64,c=10,r=5,m=1") != null);
|
||||
// A continuation escape with no control keys, and the final m=0.
|
||||
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=1;") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, out, "\x1b_Gm=0;") != null);
|
||||
}
|
||||
|
||||
test "placeInline appends exactly `rows` newlines after the image" {
|
||||
const alloc = testing.allocator;
|
||||
var aw: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer aw.deinit();
|
||||
const rgb = [_]u8{ 0, 0, 0, 0, 0, 0 }; // 2x1, base64 has no newlines
|
||||
try placeInline(&aw.writer, alloc, &rgb, 2, 1, 4, 3);
|
||||
const out = aw.written();
|
||||
try testing.expect(std.mem.endsWith(u8, out, "\x1b\\\n\n\n"));
|
||||
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, out, "\n"));
|
||||
}
|
||||
95
src/term_query.zig
Normal file
95
src/term_query.zig
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Terminal capability probing for inline kitty graphics in the plain
|
||||
//! CLI commands (the TUI gets this from vaxis).
|
||||
//!
|
||||
//! v1 uses environment heuristics plus a TTY check; it does not actively
|
||||
//! query the terminal. The cell pixel size defaults to a common 8x16 -
|
||||
//! kitty scales the transmitted image to the requested cell footprint,
|
||||
//! so the default only affects render crispness, not layout. (An active
|
||||
//! `CSI 16 t` cell-size query is a sensible follow-up.)
|
||||
|
||||
const std = @import("std");
|
||||
const term_graphics = @import("term_graphics.zig");
|
||||
|
||||
pub const Caps = struct {
|
||||
/// The terminal appears to speak the kitty graphics protocol.
|
||||
kitty: bool = false,
|
||||
/// Cell pixel size used to size inline charts.
|
||||
cell_w: u32 = term_graphics.default_cell_w,
|
||||
cell_h: u32 = term_graphics.default_cell_h,
|
||||
};
|
||||
|
||||
/// Detect inline-graphics capability. Graphics escapes are only ever
|
||||
/// emitted to a real terminal, never into a pipe or file, so a non-TTY
|
||||
/// stdout always yields `.kitty = false` (callers fall back to braille).
|
||||
pub fn detect(io: std.Io, environ_map: *const std.process.Environ.Map) Caps {
|
||||
const is_tty = std.Io.File.stdout().isTty(io) catch false;
|
||||
if (!is_tty) return .{};
|
||||
return .{ .kitty = kittyFromEnv(environ_map) };
|
||||
}
|
||||
|
||||
/// Environment heuristic for kitty-graphics support. Recognizes kitty,
|
||||
/// Ghostty, and WezTerm - the common terminals implementing the
|
||||
/// protocol. (iTerm2 uses a different image protocol and is not matched.)
|
||||
pub fn kittyFromEnv(env: *const std.process.Environ.Map) bool {
|
||||
if (env.get("TERM")) |t| {
|
||||
if (std.mem.indexOf(u8, t, "kitty") != null) return true;
|
||||
}
|
||||
if (env.get("KITTY_WINDOW_ID") != null) return true;
|
||||
if (env.get("GHOSTTY_RESOURCES_DIR") != null) return true;
|
||||
if (env.get("GHOSTTY_BIN_DIR") != null) return true;
|
||||
if (env.get("WEZTERM_EXECUTABLE") != null) return true;
|
||||
if (env.get("WEZTERM_PANE") != null) return true;
|
||||
if (env.get("TERM_PROGRAM")) |p| {
|
||||
if (std.mem.eql(u8, p, "ghostty")) return true;
|
||||
if (std.mem.eql(u8, p, "WezTerm")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "kittyFromEnv: kitty TERM" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM", "xterm-kitty");
|
||||
try testing.expect(kittyFromEnv(&env));
|
||||
}
|
||||
|
||||
test "kittyFromEnv: KITTY_WINDOW_ID, ghostty, wezterm" {
|
||||
{
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM", "xterm-256color");
|
||||
try env.put("KITTY_WINDOW_ID", "1");
|
||||
try testing.expect(kittyFromEnv(&env));
|
||||
}
|
||||
{
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM_PROGRAM", "ghostty");
|
||||
try testing.expect(kittyFromEnv(&env));
|
||||
}
|
||||
{
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("WEZTERM_PANE", "0");
|
||||
try testing.expect(kittyFromEnv(&env));
|
||||
}
|
||||
}
|
||||
|
||||
test "kittyFromEnv: plain xterm / Apple Terminal is not kitty" {
|
||||
var env = std.process.Environ.Map.init(testing.allocator);
|
||||
defer env.deinit();
|
||||
try env.put("TERM", "xterm-256color");
|
||||
try env.put("TERM_PROGRAM", "Apple_Terminal");
|
||||
try testing.expect(!kittyFromEnv(&env));
|
||||
}
|
||||
|
||||
test "Caps defaults to the assumed cell size, no kitty" {
|
||||
const c: Caps = .{};
|
||||
try testing.expect(!c.kitty);
|
||||
try testing.expectEqual(term_graphics.default_cell_w, c.cell_w);
|
||||
try testing.expectEqual(term_graphics.default_cell_h, c.cell_h);
|
||||
}
|
||||
57
src/tui.zig
57
src/tui.zig
|
|
@ -9,7 +9,7 @@ const keybinds = @import("tui/keybinds.zig");
|
|||
const tab_framework = @import("tui/tab_framework.zig");
|
||||
const framework = @import("commands/framework.zig");
|
||||
const theme = @import("tui/theme.zig");
|
||||
const chart = @import("tui/chart.zig");
|
||||
const chart = @import("charts/chart.zig");
|
||||
const input_buffer = @import("tui/input_buffer.zig");
|
||||
pub const PortfolioData = @import("PortfolioData.zig");
|
||||
|
||||
|
|
@ -494,6 +494,12 @@ pub const App = struct {
|
|||
/// deterministic within a single frame and avoids threading `io`
|
||||
/// through pure date-consuming helpers like `positions()`.
|
||||
today: zfin.Date,
|
||||
/// The user's local timezone, resolved once at App init (reads the
|
||||
/// system tz database) and threaded as a value. Used by `getStatus`
|
||||
/// to render the "refreshed at" stamp in local wall-clock time -
|
||||
/// distinct from the portfolio footer's ET market-time stamp.
|
||||
/// Owns memory when resolved from tzinfo; `deinit`'d in `deinitData`.
|
||||
local_tz: zfin.market.TimeZone,
|
||||
config: zfin.Config,
|
||||
svc: *zfin.DataService,
|
||||
keymap: keybinds.KeyMap,
|
||||
|
|
@ -1417,8 +1423,8 @@ pub const App = struct {
|
|||
/// flight, shows the in-progress indicator. Otherwise: a
|
||||
/// user-set message if present, else a dynamic default hint
|
||||
/// (global keys + the active tab's `status_hints`), prefixed
|
||||
/// with a "refreshed Xs ago" readout once a refresh has run.
|
||||
/// Allocated in `arena` for the dynamic forms; the user-set
|
||||
/// with a "refreshed at <local clock>" stamp once a refresh has
|
||||
/// run. Allocated in `arena` for the dynamic forms; the user-set
|
||||
/// buffer is returned by reference.
|
||||
fn getStatus(self: *App, arena: std.mem.Allocator) []const u8 {
|
||||
if (self.refresh_pending) return "Refreshing...";
|
||||
|
|
@ -1426,14 +1432,17 @@ pub const App = struct {
|
|||
const hint = self.buildDefaultStatusHint(arena) catch
|
||||
"h/l tabs | j/k select | / symbol | ? help";
|
||||
if (self.last_refresh_s > 0) {
|
||||
// wall-clock required: per-frame "now" for the relative
|
||||
// "refreshed Xs ago" readout.
|
||||
// wall-clock required: per-frame "now" to decide whether
|
||||
// the refresh stamp needs a date prefix (refreshed on an
|
||||
// earlier local day - the TUI doesn't auto-refresh, so a
|
||||
// stamp can sit on screen across midnight).
|
||||
const now_s = std.Io.Timestamp.now(self.io, .real).toSeconds();
|
||||
var ago_buf: [24]u8 = undefined;
|
||||
const ago = fmt.fmtTimeAgo(&ago_buf, self.last_refresh_s, now_s);
|
||||
if (ago.len > 0) {
|
||||
return std.fmt.allocPrint(arena, "refreshed {s} | {s}", .{ ago, hint }) catch hint;
|
||||
}
|
||||
// Absolute local-time stamp rather than a relative "Xs
|
||||
// ago": with no auto-refresh, a relative readout silently
|
||||
// goes stale while the screen sits idle.
|
||||
var stamp_buf: [32]u8 = undefined;
|
||||
const stamp = zfin.market.fmtStamp(&stamp_buf, self.local_tz, self.last_refresh_s, now_s);
|
||||
return std.fmt.allocPrint(arena, "refreshed {s} | {s}", .{ stamp, hint }) catch hint;
|
||||
}
|
||||
return hint;
|
||||
}
|
||||
|
|
@ -1507,6 +1516,9 @@ pub const App = struct {
|
|||
Module.tab.deinit(state_ptr, self);
|
||||
}
|
||||
self.portfolio.deinit();
|
||||
// Release the resolved local timezone (no-op for the ET
|
||||
// fallback, which borrows a static spec).
|
||||
self.local_tz.deinit();
|
||||
}
|
||||
|
||||
fn reloadPortfolioFile(self: *App) void {
|
||||
|
|
@ -1622,10 +1634,9 @@ pub const App = struct {
|
|||
}
|
||||
|
||||
/// Whether the given tab should be treated as disabled in
|
||||
/// the current App context. All migrated tabs are consulted
|
||||
/// via their framework-contract `isDisabled` hook. (Portfolio
|
||||
/// is the only remaining unmigrated tab; it has no disabled
|
||||
/// predicate today.)
|
||||
/// the current App context. All tabs are consulted via their
|
||||
/// framework-contract `isDisabled` hook (tabs without one
|
||||
/// default to always-enabled).
|
||||
fn isDisabled(self: *App, t: Tab) bool {
|
||||
return self.appPredicate(t, "isDisabled");
|
||||
}
|
||||
|
|
@ -2509,10 +2520,17 @@ pub fn run(
|
|||
|
||||
var app_inst = try allocator.create(App);
|
||||
defer allocator.destroy(app_inst);
|
||||
// Resolve the user's local timezone once here at the top of the
|
||||
// TUI's unit of work, honoring $TZ from the environ map (owned by
|
||||
// main, so it outlives the App and any zone that borrows it).
|
||||
// Falls back to the system default, then ET. deinit'd in deinitData.
|
||||
const tz_override = if (config.environ_map) |em| em.get("TZ") else null;
|
||||
const local_tz = zfin.market.localTimeZone(allocator, io, tz_override);
|
||||
app_inst.* = .{
|
||||
.allocator = allocator,
|
||||
.io = io,
|
||||
.today = today,
|
||||
.local_tz = local_tz,
|
||||
.config = config,
|
||||
.svc = svc,
|
||||
.keymap = keymap,
|
||||
|
|
@ -2537,6 +2555,17 @@ pub fn run(
|
|||
if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| {
|
||||
resolved_pf_paths = rp;
|
||||
} else |_| {}
|
||||
} else {
|
||||
// Explicit-symbol launch (e.g. `zfin AAPL`) skips the full
|
||||
// portfolio load below, but the quote tab (and the 'K' overlay)
|
||||
// still want the curated security name from metadata.srf - same
|
||||
// as the CLI `quote` command. Prime just the classification map
|
||||
// (no price fetch) so classificationMap() resolves the name.
|
||||
if (framework.resolvePatterns(io, allocator, config, portfolio_patterns)) |rp| {
|
||||
var rp_owned = rp;
|
||||
defer rp_owned.deinit();
|
||||
app_inst.portfolio.primeClassificationMap(rp_owned.paths);
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
var resolved_wl: ?zfin.Config.ResolvedPath = null;
|
||||
|
|
|
|||
|
|
@ -330,6 +330,16 @@ pub const tab = struct {
|
|||
|
||||
pub const deactivate = framework.noopDeactivate(State);
|
||||
|
||||
/// Disabled only when the TUI was opened on a specific symbol
|
||||
/// (`zfin AAPL`) with no portfolio loaded: there's nothing to show
|
||||
/// here, so it greys out and becomes unselectable to match the
|
||||
/// other portfolio-backed tabs (analysis, review, projections,
|
||||
/// history). A plain `zfin` launch with no portfolio keeps the tab
|
||||
/// enabled so it can show the welcome screen (`drawWelcomeScreen`).
|
||||
pub fn isDisabled(app: *App) bool {
|
||||
return app.portfolio.file == null and app.has_explicit_symbol;
|
||||
}
|
||||
|
||||
/// Manual refresh (r/F5): re-value the portfolio with live
|
||||
/// intraday quotes, then rebuild the summary. `r` means "give me
|
||||
/// current prices now" - it does NOT force candle work. Candle
|
||||
|
|
@ -377,10 +387,15 @@ pub const tab = struct {
|
|||
// falls back to the candle last close for those.
|
||||
var live = app.svc.loadLiveQuotes(quote_syms.items);
|
||||
defer live.deinit();
|
||||
// wall-clock required: stamp the instant the live quotes were
|
||||
// fetched so the portfolio footer can render a precise
|
||||
// "(as of H:MM PM ET)" stamp via market.fmtClockET.
|
||||
const live_at_s = std.Io.Timestamp.now(app.io, .real).toSeconds();
|
||||
|
||||
_ = app.portfolio.reload(app.today, .{
|
||||
.watchlist_syms = watch_syms.items,
|
||||
.live_quotes = &live,
|
||||
.live_quotes_at_s = live_at_s,
|
||||
}) catch |err| {
|
||||
app.setStatus("Error refreshing portfolio data");
|
||||
std.log.scoped(.tui).warn("portfolio.reload: {t}", .{err});
|
||||
|
|
@ -461,11 +476,6 @@ pub const tab = struct {
|
|||
}
|
||||
}
|
||||
|
||||
/// Portfolio is always enabled (the tab itself; data may be
|
||||
/// empty if no portfolio file is loaded - that's a separate
|
||||
/// concern handled by `drawWelcomeScreen`).
|
||||
pub const isDisabled = framework.alwaysEnabled();
|
||||
|
||||
/// Drop UI state that referenced the previous portfolio.
|
||||
///
|
||||
/// `account_list` holds borrowed strings into the old
|
||||
|
|
@ -1340,6 +1350,34 @@ fn computeFilteredTotals(state: *const State, app: *const App) FilteredTotals {
|
|||
|
||||
// ── Rendering ─────────────────────────────────────────────────
|
||||
|
||||
/// Append the "(as of ...)" footer line describing how current the
|
||||
/// portfolio valuation is. When live intraday quotes were overlaid
|
||||
/// (the TUI refresh path), show a precise ET wall-clock stamp of the
|
||||
/// fetch instant - and flag it when the market was closed at that
|
||||
/// moment, so a weekend/after-hours refresh no longer implies live
|
||||
/// intraday data. Otherwise fall back to the candle close date.
|
||||
fn appendAsOfLine(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), app: *App) !void {
|
||||
const th = app.theme;
|
||||
if (app.portfolio.live_prices_applied) {
|
||||
if (app.portfolio.live_quotes_at_s) |ts| {
|
||||
var clk: [16]u8 = undefined;
|
||||
const stamp = zfin.market.fmtClockET(&clk, ts);
|
||||
const text = if (zfin.market.marketSession(ts) != .open)
|
||||
try std.fmt.allocPrint(arena, " (as of {s}, market closed)", .{stamp})
|
||||
else
|
||||
try std.fmt.allocPrint(arena, " (as of {s})", .{stamp});
|
||||
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
|
||||
} else {
|
||||
// Live overlay applied but no fetch timestamp was threaded
|
||||
// (defensive fallback).
|
||||
try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() });
|
||||
}
|
||||
} else if (app.portfolio.latest_quote_date) |d| {
|
||||
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
|
||||
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
// Modal sub-state takes over the content surface entirely.
|
||||
// Picker overlay replaces the portfolio table while open.
|
||||
|
|
@ -1382,15 +1420,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
const summary_style = if (filtered_gl >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
|
||||
|
||||
if (app.portfolio.live_prices_applied) {
|
||||
// Live overlay applied: values reflect today's intraday
|
||||
// quotes, not the candle close. Static text for now; a
|
||||
// precise "as of HH:MM ET" awaits real tz handling.
|
||||
try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() });
|
||||
} else if (app.portfolio.latest_quote_date) |d| {
|
||||
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
|
||||
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
|
||||
}
|
||||
try appendAsOfLine(arena, &lines, app);
|
||||
// No historical snapshots or net worth when filtered
|
||||
} else {
|
||||
// Unfiltered mode: use portfolio_summary totals directly
|
||||
|
|
@ -1406,15 +1436,7 @@ pub fn drawContent(state: *State, app: *App, arena: std.mem.Allocator, buf: []va
|
|||
try lines.append(arena, .{ .text = summary_text, .style = summary_style });
|
||||
|
||||
// "as of" date indicator
|
||||
if (app.portfolio.live_prices_applied) {
|
||||
// Live overlay applied: values reflect today's intraday
|
||||
// quotes, not the candle close. Static text for now; a
|
||||
// precise "as of HH:MM ET" awaits real tz handling.
|
||||
try lines.append(arena, .{ .text = " (as of intraday quote today)", .style = th.mutedStyle() });
|
||||
} else if (app.portfolio.latest_quote_date) |d| {
|
||||
const asof_text = try std.fmt.allocPrint(arena, " (as of close on {f})", .{d});
|
||||
try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() });
|
||||
}
|
||||
try appendAsOfLine(arena, &lines, app);
|
||||
|
||||
// Net Worth line (only if portfolio has illiquid assets)
|
||||
if (app.portfolio.file) |pf| {
|
||||
|
|
@ -1888,6 +1910,17 @@ pub fn buildWelcomeScreenLines(
|
|||
/// Inactive tabs stay in `loaded = false` and lazy-rebuild on
|
||||
/// next switch.
|
||||
pub fn reloadPortfolioFile(state: *State, app: *App) void {
|
||||
// `paths` is non-empty once a portfolio has been resolved - either
|
||||
// by a normal load, OR by the symbol-mode classification-map prime
|
||||
// (see PortfolioData.primeClassificationMap). In that second case
|
||||
// `file` is still null (no portfolio actually loaded yet), and the
|
||||
// `pd.reload` below runs the FIRST full load. That's intentional:
|
||||
// `R` lets you "upgrade" a symbol-only session (`zfin -s AAPL`)
|
||||
// into the full portfolio view, lighting up the portfolio-scoped
|
||||
// tabs. Only when nothing has been resolved at all (no portfolio
|
||||
// file on disk) is `paths` empty and there's genuinely nothing to
|
||||
// do. Do NOT tighten this to `file == null` - that would break the
|
||||
// upgrade path.
|
||||
if (app.portfolio.paths.len == 0) {
|
||||
app.setStatus("No portfolio file to reload");
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ const fmt = @import("../format.zig");
|
|||
const Money = @import("../Money.zig");
|
||||
const theme = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const projection_chart = @import("projection_chart.zig");
|
||||
const forecast_chart = @import("forecast_chart.zig");
|
||||
const projection_chart = @import("../charts/projection_chart.zig");
|
||||
const forecast_chart = @import("../charts/forecast_chart.zig");
|
||||
const forecast = @import("../analytics/forecast_evaluation.zig");
|
||||
const imported = @import("../data/imported_values.zig");
|
||||
const milestones = @import("../analytics/milestones.zig");
|
||||
|
|
@ -159,7 +159,7 @@ pub const State = struct {
|
|||
/// view (`.bands`) renders the standard percentile-band chart
|
||||
/// + projection report. `.convergence` and `.return_backtest`
|
||||
/// pull data from `imported_values.srf` and render
|
||||
/// forecast-evaluation charts via `tui/forecast_chart.zig`.
|
||||
/// forecast-evaluation charts via `charts/forecast_chart.zig`.
|
||||
/// Toggled by the `c` and `r` keybinds; toggling either
|
||||
/// clears the other (mutually exclusive).
|
||||
sub_view: SubView = .bands,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const zfin = @import("../root.zig");
|
|||
const fmt = @import("../format.zig");
|
||||
const Money = @import("../Money.zig");
|
||||
const theme = @import("theme.zig");
|
||||
const chart = @import("chart.zig");
|
||||
const chart = @import("../charts/chart.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const framework = @import("tab_framework.zig");
|
||||
|
||||
|
|
@ -189,14 +189,14 @@ pub const tab = struct {
|
|||
const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset;
|
||||
if (content_row != tf_row) return false;
|
||||
|
||||
// Layout: " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)"
|
||||
// Layout: " Chart: [3M] 6M YTD 1Y 3Y 5Y ([ ] to change)"
|
||||
// Prefix " Chart: " is 9 chars. Each timeframe label takes
|
||||
// `label_len + 2` (brackets/spaces around the label) + 1 (gap).
|
||||
const col: usize = @intCast(mouse.col);
|
||||
const prefix_len: usize = 9;
|
||||
if (col < prefix_len) return false;
|
||||
|
||||
const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||
const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||
var x: usize = prefix_len;
|
||||
for (timeframes) |tf| {
|
||||
const lbl_len = tf.label().len;
|
||||
|
|
@ -273,28 +273,28 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
else
|
||||
app.symbol;
|
||||
|
||||
// Symbol + price header
|
||||
// Symbol + price header. The headline "Change" reflects the
|
||||
// selected chart period (the chart's left edge -> now); the less
|
||||
// relevant 1-day change lives in the detail section below the chart.
|
||||
const cur_tf = app.states.quote.chart.timeframe;
|
||||
const cur_price: ?f64 = if (app.states.quote.live) |q| q.close else if (c.len > 0) c[c.len - 1].close else null;
|
||||
|
||||
if (app.states.quote.live) |q| {
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ sym_label, q.close });
|
||||
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||
if (q.previous_close > 0) {
|
||||
const change = q.close - q.previous_close;
|
||||
const pct = (change / q.previous_close) * 100.0;
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||
}
|
||||
} else if (c.len > 0) {
|
||||
const last = c[c.len - 1];
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ sym_label, last.close });
|
||||
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||
if (c.len >= 2) {
|
||||
const prev_close = c[c.len - 2].close;
|
||||
const change = last.close - prev_close;
|
||||
const pct = (change / prev_close) * 100.0;
|
||||
}
|
||||
|
||||
if (cur_price) |price| {
|
||||
if (fmt.windowChange(c, cur_tf.tradingDays(), price)) |wc| {
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||
var lbl_buf: [24]u8 = undefined;
|
||||
const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{cur_tf.label()}) catch "Change:";
|
||||
const change_style = if (wc.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} {s}", .{ lbl, fmt.fmtPriceChange(&chg_buf, wc.change, wc.pct) }), .style = change_style });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,7 +305,7 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
const prefix = " Chart: ";
|
||||
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
|
||||
tf_pos += prefix.len;
|
||||
const timeframes = [_]chart.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||
const timeframes = [_]chart.Timeframe{ .@"3M", .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||
for (timeframes) |tf| {
|
||||
const lbl = tf.label();
|
||||
if (tf == app.states.quote.chart.timeframe) {
|
||||
|
|
@ -388,11 +388,15 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
// Free old cache if it exists
|
||||
app.states.quote.chart.freeCache(app.allocator);
|
||||
|
||||
// Compute and cache new indicators
|
||||
const new_cache = chart.computeIndicators(
|
||||
// Compute and cache new indicators. Use the warmup variant so
|
||||
// the Bollinger bands / RSI are computed over an extra lookback
|
||||
// beyond the timeframe window and stay valid from the first
|
||||
// *visible* candle - no warm-up gap at the chart's left edge.
|
||||
const new_cache = chart.computeIndicatorsWarmup(
|
||||
app.allocator,
|
||||
c,
|
||||
app.states.quote.chart.timeframe,
|
||||
app.states.quote.chart.timeframe.tradingDays(),
|
||||
20,
|
||||
) catch |err| {
|
||||
app.states.quote.chart.dirty = false;
|
||||
var err_buf: [128]u8 = undefined;
|
||||
|
|
@ -570,7 +574,10 @@ fn drawWithKittyChart(app: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, wi
|
|||
const price = if (quote_data) |q| q.close else latest.close;
|
||||
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
|
||||
|
||||
try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close);
|
||||
// `cur_tf` (the selected timeframe) is computed once at the
|
||||
// top of this function; the window change reuses it.
|
||||
const period = fmt.windowChange(c, cur_tf.tradingDays(), price);
|
||||
try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close, period, cur_tf.label());
|
||||
|
||||
// Write detail lines into the buffer below the image
|
||||
const detail_buf_start = detail_start_row * @as(usize, width);
|
||||
|
|
@ -664,7 +671,7 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|||
{
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change (1D): {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
|
||||
}
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
|
@ -681,7 +688,11 @@ fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
|||
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
|
||||
const latest = c[c.len - 1];
|
||||
|
||||
try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close);
|
||||
// The braille fallback charts the last 60 candles; the window
|
||||
// change measures against that same span.
|
||||
const period = fmt.windowChange(c, 60, price);
|
||||
|
||||
try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close, period, "60D");
|
||||
|
||||
// Braille sparkline chart of recent 60 trading days
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
|
@ -737,25 +748,35 @@ fn buildDetailColumns(
|
|||
quote_data: ?zfin.Quote,
|
||||
price: f64,
|
||||
prev_close: f64,
|
||||
period: ?fmt.PctChange,
|
||||
period_label: []const u8,
|
||||
) !void {
|
||||
const th = app.theme;
|
||||
var vol_buf: [32]u8 = undefined;
|
||||
|
||||
// Column 1: Price/OHLCV
|
||||
var col1 = Column.init();
|
||||
col1.width = 30;
|
||||
col1.width = 36;
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {f}", .{latest.date}), th.contentStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {f}", .{Money.from(price)}), th.contentStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
|
||||
if (prev_close > 0) {
|
||||
const change = price - prev_close;
|
||||
const pct = (change / prev_close) * 100.0;
|
||||
if (fmt.pctChange(price, prev_close)) |dc| {
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style);
|
||||
const change_style = if (dc.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " {s:<14}{s}", .{ "Change (1D):", fmt.fmtPriceChange(&chg_buf, dc.change, dc.pct) }), change_style);
|
||||
}
|
||||
// Change over the displayed chart window (the chart's left edge ->
|
||||
// now), labeled with the window span so it reads distinctly from the
|
||||
// 1-day change above. Null when there's no meaningful window.
|
||||
if (period) |pc| {
|
||||
var wchg_buf: [64]u8 = undefined;
|
||||
var lbl_buf: [24]u8 = undefined;
|
||||
const lbl = std.fmt.bufPrint(&lbl_buf, "Change ({s}):", .{period_label}) catch "Change:";
|
||||
const wstyle = if (pc.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " {s:<14}{s}", .{ lbl, fmt.fmtPriceChange(&wchg_buf, pc.change, pc.pct) }), wstyle);
|
||||
}
|
||||
|
||||
// Columns 2-4: ETF profile (only for actual ETFs)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue