add new review command/tab

This commit is contained in:
Emil Lerch 2026-06-05 13:16:25 -07:00
parent 79ffbeb078
commit 6fbbf48486
Signed by: lobo
GPG key ID: A7B62D657EF764F8
18 changed files with 4672 additions and 144 deletions

View file

@ -1,6 +1,5 @@
[tools]
prek = "0.4.1"
pre-commit = "4.6.0"
zig = "0.16.0"
zls = "0.16.0"
"ubi:DonIsaac/zlint" = "0.7.9"

View file

@ -423,7 +423,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
- **Negative cache entries.** When a provider fetch fails permanently (not rate-limited), a negative cache entry is written to prevent repeated retries for nonexistent symbols.
- **TUI tab framework.** The TUI is a registry-driven framework with eight tabs sharing infrastructure. The single source of truth is `tab_modules` in `src/tui.zig` (an anonymous struct literal mapping tag → module). Everything else — the `Tab` enum, tab-bar labels, the `TabStates` aggregator, key/mouse dispatch, help overlay rows, status-line hints, draw routing — is derived from `tab_modules` at comptime. Each tab module conforms to a contract documented in `src/tui/tab_framework.zig`: declare an `Action` enum, a `State` struct, a `tab` namespace with required hooks, and exactly one of `buildStyledLines` or `drawContent`. A comptime validator (`tab_framework.validateTabModule`) checks every registered module at build time, including hook signatures and a "tabs cannot bind globally-bound keys" rule. See "Adding a new TUI tab" below for the workflow.
- **TUI tab framework.** The TUI is a registry-driven framework with nine tabs sharing infrastructure. The single source of truth is `tab_modules` in `src/tui.zig` (an anonymous struct literal mapping tag → module). Everything else — the `Tab` enum, tab-bar labels, the `TabStates` aggregator, key/mouse dispatch, help overlay rows, status-line hints, draw routing — is derived from `tab_modules` at comptime. Each tab module conforms to a contract documented in `src/tui/tab_framework.zig`: declare an `Action` enum, a `State` struct, a `tab` namespace with required hooks, and exactly one of `buildStyledLines` or `drawContent`. A comptime validator (`tab_framework.validateTabModule`) checks every registered module at build time, including hook signatures and a "tabs cannot bind globally-bound keys" rule. See "Adding a new TUI tab" below for the workflow.
### Module map
@ -435,7 +435,7 @@ User input → main.zig (CLI dispatch) or tui.zig (TUI event loop)
| `src/data/` | Static / semi-static datasets: `imported_values.zig` (back-history values), `shiller.zig` (S&P + CPI series, Shiller's data set), `staleness.zig` (account-cadence checks). `ie_data.csv` is the raw Shiller dataset. |
| `src/views/` | View models producing renderer-agnostic display data with `StyleIntent`: `compare.zig`, `history.zig`, `portfolio_sections.zig`, `projections.zig`. |
| `src/commands/` | CLI command handlers: each has a `pub fn run(...)` entry point. Signatures vary by command's needs (some take `as_of`, `now_s`, `args`, etc.); `common.zig` has shared CLI helpers and color constants. See "Adding a new CLI command" below. |
| `src/tui/` | Eight-tab interactive TUI. Each tab is a separate file conforming to the framework contract documented in `tab_framework.zig`: `portfolio_tab.zig`, `quote_tab.zig`, `performance_tab.zig`, `options_tab.zig`, `earnings_tab.zig`, `analysis_tab.zig`, `history_tab.zig`, `projections_tab.zig`. Plus `keybinds.zig` (configurable input + scoped bindings), `theme.zig` (configurable colors), `chart.zig` (Kitty graphics chart renderer), `projection_chart.zig` (percentile-band overlay), `input_buffer.zig` (modal text-input state machine). The `App` orchestrator lives in the parent `src/tui.zig`. |
| `src/tui/` | Nine-tab interactive TUI. Each tab is a separate file conforming to the framework contract documented in `tab_framework.zig`: `portfolio_tab.zig`, `analysis_tab.zig`, `review_tab.zig`, `projections_tab.zig`, `history_tab.zig`, `quote_tab.zig`, `performance_tab.zig`, `earnings_tab.zig`, `options_tab.zig`. Plus `keybinds.zig` (configurable input + scoped bindings), `theme.zig` (configurable colors), `chart.zig` (Kitty graphics chart renderer), `projection_chart.zig` (percentile-band overlay), `input_buffer.zig` (modal text-input state machine). The `App` orchestrator lives in the parent `src/tui.zig`. |
| `src/cache/` | `store.zig`: SRF cache read/write with TTL freshness checks. |
| `src/net/` | `http.zig`: HTTP client with retry and error classification. `RateLimiter.zig`: token-bucket rate limiter. |
| `build/` | Build-time support: `Coverage.zig` (kcov integration), `download_kcov.zig` (kcov binary fetcher), `gen_shiller.zig` (CSV → comptime data converter), `bcov.css` (kcov report styling). |

138
TODO.md
View file

@ -5,6 +5,144 @@ ordered roughly by priority within each section. Priority labels
(`HIGH` / `MEDIUM` / `LOW`) mark items that deserve explicit
ranking; unlabeled items are "someday, if the mood strikes."
## Review tab: cursor + symbol selection + drill-down — priority MEDIUM
The review tab is currently a static table — you can sort it but
not select a row. Common workflow: see something interesting on
the review tab (e.g. NKE has the worst trailing returns), want to
jump straight into the per-symbol detail tabs (performance, quote,
options) for it. Today that takes either:
- typing `/<symbol>` to set the active symbol manually, or
- switching to portfolio tab, finding the row, pressing `s` /
space / left-click on it, then switching back.
Both are small but noticeable papercuts when scanning the review
table.
### What to add
Mirror the portfolio tab's pattern (`src/tui/portfolio_tab.zig`):
- `State.cursor: usize` — selected row index.
- `j`/`k` and arrow keys move the cursor (already routed by the
framework's `onCursorMove` hook; opt in by declaring it on the
tab).
- Mouse wheel scrolls (already handled by App when the tab opts
out via `handleMouse` returning false on wheel events).
- Mouse click on a data row sets the cursor to that row.
- Cursor row gets `selectStyle` highlight in the row's
`StyleSpan` set so it visibly stands out.
- Active-symbol indicator: rows whose `symbol` matches `app.symbol`
get an asterisk or similar marker (matches portfolio tab's
star convention).
- Press `s` (or space, or Enter) to set `app.symbol` to the
cursor row's symbol — same `select_symbol` action portfolio tab
binds. The framework validator already prevents tab-local
bindings from colliding with global keys, so reusing `s`/space
is fine because portfolio tab does it too.
### Drill-down navigation
A natural extension once selection works: a hotkey that both
selects the symbol AND switches to a per-symbol tab (performance
is the obvious target since that's the deep-dive surface). Maybe
`Enter` with a row selected → set symbol + jump to performance
tab. Compare: portfolio tab's Enter is "expand/collapse"; review
rows don't expand, so Enter is free.
### Tests
- Cursor moves on j/k/arrows, clamps at edges.
- Click on row N sets cursor to N.
- `select_symbol` action sets `app.symbol` and triggers a
`loadTabData()` if a downstream tab is active.
- Active-symbol asterisk renders for the row matching `app.symbol`.
## TUI: share candle/dividend maps across tabs — priority MEDIUM
`App.ensurePortfolioDataLoaded` builds a complete per-symbol
`candle_map` via `buildPortfolioData`, uses it once to compute
historical-snapshot values, then **frees it** at
`src/tui.zig:1404-1406`. Every per-position TUI tab that
subsequently needs candles (`review`, `performance`, parts of
`portfolio` and `quote`) re-reads them from the SRF cache via
`getCachedCandles` — ~27 redundant reads per tab activation on
a 27-symbol portfolio, each running its own SRF iterator pass.
Most visible in debug builds (~2s tab activation); release
mode is sub-second but still measurable on first switch.
### Fix sketch
Promote `candle_map` and add a `dividend_map` to fields on
`App.portfolio.PortfolioData`. Tabs read from
`app.portfolio.candle_map` / `app.portfolio.dividend_map`
instead of re-fetching. Lifetime tied to the existing
`summary` ownership: cleared atomically on portfolio reload,
freed once in `PortfolioData.deinit`.
The review tab's `State.dividend_map` field is removed —
that data lives on App now.
### Loading strategy options
**Eager.** Populate both maps inside
`ensurePortfolioDataLoaded` so they're ready when the first
per-symbol tab activates. Pros: tab switches are always
instant. Cons: pays the dividend-cache read cost (~27 SRF
reads) at TUI startup even if the user never opens a tab
that needs them.
**Lazy.** App exposes `ensureDividendMap()` (parallel to
`ensureAccountMap`); first tab to need dividends pays the
load. Pros: no startup cost for users who don't open
review/performance/etc. Cons: first review-tab activation
still slow.
**Async (recommended middle ground).** On TUI startup, spawn
a background task using Zig 0.16.0's `Io` async to populate
both maps. Tabs read through a synchronization wrapper —
something like:
```zig
pub const PortfolioCache = struct {
candle_map: ?std.StringHashMap([]const Candle) = null,
dividend_map: ?std.StringHashMap([]const Dividend) = null,
ready: std.Thread.Semaphore, // or io.Async equivalent
pub fn waitReady(self: *PortfolioCache) void { ... }
};
```
Tabs that need the maps call `waitReady()` (cheap when
already loaded; brief block on first call if the background
task hasn't finished). Uses Zig 0.16's `io` async layer so
we don't manage thread lifecycle directly. Pros: zero
perceived startup cost AND zero perceived tab-switch cost
for typical workflows. Cons: more design work; need to
handle the failure case (background task errored — fall
back to lazy load).
### Other call sites to audit
- `src/tui/portfolio_tab.zig:1756` — re-reads cached candles
to compute the latest-quote-date footer. Re-route through
the shared map.
- `src/tui/quote_tab.zig` and `src/tui/performance_tab.zig`
these are intentionally per-symbol-narrow (single ticker
detail views), so they probably stay on `getTrailingReturns`
but could short-circuit when the candles are already in
the shared map.
### Tests
- Lifetime test: portfolio reload clears both maps before
the next load assigns new ones; no use-after-free.
- Async path: failure in the background task surfaces as a
visible status message but doesn't break tab activation
(lazy fallback still works).
## Projections: future enhancements
- **Configurable return cap per position — priority MEDIUM.**

View file

@ -210,6 +210,39 @@ pub fn yearsBetween(from: Date, to: Date) f64 {
return @as(f64, @floatFromInt(to.days - from.days)) / 365.25;
}
/// Pack year + month into a single comparable integer
/// (`year × 100 + month`, e.g. 2026-03 202603). Used by
/// month-end resampling code in `analytics/risk.zig` and
/// `analytics/portfolio_risk.zig` as a hash-map / monotonic
/// boundary key. Defensively clamps negative years to 0 real
/// candle data never has them, but the function would otherwise
/// panic on underflow during the `i16 u32` cast.
pub fn yearMonth(self: Date) u32 {
const y_raw = self.year();
const y: u32 = if (y_raw < 0) 0 else @intCast(y_raw);
const m: u32 = self.month();
return y * 100 + m;
}
/// Calendar-month distance between two dates as a signed integer.
/// Positive when `to` is after `from`, negative when before, zero
/// when both fall in the same calendar month.
///
/// Computed from the year/month fields only day-of-month is
/// ignored. So `monthsBetween(2024-01-31, 2024-02-01) == 1`
/// despite being one calendar day apart, and
/// `monthsBetween(2024-01-01, 2024-01-31) == 0`. That's the
/// correct shape for month-bucket arithmetic, where the consumer
/// wants "how many distinct calendar-month buckets separate
/// these dates."
pub fn monthsBetween(from: Date, to: Date) i32 {
const from_y: i32 = from.year();
const from_m: i32 = from.month();
const to_y: i32 = to.year();
const to_m: i32 = to.month();
return (to_y - from_y) * 12 + (to_m - from_m);
}
/// Whole years between two dates, floored to a non-negative
/// `u16`. Returns 0 when `to` is at or before `from`. Built on
/// `yearsBetween` (365.25-day approximation) sufficient for
@ -508,6 +541,35 @@ test "yearsBetween" {
try std.testing.expectApproxEqAbs(@as(f64, 0.0), Date.yearsBetween(a, a), 0.001);
}
test "yearMonth packs as YYYYMM" {
try std.testing.expectEqual(@as(u32, 202603), Date.fromYmd(2026, 3, 16).yearMonth());
try std.testing.expectEqual(@as(u32, 202412), Date.fromYmd(2024, 12, 31).yearMonth());
try std.testing.expectEqual(@as(u32, 100001), Date.fromYmd(1000, 1, 1).yearMonth());
}
test "monthsBetween: same calendar month is zero" {
const a = Date.fromYmd(2024, 1, 1);
const b = Date.fromYmd(2024, 1, 31);
try std.testing.expectEqual(@as(i32, 0), Date.monthsBetween(a, b));
try std.testing.expectEqual(@as(i32, 0), Date.monthsBetween(b, a));
}
test "monthsBetween: forward and backward" {
const a = Date.fromYmd(2024, 1, 15);
const b = Date.fromYmd(2024, 2, 1);
try std.testing.expectEqual(@as(i32, 1), Date.monthsBetween(a, b));
try std.testing.expectEqual(@as(i32, -1), Date.monthsBetween(b, a));
}
test "monthsBetween: across year boundary" {
const a = Date.fromYmd(2023, 11, 15);
const b = Date.fromYmd(2024, 2, 1);
try std.testing.expectEqual(@as(i32, 3), Date.monthsBetween(a, b));
const c = Date.fromYmd(2026, 3, 1);
const d = Date.fromYmd(2014, 3, 1);
try std.testing.expectEqual(@as(i32, 144), Date.monthsBetween(d, c)); // 12 years × 12
}
test "wholeYearsBetween" {
const a = Date.fromYmd(2024, 1, 1);
// 2024-01-01 2025-01-01 is 366 days (2024 is a leap year).

View file

@ -492,6 +492,20 @@ pub const mid_cash_equivalents: []const u8 = "Cash & Equivalents";
pub const mid_derivatives: []const u8 = "Derivatives";
pub const mid_other: []const u8 = "Other";
/// Display-friendly abbreviations for sector labels that don't fit
/// cleanly in narrow columns. Returns the input unchanged when no
/// abbreviation is registered for it; consumers that need a fixed
/// width should also pass the result through `format.truncateToCols`.
///
/// Single source of truth for both the analysis tab's sector
/// breakdown rows and the review tab's per-holding sector cells.
/// Add new abbreviations here when a sector label keeps overflowing
/// the columns it lives in.
pub fn abbreviateSector(s: []const u8) []const u8 {
if (std.mem.eql(u8, s, "Communication Services")) return "Comm. Services";
return s;
}
/// Map a sector string through the chosen granularity. Returns
/// a static literal (or, at fine granularity, the input slice
/// itself) suitable for use as a stable HashMap key.
@ -2069,3 +2083,11 @@ test "analyzePortfolio: sector wins over asset_class when both present" {
try std.testing.expectEqualStrings(bucket_fixed_income, result.asset_category[0].label);
try std.testing.expectApproxEqAbs(@as(f64, 100_000), result.asset_category[0].value, 1.0);
}
test "abbreviateSector: known long labels collapse, others pass through" {
try std.testing.expectEqualStrings("Comm. Services", abbreviateSector("Communication Services"));
try std.testing.expectEqualStrings("Technology", abbreviateSector("Technology"));
try std.testing.expectEqualStrings("Bonds", abbreviateSector("Bonds"));
try std.testing.expectEqualStrings("Equity / Corporate", abbreviateSector("Equity / Corporate"));
try std.testing.expectEqualStrings("", abbreviateSector(""));
}

View file

@ -0,0 +1,704 @@
//! True correlation-aware portfolio risk via synthetic-series construction.
//!
//! `analytics/risk.zig` computes per-symbol risk metrics: vol, Sharpe, max
//! drawdown over 1Y/3Y/5Y/10Y windows derived from monthly returns. That's
//! the right shape for individual holdings, but the weighted average of
//! per-symbol vols is NOT the same as the portfolio's true vol the
//! diversification benefit (correlation < 1 between holdings) means the
//! portfolio-level number is typically 2040% lower than the weighted
//! average for a real diversified portfolio.
//!
//! This module builds the correct number. For each window:
//!
//! 1. Resample each holding's daily candles to month-end closes.
//! 2. Identify the set of months covered by ALL participating holdings.
//! 3. For each covered month, compute portfolio_return_t =
//! Σ wᵢ · position_return_i_t with weights renormalized over
//! participating holdings.
//! 4. Run the same volatility / Sharpe / max-drawdown math on the
//! synthetic monthly-return series.
//!
//! ## Reweight policy (per-window dynamic)
//!
//! A 2024-IPO position drops out of the 10Y window but participates fully
//! in the 3Y window. We renormalize weights independently per window so
//! every window gets the most-honest representation possible. When that
//! happens, the corresponding `reweight_flags` field is set so the renderer
//! can mark the totals-row cell with a reweight asterisk.
//!
//! ## Single window: 5Y for max drawdown
//!
//! The `review` view shows MaxDD at 5Y only (captures the 2022 bear market
//! without the 2020 COVID drawdown flooding every row), so this module
//! exposes `maxdd_5y` rather than max-drawdown at all windows.
const std = @import("std");
const Date = @import("../Date.zig");
const Candle = @import("../models/candle.zig").Candle;
const risk = @import("risk.zig");
/// Per-window flags marking metrics that required holding-dropout
/// renormalization. Set when at least one position lacked candle coverage
/// for that window. Renderers append a `*` to the corresponding cell and
/// emit a footnote.
///
/// Plain (not packed) struct so individual fields can be addressed by
/// pointer where it's ergonomic; the wire size of nine bools isn't
/// performance-sensitive.
pub const ReweightFlags = struct {
vol_3y: bool = false,
vol_10y: bool = false,
sharpe_3y: bool = false,
sharpe_10y: bool = false,
maxdd_5y: bool = false,
return_1y: bool = false,
return_3y: bool = false,
return_5y: bool = false,
return_10y: bool = false,
};
/// True portfolio-level risk metrics from synthetic series construction.
/// Each `?f64` is null when no synthetic series could be constructed (no
/// participating holdings, or fewer than 12 monthly returns).
pub const SyntheticRisk = struct {
vol_3y: ?f64 = null,
vol_10y: ?f64 = null,
sharpe_3y: ?f64 = null,
sharpe_10y: ?f64 = null,
maxdd_5y: ?f64 = null,
/// Synthesized portfolio annualized return over the trailing window
/// (CAGR derived from the geometric compound of monthly returns).
/// The 1Y figure is the cumulative return (no annualization needed
/// for a 1-year window); 3Y/5Y/10Y are annualized.
return_1y: ?f64 = null,
return_3y: ?f64 = null,
return_5y: ?f64 = null,
return_10y: ?f64 = null,
reweight_flags: ReweightFlags = .{},
};
/// One position's contribution to the synthetic series. `candles` are
/// borrowed; the caller retains ownership for the duration of the call.
pub const PositionCandles = struct {
symbol: []const u8,
/// Sorted daily candles. Empty slice = the position has no candle
/// data at all (drops out of every window).
candles: []const Candle,
/// Position weight in the portfolio (market_value / total_value).
/// Must be non-negative; zero-weight positions are skipped entirely.
weight: f64,
};
/// Compute true portfolio-level risk metrics for the standard windows.
/// Iterates `positions`, derives per-position monthly return series,
/// builds a weighted synthetic series per window with dropout-and-
/// renormalize, and runs the same monthly-returns math `risk.zig` uses
/// per-symbol. `as_of` is the reference date (typically today) windows
/// extend backward from there using calendar-year math.
pub fn syntheticPortfolioRisk(
allocator: std.mem.Allocator,
positions: []const PositionCandles,
as_of: Date,
) !SyntheticRisk {
if (positions.len == 0) return .{};
// Months count from the synthesized series. We need 12+ for vol/Sharpe;
// for a clean 10Y window plus a few months of slack we cap at 130.
const max_months = 130;
var result: SyntheticRisk = .{};
// Each window is computed independently different windows include
// different holdings (newer positions drop out of longer windows).
const window_specs = [_]struct {
years: u16,
out_vol: ?*?f64,
out_sharpe: ?*?f64,
out_maxdd: ?*?f64,
out_total_return: ?*?f64,
flag_vol: ?*bool,
flag_sharpe: ?*bool,
flag_maxdd: ?*bool,
flag_total_return: ?*bool,
}{
.{
.years = 1,
.out_vol = null,
.out_sharpe = null,
.out_maxdd = null,
.out_total_return = &result.return_1y,
.flag_vol = null,
.flag_sharpe = null,
.flag_maxdd = null,
.flag_total_return = &result.reweight_flags.return_1y,
},
.{
.years = 3,
.out_vol = &result.vol_3y,
.out_sharpe = &result.sharpe_3y,
.out_maxdd = null,
.out_total_return = &result.return_3y,
.flag_vol = &result.reweight_flags.vol_3y,
.flag_sharpe = &result.reweight_flags.sharpe_3y,
.flag_maxdd = null,
.flag_total_return = &result.reweight_flags.return_3y,
},
.{
.years = 5,
.out_vol = null,
.out_sharpe = null,
.out_maxdd = &result.maxdd_5y,
.out_total_return = &result.return_5y,
.flag_vol = null,
.flag_sharpe = null,
.flag_maxdd = &result.reweight_flags.maxdd_5y,
.flag_total_return = &result.reweight_flags.return_5y,
},
.{
.years = 10,
.out_vol = &result.vol_10y,
.out_sharpe = &result.sharpe_10y,
.out_maxdd = null,
.out_total_return = &result.return_10y,
.flag_vol = &result.reweight_flags.vol_10y,
.flag_sharpe = &result.reweight_flags.sharpe_10y,
.flag_maxdd = null,
.flag_total_return = &result.reweight_flags.return_10y,
},
};
for (window_specs) |spec| {
const synthesized = try synthesizeWindow(allocator, positions, as_of, spec.years, max_months);
defer if (synthesized.monthly_returns) |mr| allocator.free(mr);
if (synthesized.monthly_returns) |mr| {
// Total compound return for ALL windows that asked for it
// (1Y/3Y/5Y/10Y). Computed regardless of whether
// there are 12+ months even an under-12-month window
// can produce a meaningful compound if every month is
// present, but for shape consistency with vol/Sharpe we
// require 12 months for total return at multi-year windows
// and accept 12+ for 1Y. (Annualization isn't done here;
// the renderer reports total return as the cumulative
// monthly compound, matching how Morningstar quotes
// 1Y trailing returns.)
//
// Practical guard: require at least as many months as the
// window's years*10 to suppress totally-undercovered series.
const min_months_required: usize = @as(usize, spec.years) * 10;
if (mr.len >= min_months_required) {
if (spec.out_total_return) |out| {
var compound: f64 = 1.0;
for (mr) |r| compound *= (1.0 + r);
const total = compound - 1.0;
// Annualize for multi-year windows so the totals
// row is comparable to per-position annualized
// trailing returns. 1Y stays as cumulative over
// a 1-year window the cumulative IS the annual.
const annualized = if (spec.years > 1) blk: {
const years_f: f64 = @floatFromInt(spec.years);
break :blk std.math.pow(f64, 1.0 + total, 1.0 / years_f) - 1.0;
} else total;
out.* = annualized;
if (spec.flag_total_return) |fp| fp.* = synthesized.reweighted;
}
}
if (mr.len >= 12) {
const window_end = as_of;
const window_start = window_end.subtractYears(spec.years);
const rfr = risk.avgRiskFreeRateForRange(window_start, window_end);
const stats = risk.statsFromMonthlyReturns(mr, rfr);
if (spec.out_vol) |out| {
out.* = stats.volatility;
if (spec.flag_vol) |fp| fp.* = synthesized.reweighted;
}
if (spec.out_sharpe) |out| {
out.* = stats.sharpe;
if (spec.flag_sharpe) |fp| fp.* = synthesized.reweighted;
}
if (spec.out_maxdd) |out| {
out.* = stats.max_drawdown;
if (spec.flag_maxdd) |fp| fp.* = synthesized.reweighted;
}
}
}
}
return result;
}
const SynthesizedSeries = struct {
/// Synthetic monthly returns. `null` when no series could be built
/// (no participating holdings or insufficient overlap). Caller frees.
monthly_returns: ?[]f64,
/// True iff at least one position was dropped from this window
/// (lacked candle coverage), forcing a weight renormalization.
reweighted: bool,
};
/// Build the weighted synthetic monthly-return series for one window.
fn synthesizeWindow(
allocator: std.mem.Allocator,
positions: []const PositionCandles,
as_of: Date,
years: u16,
comptime max_months: usize,
) !SynthesizedSeries {
const window_end = as_of;
const window_start = window_end.subtractYears(years);
// First pass: figure out which positions can participate. A
// participating position has candles whose first date is at most
// 45 days after window_start (matching `risk.zig`'s freshness
// tolerance) and at least one candle on or before window_end.
var participants_buf: [256]usize = undefined;
var n_participants: usize = 0;
var dropped = false;
for (positions, 0..) |p, i| {
if (p.weight <= 0) continue; // skip zero-weight positions silently
if (p.candles.len == 0) {
dropped = true;
continue;
}
// First candle must be reasonably close to the window start.
const first = p.candles[0].date;
if (first.days > window_start.days + 45) {
dropped = true;
continue;
}
// Need at least one candle within the window.
const last = p.candles[p.candles.len - 1].date;
if (last.days < window_start.days) {
dropped = true;
continue;
}
if (n_participants >= participants_buf.len) {
// Hard cap: portfolios with >256 positions just don't fit
// in our scratch buffer. Returning what we have is fine
// this is an extreme edge case for personal-portfolio use.
break;
}
participants_buf[n_participants] = i;
n_participants += 1;
}
if (n_participants == 0) {
return .{ .monthly_returns = null, .reweighted = dropped };
}
// Renormalize weights across participants.
var total_weight: f64 = 0;
for (participants_buf[0..n_participants]) |idx| total_weight += positions[idx].weight;
if (total_weight <= 0) return .{ .monthly_returns = null, .reweighted = dropped };
// Total months in `[window_start, window_end]`. We pre-allocate a
// (n_participants × n_months_total) prices grid, with NaN sentinels
// marking months a position lacked data for.
const months_diff = Date.monthsBetween(window_start, window_end);
if (months_diff < 1) return .{ .monthly_returns = null, .reweighted = dropped };
const n_months_total: usize = @intCast(months_diff + 1);
if (n_months_total > max_months) {
// If a caller asks for an absurd window, cap at max_months from
// the end. This keeps the math correct for the supported windows.
// Practically only matters if `years` is huge.
return .{ .monthly_returns = null, .reweighted = dropped };
}
// Prices[participant][month_index]. NaN sentinel = no data that month.
var prices = try allocator.alloc(f64, n_participants * n_months_total);
defer allocator.free(prices);
for (prices) |*p| p.* = std.math.nan(f64);
for (participants_buf[0..n_participants], 0..) |orig_idx, p_idx| {
const cand = positions[orig_idx].candles;
// Walk candles, recording the LAST close in each month bucket
// that falls within the window. We use yearMonth() for the
// month-boundary comparison (cheap integer compare) and
// Date.monthsBetween for the slot index into `prices`.
var prev_ym: u32 = 0;
var prev_date: Date = window_start;
var last_close: f64 = 0;
var have_any = false;
for (cand) |c| {
if (c.date.days < window_start.days) {
// Before window: still track the running last-close,
// but only emit it once we cross into the window.
last_close = c.adj_close;
prev_ym = c.date.yearMonth();
prev_date = c.date;
have_any = true;
continue;
}
if (c.date.days > window_end.days) break;
const ym = c.date.yearMonth();
if (have_any and ym != prev_ym) {
// Month boundary: stash prev month's last close.
const m_idx_signed = Date.monthsBetween(window_start, prev_date);
if (m_idx_signed >= 0) {
const m_idx: usize = @intCast(m_idx_signed);
if (m_idx < n_months_total) {
prices[p_idx * n_months_total + m_idx] = last_close;
}
}
}
last_close = c.adj_close;
prev_ym = ym;
prev_date = c.date;
have_any = true;
}
// Final partial month: stash whatever the latest close was.
if (have_any) {
const m_idx_signed = Date.monthsBetween(window_start, prev_date);
if (m_idx_signed >= 0) {
const m_idx: usize = @intCast(m_idx_signed);
if (m_idx < n_months_total) {
prices[p_idx * n_months_total + m_idx] = last_close;
}
}
}
}
// Now compute portfolio monthly returns. For each month transition
// (m m+1), include only participants with valid prices in BOTH
// months; renormalize their weights for that single transition.
// This handles the "I have data starting in month 5" case naturally:
// months 1-4 simply lack that participant's contribution, weights
// for those months are over the participants who DO have data.
var monthly_returns = try allocator.alloc(f64, n_months_total - 1);
var n_valid_returns: usize = 0;
for (0..n_months_total - 1) |m| {
var month_weight_sum: f64 = 0;
var month_return_sum: f64 = 0;
for (participants_buf[0..n_participants], 0..) |orig_idx, p_idx| {
const prev_p = prices[p_idx * n_months_total + m];
const curr_p = prices[p_idx * n_months_total + m + 1];
if (std.math.isNan(prev_p) or std.math.isNan(curr_p)) continue;
if (prev_p <= 0) continue;
const r = (curr_p / prev_p) - 1.0;
const w = positions[orig_idx].weight;
month_return_sum += w * r;
month_weight_sum += w;
}
if (month_weight_sum > 0) {
monthly_returns[n_valid_returns] = month_return_sum / month_weight_sum;
n_valid_returns += 1;
}
}
if (n_valid_returns == 0) {
allocator.free(monthly_returns);
return .{ .monthly_returns = null, .reweighted = dropped };
}
// Trim to actual length.
const trimmed = try allocator.realloc(monthly_returns, n_valid_returns);
return .{ .monthly_returns = trimmed, .reweighted = dropped };
}
// Tests
const testing = std.testing;
fn makeCandle(date: Date, price: f64) Candle {
return .{
.date = date,
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
/// Build a candle slice spanning `n_months` months of business days
/// where the close on month `i` is `price_at_month(i)`. Month-end is
/// the last business day of each month we approximate with day 28.
fn buildMonthlyCandles(
allocator: std.mem.Allocator,
start_year: u16,
n_months: u16,
price_at_month: *const fn (i: u16) f64,
) ![]Candle {
var candles = std.ArrayList(Candle).empty;
errdefer candles.deinit(allocator);
var month: u16 = 0;
while (month < n_months) : (month += 1) {
const year_offset = month / 12;
const m_in_year: u8 = @intCast((month % 12) + 1);
const y_signed: i16 = @intCast(start_year + year_offset);
const date = Date.fromYmd(y_signed, m_in_year, 15);
const p = price_at_month(month);
try candles.append(allocator, makeCandle(date, p));
// Add an end-of-month candle so the resampler captures a
// distinct month-boundary close.
const end_date = Date.fromYmd(y_signed, m_in_year, 28);
try candles.append(allocator, makeCandle(end_date, p));
}
return candles.toOwnedSlice(allocator);
}
fn linearGrowth(i: u16) f64 {
return 100.0 + @as(f64, @floatFromInt(i)) * 1.0;
}
fn slowGrowth(i: u16) f64 {
return 100.0 + @as(f64, @floatFromInt(i)) * 0.3;
}
test "syntheticPortfolioRisk: empty positions returns empty result" {
const r = try syntheticPortfolioRisk(testing.allocator, &.{}, Date.fromYmd(2026, 1, 1));
try testing.expect(r.vol_3y == null);
try testing.expect(r.vol_10y == null);
try testing.expect(r.maxdd_5y == null);
try testing.expect(r.return_3y == null);
try testing.expect(r.reweight_flags.vol_3y == false);
}
test "syntheticPortfolioRisk: single position, 3Y window populates" {
// Build 40 months of monthly data enough for a 3Y window.
const candles = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(candles);
const positions = [_]PositionCandles{
.{ .symbol = "VTI", .candles = candles, .weight = 1.0 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y != null);
try testing.expect(r.sharpe_3y != null);
try testing.expect(r.vol_3y.? > 0);
try testing.expectEqual(false, r.reweight_flags.vol_3y);
}
test "syntheticPortfolioRisk: holding missing 10Y data flags 10Y but not 3Y" {
// Position A: 10+ years of data
const cand_a = try buildMonthlyCandles(testing.allocator, 2014, 145, &linearGrowth);
defer testing.allocator.free(cand_a);
// Position B: only 4 years of data (covers 3Y but not 10Y)
const cand_b = try buildMonthlyCandles(testing.allocator, 2022, 50, &slowGrowth);
defer testing.allocator.free(cand_b);
const positions = [_]PositionCandles{
.{ .symbol = "OLD", .candles = cand_a, .weight = 0.5 },
.{ .symbol = "NEW", .candles = cand_b, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
// 3Y window: both holdings participate.
try testing.expect(r.vol_3y != null);
try testing.expectEqual(false, r.reweight_flags.vol_3y);
// 10Y window: only OLD participates reweighted.
try testing.expect(r.vol_10y != null);
try testing.expectEqual(true, r.reweight_flags.vol_10y);
}
test "syntheticPortfolioRisk: zero-weight position is silently skipped" {
const cand_a = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_a);
const positions = [_]PositionCandles{
.{ .symbol = "VTI", .candles = cand_a, .weight = 1.0 },
.{ .symbol = "ZERO", .candles = &.{}, .weight = 0.0 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y != null);
// The zero-weight slot doesn't trigger reweight (skipped before participation check).
try testing.expectEqual(false, r.reweight_flags.vol_3y);
}
test "syntheticPortfolioRisk: empty-candle position drops out and flags reweight" {
const cand_a = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_a);
const positions = [_]PositionCandles{
.{ .symbol = "VTI", .candles = cand_a, .weight = 0.5 },
.{ .symbol = "MISSING", .candles = &.{}, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y != null);
try testing.expectEqual(true, r.reweight_flags.vol_3y);
}
test "syntheticPortfolioRisk: position with all candles before window drops out" {
// Build candles that ALL fall before the window (candles end in
// 2014, window asks for 2026-3Y backward = 2023+). The
// participation check at `last.days < window_start.days` should
// mark this position as dropped.
const cand_old = try buildMonthlyCandles(testing.allocator, 2010, 36, &linearGrowth);
defer testing.allocator.free(cand_old);
// Plus one position with current data so the window can populate.
const cand_current = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_current);
const positions = [_]PositionCandles{
.{ .symbol = "OLD_DEAD", .candles = cand_old, .weight = 0.5 },
.{ .symbol = "VTI", .candles = cand_current, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y != null);
// OLD_DEAD's candles are all >>45 days before the 3Y window
// start; it drops, reweight flag fires.
try testing.expectEqual(true, r.reweight_flags.vol_3y);
}
test "syntheticPortfolioRisk: all-positions-drop produces null result" {
// Both positions have candles that end before the window starts
// no participants null returns. Reweight flags are NOT set
// here because there's no successful stats pass to set them on
// (the flags are written as a side-effect of stats computation).
// That's a known asymmetry when *some* positions drop but
// others remain, the kept window's flags fire; when ALL
// positions drop, the field stays null and the flag stays
// false because no metric was computed at all.
const cand_old1 = try buildMonthlyCandles(testing.allocator, 2010, 24, &linearGrowth);
defer testing.allocator.free(cand_old1);
const cand_old2 = try buildMonthlyCandles(testing.allocator, 2011, 24, &linearGrowth);
defer testing.allocator.free(cand_old2);
const positions = [_]PositionCandles{
.{ .symbol = "DEAD1", .candles = cand_old1, .weight = 0.5 },
.{ .symbol = "DEAD2", .candles = cand_old2, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.vol_3y == null);
try testing.expect(r.vol_10y == null);
}
test "syntheticPortfolioRisk: perfectly correlated positions yield ~weighted-avg vol" {
// Two positions with IDENTICAL candle series portfolio vol should
// equal the per-position vol (within float tolerance), since the
// synthetic series is a weighted average of identical series, which
// is itself the same series.
const cand_a = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_a);
const cand_b = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_b);
const positions = [_]PositionCandles{
.{ .symbol = "A", .candles = cand_a, .weight = 0.6 },
.{ .symbol = "B", .candles = cand_b, .weight = 0.4 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
// Per-position vol from risk.zig
const tr_a = risk.trailingRisk(cand_a);
try testing.expect(tr_a.three_year != null);
const per_vol = tr_a.three_year.?.volatility;
try testing.expect(r.vol_3y != null);
// Should match within numerical tolerance.
try testing.expectApproxEqAbs(per_vol, r.vol_3y.?, 0.01);
}
test "syntheticPortfolioRisk: anti-correlated positions yield lower vol than weighted-avg" {
// Position A grows; position B falls. Weighted-avg of
// their per-position vols would suggest the portfolio is volatile,
// but the synthetic series (50/50 of two anti-correlated streams)
// should be flatter that's the diversification benefit.
const Anti = struct {
fn fall(i: u16) f64 {
return 200.0 - @as(f64, @floatFromInt(i)) * 1.0;
}
};
const cand_a = try buildMonthlyCandles(testing.allocator, 2022, 50, &linearGrowth);
defer testing.allocator.free(cand_a);
const cand_b = try buildMonthlyCandles(testing.allocator, 2022, 50, &Anti.fall);
defer testing.allocator.free(cand_b);
const positions = [_]PositionCandles{
.{ .symbol = "UP", .candles = cand_a, .weight = 0.5 },
.{ .symbol = "DOWN", .candles = cand_b, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
const tr_a = risk.trailingRisk(cand_a);
const tr_b = risk.trailingRisk(cand_b);
try testing.expect(tr_a.three_year != null);
try testing.expect(tr_b.three_year != null);
const avg_vol = (tr_a.three_year.?.volatility + tr_b.three_year.?.volatility) / 2.0;
try testing.expect(r.vol_3y != null);
// Synthetic series should have meaningfully lower vol than the
// weighted average. (Strict equality wouldn't hold because the
// series aren't perfectly anti-correlated under monthly compounding,
// but it should be measurably below.)
try testing.expect(r.vol_3y.? < avg_vol);
}
test "syntheticPortfolioRisk: reweight flags don't bleed across windows" {
// Position A: 10Y available. Position B: only 6 months (insufficient
// for ANY window). 3Y vol should be A-only with reweight=true.
const cand_a = try buildMonthlyCandles(testing.allocator, 2014, 145, &linearGrowth);
defer testing.allocator.free(cand_a);
const cand_b = try buildMonthlyCandles(testing.allocator, 2025, 6, &slowGrowth);
defer testing.allocator.free(cand_b);
const positions = [_]PositionCandles{
.{ .symbol = "A", .candles = cand_a, .weight = 0.5 },
.{ .symbol = "B", .candles = cand_b, .weight = 0.5 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
// Both windows should be reweighted B doesn't qualify for either.
try testing.expectEqual(true, r.reweight_flags.vol_3y);
try testing.expectEqual(true, r.reweight_flags.vol_10y);
}
test "syntheticPortfolioRisk: 5Y maxdd populated when sufficient data" {
// 6 years of growing-then-falling data so max_drawdown is non-zero.
const Curve = struct {
fn shape(i: u16) f64 {
// 30 months up to peak at 200, then 42 months down to 100,
// then recovery produces a clean ~50% drawdown.
if (i <= 30) return 100.0 + @as(f64, @floatFromInt(i)) * 3.33;
if (i <= 60) return 200.0 - @as(f64, @floatFromInt(i - 30)) * 3.33;
return 100.0 + @as(f64, @floatFromInt(i - 60)) * 1.0;
}
};
const cand = try buildMonthlyCandles(testing.allocator, 2020, 75, &Curve.shape);
defer testing.allocator.free(cand);
const positions = [_]PositionCandles{
.{ .symbol = "X", .candles = cand, .weight = 1.0 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.maxdd_5y != null);
try testing.expect(r.maxdd_5y.? > 0.30); // >30% the 200100 leg
}
test "syntheticPortfolioRisk: return_3y annualizes correctly" {
// 50 months of constant +1%/month returns. Annualized to ~12.7%.
const Constant = struct {
fn p(i: u16) f64 {
return std.math.pow(f64, 1.01, @as(f64, @floatFromInt(i))) * 100.0;
}
};
const cand = try buildMonthlyCandles(testing.allocator, 2022, 50, &Constant.p);
defer testing.allocator.free(cand);
const positions = [_]PositionCandles{
.{ .symbol = "X", .candles = cand, .weight = 1.0 },
};
const r = try syntheticPortfolioRisk(testing.allocator, &positions, Date.fromYmd(2026, 3, 1));
try testing.expect(r.return_3y != null);
// Annualized 1%/month (1.01^12 - 1) 0.1268. Allow a generous
// tolerance to absorb the calendar-edge effects.
try testing.expect(r.return_3y.? > 0.10);
try testing.expect(r.return_3y.? < 0.16);
}

View file

@ -12,10 +12,6 @@ pub const RiskMetrics = struct {
sharpe: f64,
/// Maximum drawdown as a positive decimal (e.g., 0.30 = 30% drawdown)
max_drawdown: f64,
/// Start date of max drawdown period
drawdown_start: ?Date = null,
/// Trough date of max drawdown
drawdown_trough: ?Date = null,
/// Number of monthly returns used
sample_size: usize,
};
@ -62,9 +58,12 @@ const tbill_rates = [_]struct { year: u16, rate: f64 }{
.{ .year = 2026, .rate = 0.0345 },
};
/// Look up the average risk-free rate for a date range from the T-bill table.
/// Returns the simple average of annual rates for all years that overlap the range.
fn avgRiskFreeRate(start: Date, end: Date) f64 {
/// Look up the average risk-free rate for a date range from the T-bill
/// table. Returns the simple average of annual rates for all years
/// that overlap the range. Used both for per-symbol Sharpe (`computeRisk`
/// in this file) and for synthetic-portfolio Sharpe
/// (`analytics/portfolio_risk.zig`) so both paths use the same rate.
pub fn avgRiskFreeRateForRange(start: Date, end: Date) f64 {
const start_year: u16 = @intCast(start.year());
const end_year: u16 = @intCast(end.year());
@ -96,24 +95,73 @@ pub fn trailingRisk(candles: []const Candle) TrailingRisk {
const start_10y = end_date.subtractYears(10);
return .{
.one_year = computeRisk(candles, start_1y, end_date, avgRiskFreeRate(start_1y, end_date)),
.three_year = computeRisk(candles, start_3y, end_date, avgRiskFreeRate(start_3y, end_date)),
.five_year = computeRisk(candles, start_5y, end_date, avgRiskFreeRate(start_5y, end_date)),
.ten_year = computeRisk(candles, start_10y, end_date, avgRiskFreeRate(start_10y, end_date)),
.one_year = computeRisk(candles, start_1y, end_date, avgRiskFreeRateForRange(start_1y, end_date)),
.three_year = computeRisk(candles, start_3y, end_date, avgRiskFreeRateForRange(start_3y, end_date)),
.five_year = computeRisk(candles, start_5y, end_date, avgRiskFreeRateForRange(start_5y, end_date)),
.ten_year = computeRisk(candles, start_10y, end_date, avgRiskFreeRateForRange(start_10y, end_date)),
};
}
/// Compute risk metrics for a specific date range using monthly returns.
/// Returns null if fewer than 12 monthly returns are available.
fn computeRisk(candles: []const Candle, start: Date, end: Date, risk_free_rate: f64) ?RiskMetrics {
// Find the slice of candles within [start, end]
/// Maximum monthly observations we'll resample from a 10+ year window.
/// Public so callers (notably `analytics/portfolio_risk.zig`) can size
/// their stack-allocated buffers consistently.
pub const max_months: usize = 130;
/// Stats derived from a month-end return series. Pure-data result of
/// `statsFromMonthlyReturns`; callers wrap it (or extend it with date
/// attribution) for their richer outward types like `RiskMetrics`.
pub const MonthlyStats = struct {
/// Annualized standard deviation of monthly returns.
volatility: f64,
/// Sharpe ratio: (annualized return - risk-free rate) / annualized vol.
sharpe: f64,
/// Maximum drawdown over the synthetic compound series, as a
/// positive decimal (e.g. 0.30 = 30% drawdown).
max_drawdown: f64,
/// Number of monthly returns the stats were derived from.
sample_size: usize,
};
/// Result of `monthEndReturns`: a slice into the caller's buffer
/// containing one return per month transition, plus the parallel
/// month-end dates for date-attribution callers (e.g. drawdown
/// start/trough). The `dates` slice is one element LONGER than
/// `returns` because returns are between consecutive month-ends.
pub const MonthEndSeries = struct {
returns: []const f64,
dates: []const Date,
};
/// Resample daily candles to a month-end return series, scoped to
/// `[start, end]`. Stack-only caller provides two scratch buffers
/// of size at least `max_months`. Returns null when the period
/// isn't sufficiently covered:
///
/// - no candles inside the window, OR
/// - data starts more than 45 days after `start`, OR
/// - fewer than 2 month-end observations (need at least 1 return).
///
/// Uses `Candle.adj_close` so synthetic returns include dividends
/// already baked into the adjusted price (Tiingo / Polygon
/// adj_close are split-and-dividend adjusted).
pub fn monthEndReturns(
candles: []const Candle,
start: Date,
end: Date,
out_returns: []f64,
out_dates: []Date,
) ?MonthEndSeries {
std.debug.assert(out_returns.len >= max_months);
std.debug.assert(out_dates.len >= max_months);
// Find the candles within [start, end].
var first: usize = 0;
for (candles, 0..) |c, i| {
if (c.date.days >= start.days) {
first = i;
break;
}
} else return null; // no candles in range
} else return null;
var last: usize = first;
for (candles[first..], first..) |c, i| {
@ -124,28 +172,25 @@ fn computeRisk(candles: []const Candle, start: Date, end: Date, risk_free_rate:
const slice = candles[first .. last + 1];
if (slice.len < 2) return null;
// If data starts more than 45 days after the requested start,
// the period isn't sufficiently covered don't report misleading metrics.
// Period not sufficiently covered.
if (slice[0].date.days - start.days > 45) return null;
// Resample to month-end closes: for each calendar month, take the last available close.
// We store up to 130 month-end prices (10+ years).
const max_months = 130;
// Resample: for each calendar month, take the last available
// close. We track the closes in a local buffer to avoid stomping
// the caller's `out_returns` (which we'll fill with returns after
// the resample completes).
var month_closes: [max_months]f64 = undefined;
var month_dates: [max_months]Date = undefined;
var n_months: usize = 0;
var prev_ym: u32 = yearMonth(slice[0].date);
var prev_ym: u32 = slice[0].date.yearMonth();
var last_close: f64 = slice[0].adj_close;
var last_date: Date = slice[0].date;
for (slice[1..]) |c| {
const ym = yearMonth(c.date);
const ym = c.date.yearMonth();
if (ym != prev_ym) {
// Month boundary crossed record the previous month's last close
if (n_months < max_months) {
month_closes[n_months] = last_close;
month_dates[n_months] = last_date;
out_dates[n_months] = last_date;
n_months += 1;
}
prev_ym = ym;
@ -153,81 +198,95 @@ fn computeRisk(candles: []const Candle, start: Date, end: Date, risk_free_rate:
last_close = c.adj_close;
last_date = c.date;
}
// Record the final (possibly partial) month
// Record the final (possibly partial) month.
if (n_months < max_months) {
month_closes[n_months] = last_close;
month_dates[n_months] = last_date;
out_dates[n_months] = last_date;
n_months += 1;
}
if (n_months < 2) return null;
// Compute monthly returns
// Compute month-over-month returns into `out_returns`.
const n_returns = n_months - 1;
if (n_returns < 12) return null; // need at least 12 monthly returns
for (0..n_returns) |i| {
const prev = month_closes[i];
const curr = month_closes[i + 1];
out_returns[i] = if (prev > 0) (curr / prev) - 1.0 else 0;
}
return .{
.returns = out_returns[0..n_returns],
.dates = out_dates[0..n_months],
};
}
/// Compute volatility / Sharpe / max-drawdown from a month-end
/// return series. Pure: no allocation, no I/O. Returns
/// zero-vol / zero-sharpe when the input is empty or trivially
/// non-volatile; callers that want a "no data" sentinel should
/// gate on `returns.len` themselves.
///
/// MaxDD walks a synthetic compound series anchored at 1.0. This
/// is mathematically equivalent to walking month-end prices
/// `(p_t / p_0)` is exactly the compounded return but lets the
/// helper operate on returns alone, which is what the synthetic-
/// portfolio path produces.
pub fn statsFromMonthlyReturns(returns: []const f64, risk_free_rate: f64) MonthlyStats {
var sum: f64 = 0;
var sum_sq: f64 = 0;
// Max drawdown from monthly closes
var peak: f64 = month_closes[0];
var price: f64 = 1.0;
var peak: f64 = 1.0;
var max_dd: f64 = 0;
var dd_start: ?Date = null;
var dd_trough: ?Date = null;
var current_dd_start: Date = month_dates[0];
for (1..n_months) |i| {
const prev = month_closes[i - 1];
const curr = month_closes[i];
if (prev <= 0) continue;
const ret = (curr / prev) - 1.0;
sum += ret;
sum_sq += ret * ret;
// Drawdown tracking on monthly closes
if (curr > peak) {
peak = curr;
current_dd_start = month_dates[i];
}
const dd = (peak - curr) / peak;
if (dd > max_dd) {
max_dd = dd;
dd_start = current_dd_start;
dd_trough = month_dates[i];
for (returns) |r| {
sum += r;
sum_sq += r * r;
price *= (1.0 + r);
if (price > peak) peak = price;
if (peak > 0) {
const dd = (peak - price) / peak;
if (dd > max_dd) max_dd = dd;
}
}
const nf: f64 = @floatFromInt(n_returns);
const mean = sum / nf;
// Use sample variance (n-1) for unbiased estimate
const variance = if (n_returns > 1)
const n = returns.len;
const nf: f64 = @floatFromInt(n);
const mean: f64 = if (n > 0) sum / nf else 0;
// Sample variance (Bessel's correction) for unbiased estimate.
const variance: f64 = if (n > 1)
(sum_sq - nf * mean * mean) / (nf - 1.0)
else
0;
const monthly_vol = @sqrt(@max(variance, 0));
// Annualize using standard monthly-to-annual conversion
const annual_vol = monthly_vol * @sqrt(months_per_year);
// Geometric annualized return
const annual_return = std.math.pow(f64, 1.0 + mean, months_per_year) - 1.0;
const sharpe = if (annual_vol > 0) (annual_return - risk_free_rate) / annual_vol else 0;
const sharpe: f64 = if (annual_vol > 0) (annual_return - risk_free_rate) / annual_vol else 0;
return .{
.volatility = annual_vol,
.sharpe = sharpe,
.max_drawdown = max_dd,
.drawdown_start = dd_start,
.drawdown_trough = dd_trough,
.sample_size = n_returns,
.sample_size = n,
};
}
/// Encode year+month as a single comparable integer (e.g. 2026*100+3 = 202603).
fn yearMonth(date: Date) u32 {
const y: u32 = @intCast(date.year());
const m: u32 = date.month();
return y * 100 + m;
/// Compute risk metrics for a specific date range using monthly returns.
/// Returns null if fewer than 12 monthly returns are available.
fn computeRisk(candles: []const Candle, start: Date, end: Date, risk_free_rate: f64) ?RiskMetrics {
var ret_buf: [max_months]f64 = undefined;
var date_buf: [max_months]Date = undefined;
const series = monthEndReturns(candles, start, end, &ret_buf, &date_buf) orelse return null;
if (series.returns.len < 12) return null; // need at least 12 monthly returns
const stats = statsFromMonthlyReturns(series.returns, risk_free_rate);
return .{
.volatility = stats.volatility,
.sharpe = stats.sharpe,
.max_drawdown = stats.max_drawdown,
.sample_size = stats.sample_size,
};
}
// Tests
@ -327,17 +386,80 @@ test "trailingRisk insufficient data" {
try std.testing.expect(tr.three_year == null);
}
test "yearMonth encoding" {
try std.testing.expectEqual(@as(u32, 202603), yearMonth(Date.fromYmd(2026, 3, 16)));
try std.testing.expectEqual(@as(u32, 202412), yearMonth(Date.fromYmd(2024, 12, 1)));
}
test "avgRiskFreeRate uses historical T-bill data" {
test "avgRiskFreeRateForRange uses historical T-bill data" {
// 2023-2025: average of 5.07%, 4.97%, 4.07% = 4.70%
const rate_3y = avgRiskFreeRate(Date.fromYmd(2023, 3, 16), Date.fromYmd(2025, 12, 31));
const rate_3y = avgRiskFreeRateForRange(Date.fromYmd(2023, 3, 16), Date.fromYmd(2025, 12, 31));
try std.testing.expectApproxEqAbs(@as(f64, 0.047), rate_3y, 0.002);
// 2020-2025: includes the near-zero years
const rate_5y = avgRiskFreeRate(Date.fromYmd(2020, 1, 1), Date.fromYmd(2025, 12, 31));
const rate_5y = avgRiskFreeRateForRange(Date.fromYmd(2020, 1, 1), Date.fromYmd(2025, 12, 31));
try std.testing.expect(rate_5y < rate_3y); // should be lower due to 2020-2021 near-zero rates
}
test "monthEndReturns: empty range returns null" {
var ret_buf: [max_months]f64 = undefined;
var date_buf: [max_months]Date = undefined;
const candles: []const Candle = &.{};
const series = monthEndReturns(candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1), &ret_buf, &date_buf);
try std.testing.expect(series == null);
}
test "monthEndReturns: data starting >45 days late returns null" {
var ret_buf: [max_months]f64 = undefined;
var date_buf: [max_months]Date = undefined;
// Window starts 2022-01-01, but candles start 2022-04-01 (90 days late).
var candles: [60]Candle = undefined;
var d = Date.fromYmd(2022, 4, 1);
for (0..candles.len) |i| {
candles[i] = makeCandle(d, 100.0 + @as(f64, @floatFromInt(i)));
d = d.addDays(7);
}
const series = monthEndReturns(&candles, Date.fromYmd(2022, 1, 1), Date.fromYmd(2024, 1, 1), &ret_buf, &date_buf);
try std.testing.expect(series == null);
}
test "monthEndReturns: happy path returns one fewer return than month-end dates" {
var ret_buf: [max_months]f64 = undefined;
var date_buf: [max_months]Date = undefined;
// 6 months of data, daily candles.
var candles: [180]Candle = undefined;
var d = Date.fromYmd(2024, 1, 3);
for (0..candles.len) |i| {
candles[i] = makeCandle(d, 100.0 + @as(f64, @floatFromInt(i)) * 0.1);
d = d.addDays(1);
while (d.dayOfWeek() >= 5) d = d.addDays(1);
}
const series = monthEndReturns(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1), &ret_buf, &date_buf);
try std.testing.expect(series != null);
const s = series.?;
// returns is one shorter than dates: returns[i] is the return
// from dates[i] to dates[i+1].
try std.testing.expectEqual(s.dates.len - 1, s.returns.len);
try std.testing.expect(s.returns.len >= 5); // at least Feb..Jun returns
}
test "statsFromMonthlyReturns: zero-variance series has zero vol" {
const constant = [_]f64{ 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01 };
const stats = statsFromMonthlyReturns(&constant, 0.04);
try std.testing.expectApproxEqAbs(@as(f64, 0), stats.volatility, 0.0001);
try std.testing.expectEqual(@as(usize, 12), stats.sample_size);
// Drawdown is zero every month is +1%, no peak retreats.
try std.testing.expectApproxEqAbs(@as(f64, 0), stats.max_drawdown, 0.0001);
}
test "statsFromMonthlyReturns: max drawdown matches hand calc" {
// Up 10%, up 10%, down 20%, up 5%. Peak at month 2 (1.21);
// trough at month 3 (1.21 × 0.80 = 0.968). Drawdown =
// (1.21 - 0.968) / 1.21 0.20.
const returns = [_]f64{ 0.10, 0.10, -0.20, 0.05 };
const stats = statsFromMonthlyReturns(&returns, 0.04);
try std.testing.expectApproxEqAbs(@as(f64, 0.20), stats.max_drawdown, 0.001);
}
test "statsFromMonthlyReturns: empty input returns zeros" {
const stats = statsFromMonthlyReturns(&.{}, 0.04);
try std.testing.expectEqual(@as(f64, 0), stats.volatility);
try std.testing.expectEqual(@as(f64, 0), stats.sharpe);
try std.testing.expectEqual(@as(f64, 0), stats.max_drawdown);
try std.testing.expectEqual(@as(usize, 0), stats.sample_size);
}

View file

@ -198,15 +198,36 @@ pub fn printRiskTable(out: *std.Io.Writer, tr: zfin.risk.TrailingRisk, color: bo
try cli.reset(out, color);
const labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" };
const cell_width: usize = 13;
for (0..4) |i| {
try out.print(" {s:<20}", .{labels[i]});
if (risk_arr[i]) |rm| {
try out.print(" {d:>12.1}%", .{rm.volatility * 100.0});
try out.print(" {d:>13.2}", .{rm.sharpe});
try cli.printFg(out, color, cli.CLR_NEGATIVE, " {d:>12.1}%", .{rm.max_drawdown * 100.0});
// Vol / Sharpe / MaxDD cells. Per-cell formatters share the
// same `fmt.fmtPctOpt` / `fmt.fmtSharpeOpt` family the review
// tab uses, so a vol number renders the same way everywhere.
// Width-padding via `fmt.padLeftToCols` is display-column
// aware so the em-dash sentinel aligns correctly when a
// window has insufficient data.
var vol_buf: [16]u8 = undefined;
var sharpe_buf: [16]u8 = undefined;
var dd_buf: [16]u8 = undefined;
const vol_v: ?f64 = if (risk_arr[i]) |rm| rm.volatility else null;
const sharpe_v: ?f64 = if (risk_arr[i]) |rm| rm.sharpe else null;
const dd_v: ?f64 = if (risk_arr[i]) |rm| rm.max_drawdown else null;
const vol_str = fmt.fmtPctOpt(&vol_buf, vol_v, .{});
const sharpe_str = fmt.fmtSharpeOpt(&sharpe_buf, sharpe_v, .{});
const dd_str = fmt.fmtPctOpt(&dd_buf, dd_v, .{});
var pad_buf: [32]u8 = undefined;
try out.print(" ", .{});
try out.print("{s}", .{fmt.padLeftToCols(&pad_buf, vol_str, cell_width)});
try out.print(" ", .{});
try out.print("{s}", .{fmt.padLeftToCols(&pad_buf, sharpe_str, cell_width)});
try out.print(" ", .{});
if (risk_arr[i] != null) {
try cli.printFg(out, color, cli.CLR_NEGATIVE, "{s}", .{fmt.padLeftToCols(&pad_buf, dd_str, cell_width)});
} else {
try cli.printFg(out, color, cli.CLR_MUTED, " {s:>13} {s:>13} {s:>13}", .{ "", "", "" });
try cli.printFg(out, color, cli.CLR_MUTED, "{s}", .{fmt.padLeftToCols(&pad_buf, dd_str, cell_width)});
}
try out.print("\n", .{});
}

View file

@ -523,36 +523,11 @@ pub fn display(
}
}
// Risk metrics (3-year, matching Morningstar default)
{
var any_risk = false;
for (summary.allocations) |a| {
if (pf_data.candle_map.get(a.symbol)) |candles| {
const tr = zfin.risk.trailingRisk(candles);
if (tr.three_year) |metrics| {
if (!any_risk) {
try out.print("\n", .{});
try cli.printBold(out, color, " Risk Metrics (3-Year, monthly returns):\n", .{});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" {s:>6} {s:>10} {s:>8} {s:>10}\n", .{
"Symbol", "Volatility", "Sharpe", "Max DD",
});
try out.print(" {s:->6} {s:->10} {s:->8} {s:->10}\n", .{
"", "", "", "",
});
try cli.reset(out, color);
any_risk = true;
}
try out.print(" {s:>6} {d:>9.1}% {d:>8.2} ", .{
a.display_symbol, metrics.volatility * 100.0, metrics.sharpe,
});
try cli.printFg(out, color, cli.CLR_NEGATIVE, "{d:>9.1}%", .{metrics.max_drawdown * 100.0});
try out.print("\n", .{});
}
}
}
}
// Per-symbol risk metrics (vol / Sharpe / max drawdown) used to
// print here. They moved to `zfin review`, which combines them
// with trailing returns + sector + tax-status into a single
// per-holding dashboard. The portfolio command now sticks to its
// job: positions + valuations + watchlist.
try out.print("\n", .{});
}

754
src/commands/review.zig Normal file
View file

@ -0,0 +1,754 @@
//! `zfin review` per-holding performance and risk dashboard.
//!
//! The CLI surface for the `review` view. Loads the portfolio + sibling
//! files (metadata.srf, accounts.srf), fetches per-symbol prices and
//! cached candles/dividends, builds the renderer-agnostic
//! `views/review.zig` view, and renders it as a wide ANSI table.
//!
//! The TUI has a peer surface (`tui/review_tab.zig`) consuming the same
//! view module both renderers stay in sync by definition.
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const review_view = @import("../views/review.zig");
const portfolio_risk = @import("../analytics/portfolio_risk.zig");
pub const ParsedArgs = struct {
sort: ?review_view.SortField = null,
sort_dir: review_view.SortDirection = .desc,
};
pub const meta: framework.Meta = .{
.name = "review",
.group = .portfolio,
.synopsis = "Per-holding performance and risk dashboard",
.help =
\\Usage: zfin review [opts]
\\
\\Show one row per portfolio holding with sector, tax-status,
\\trailing returns (1Y/3Y/5Y/10Y month-end total return), risk
\\metrics (3Y+10Y vol/Sharpe + 5Y MaxDD), and a true correlation-
\\aware portfolio totals row at the bottom.
\\
\\Default sort: grouped by sector (alphabetical), then weight
\\descending within each sector group.
\\
\\Options:
\\ --sort FIELD Sort by FIELD (overrides default grouping):
\\ sector, symbol, weight, tax,
\\ 1y, 3y, 5y, 10y,
\\ 3y-vol, 10y-vol,
\\ 3y-sharpe, 10y-sharpe,
\\ 5y-maxdd
\\ --asc Sort ascending (default: descending for
\\ numeric fields, ascending for symbol/sector)
\\
\\Reads classifications from `metadata.srf` and account tax types
\\from `accounts.srf`. Tax% is the share of each holding's market
\\value held in taxable accounts.
\\
,
.uppercase_first_arg = false,
.user_errors = error{ UnexpectedArg, InvalidSortField },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
var parsed: ParsedArgs = .{};
var i: usize = 0;
while (i < cmd_args.len) : (i += 1) {
const arg = cmd_args[i];
if (std.mem.eql(u8, arg, "--sort") and i + 1 < cmd_args.len) {
i += 1;
const value = cmd_args[i];
const field = review_view.parseSortField(value) orelse {
cli.stderrPrint(ctx.io, "Error: --sort must be one of: ");
cli.stderrPrint(ctx.io, joinSortFields());
cli.stderrPrint(ctx.io, "\n");
return error.InvalidSortField;
};
parsed.sort = field;
} else if (std.mem.eql(u8, arg, "--asc")) {
parsed.sort_dir = .asc;
} else if (std.mem.eql(u8, arg, "--desc")) {
parsed.sort_dir = .desc;
} else {
cli.stderrPrint(ctx.io, "Error: 'review' takes no positional arguments\n");
return error.UnexpectedArg;
}
}
return parsed;
}
/// Comma-joined valid sort fields for the user-facing error message.
/// Built once at comptime so we don't allocate at error time.
const joined_sort_fields: []const u8 = blk: {
var s: []const u8 = "";
for (review_view.sort_field_names, 0..) |name, idx| {
if (idx > 0) s = s ++ ", ";
s = s ++ name;
}
break :blk s;
};
fn joinSortFields() []const u8 {
return joined_sort_fields;
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const svc = ctx.svc orelse return error.MissingDataService;
const io = ctx.io;
const allocator = ctx.allocator;
const out = ctx.out;
const color = ctx.color;
const as_of = ctx.today;
var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
defer loaded.deinit(allocator);
const portfolio = loaded.portfolio;
const positions = loaded.positions;
const syms = loaded.syms;
const anchor_path = loaded.anchor();
// Fetch fresh prices via the parallel loader so risk + return
// figures reflect TTL-current data. Mirrors the analysis command.
var prices = std.StringHashMap(f64).init(allocator);
defer prices.deinit();
if (syms.len > 0) {
var load_result = cli.loadPortfolioPrices(io, svc, syms, &.{}, ctx.globals.refresh_policy, color);
defer load_result.deinit();
var it = load_result.prices.iterator();
while (it.next()) |entry| {
try prices.put(entry.key_ptr.*, entry.value_ptr.*);
}
}
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) {
error.NoAllocations, error.SummaryFailed => {
cli.stderrPrint(io, "Error computing portfolio summary.\n");
return;
},
else => return err,
};
defer pf_data.deinit(allocator);
// Load classifications + account map from sibling files.
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return;
defer allocator.free(meta_path);
const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch {
cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf\n");
return;
};
defer allocator.free(meta_data);
var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch {
cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n");
return;
};
defer cm.deinit();
var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(anchor_path);
defer if (acct_map_opt) |*am| am.deinit();
// Per-symbol cached dividends so total-return windows include
// dividend reinvestment when available. Cached-only no
// network to keep the command fast on large portfolios.
var dividend_map = std.StringHashMap([]const zfin.Dividend).init(allocator);
defer {
var it = dividend_map.iterator();
while (it.next()) |entry| {
zfin.Dividend.freeSlice(allocator, @constCast(entry.value_ptr.*));
}
dividend_map.deinit();
}
for (pf_data.summary.allocations) |a| {
if (svc.getCachedDividends(a.symbol)) |divs| {
try dividend_map.put(a.symbol, divs);
}
}
var view = try review_view.buildReview(
allocator,
pf_data.summary,
&pf_data.candle_map,
&dividend_map,
portfolio,
cm,
acct_map_opt,
as_of,
anchor_path,
);
defer view.deinit(allocator);
// Sort: explicit --sort overrides the default grouping.
if (parsed.sort) |field| {
review_view.sortRows(view.rows, field, parsed.sort_dir);
} else {
review_view.sortGroupedByDefault(view.rows);
}
try render(out, color, view);
}
// Rendering
const Money = @import("../Money.zig");
const fmt = @import("../format.zig");
/// Column widths (display columns). Cells are right-aligned by
/// caller via `format.padLeftToCols`, so the same width spec
/// produces a correctly-padded cell whether the content is `12.3%`,
/// `+22.4%`, or the multibyte em-dash `` (which Zig's `{s:>N}`
/// byte-padding under-pads by 2 cols).
///
/// Tax% lives at the END of the row, not the start: it's contextual
/// hint, not a primary metric, so it shouldn't anchor the eye. The
/// first numeric column the reader sees is Wt% (how big is this
/// position?), which is the right anchor for "what am I looking at."
const col_symbol: usize = 8;
const col_sector: usize = 20;
const col_weight: usize = 7;
const col_pct: usize = 8; // each return / vol cell
const col_sharpe: usize = 8;
const col_maxdd: usize = 10;
const col_tax: usize = 7;
/// Total numeric-cell widths in display order. Used for separator
/// rendering and for the column-iter loop.
const col_widths = [_]usize{
col_symbol, col_sector, col_weight,
col_pct, col_pct, col_pct,
col_pct, col_pct, col_pct,
col_sharpe, col_sharpe, col_maxdd,
col_tax,
};
fn render(out: *std.Io.Writer, color: bool, view: review_view.ReviewView) !void {
try cli.printBold(out, color, "\nPortfolio Review ({s})\n", .{view.portfolio_path});
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" As of {f} Liquid: {f} Holdings: {d}\n\n", .{
view.as_of, Money.from(view.total_liquid), view.rows.len,
});
try cli.reset(out, color);
// Header row. Column order matches `col_widths`.
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ", .{});
try out.print("{s:<8}", .{"Symbol"});
try out.print(" {s:<20}", .{"Sector"});
try out.print(" {s:>7}", .{"Wt%"});
try out.print(" {s:>8}", .{"1Y"});
try out.print(" {s:>8}", .{"3Y"});
try out.print(" {s:>8}", .{"5Y"});
try out.print(" {s:>8}", .{"10Y"});
try out.print(" {s:>8}", .{"3Y-Vol"});
try out.print(" {s:>8}", .{"10Y-Vol"});
try out.print(" {s:>8}", .{"3Y-SR"});
try out.print(" {s:>8}", .{"10Y-SR"});
try out.print(" {s:>10}", .{"5Y-MaxDD"});
try out.print(" {s:>7}", .{"Tax%"});
try out.print("\n", .{});
try cli.reset(out, color);
try writeSeparator(out, color);
// Rows.
for (view.rows) |r| {
try renderRow(out, color, r);
}
try writeSeparator(out, color);
// Totals row.
try renderTotalsRow(out, color, view.totals);
// Footnote about reweighting.
if (anyReweightFlag(view.totals.reweight_flags)) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("\n * Reweighted: at least one holding lacked full-window candle coverage.\n", .{});
try out.print(" Affected metrics renormalized weights across participating holdings.\n", .{});
try cli.reset(out, color);
}
try out.print("\n", .{});
}
fn writeSeparator(out: *std.Io.Writer, color: bool) !void {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" ", .{});
inline for (col_widths, 0..) |w, idx| {
if (idx > 0) try out.print(" ", .{});
var k: usize = 0;
while (k < w) : (k += 1) try out.print("", .{});
}
try out.print("\n", .{});
try cli.reset(out, color);
}
fn anyReweightFlag(f: portfolio_risk.ReweightFlags) bool {
return f.vol_3y or f.vol_10y or f.sharpe_3y or f.sharpe_10y or f.maxdd_5y or
f.return_1y or f.return_3y or f.return_5y or f.return_10y;
}
fn renderRow(out: *std.Io.Writer, color: bool, r: review_view.ReviewRow) !void {
try out.print(" ", .{});
try out.print("{s:<8}", .{fmt.truncateToCols(r.symbol, col_symbol)});
try out.print(" {s:<20}", .{fmt.truncateToCols(zfin.analysis.abbreviateSector(r.sector_mid), col_sector)});
try out.print(" ", .{});
try renderPctCell(out, color, .normal, r.weight, col_weight, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(r.return_1y), r.return_1y, col_pct, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(r.return_3y), r.return_3y, col_pct, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(r.return_5y), r.return_5y, col_pct, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(r.return_10y), r.return_10y, col_pct, false);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.volIntent(r.vol_3y), r.vol_3y, col_pct, false);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.volIntent(r.vol_10y), r.vol_10y, col_pct, false);
try out.print(" ", .{});
try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_3y), r.sharpe_3y, col_sharpe, false);
try out.print(" ", .{});
try renderSharpeCell(out, color, review_view.sharpeIntent(r.sharpe_10y), r.sharpe_10y, col_sharpe, false);
try out.print(" ", .{});
// MaxDD: same green/yellow/red scheme as Vol magnitude
// determines severity; a small drawdown isn't "bad", and a deep
// one isn't "merely a drawdown" either.
try renderPctCellOpt(out, color, review_view.maxddIntent(r.maxdd_5y), r.maxdd_5y, col_maxdd, false);
try out.print(" ", .{});
// Tax% is contextual; rendered in muted tone so it doesn't draw
// the eye from the primary metrics. Null when account map is
// missing for this holding.
try renderPctCellOpt(out, color, .muted, r.tax_pct, col_tax, false);
try out.print("\n", .{});
}
fn renderTotalsRow(out: *std.Io.Writer, color: bool, t: review_view.ReviewTotals) !void {
try cli.printBold(out, color, " {s:<8} {s:<20}", .{ "Total", "" });
try out.print(" ", .{});
try renderPctCell(out, color, .normal, t.weight, col_weight, false);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(t.return_1y), t.return_1y, col_pct, t.reweight_flags.return_1y);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(t.return_3y), t.return_3y, col_pct, t.reweight_flags.return_3y);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(t.return_5y), t.return_5y, col_pct, t.reweight_flags.return_5y);
try out.print(" ", .{});
try renderSignedPctCell(out, color, review_view.returnIntent(t.return_10y), t.return_10y, col_pct, t.reweight_flags.return_10y);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.volIntent(t.vol_3y), t.vol_3y, col_pct, t.reweight_flags.vol_3y);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.volIntent(t.vol_10y), t.vol_10y, col_pct, t.reweight_flags.vol_10y);
try out.print(" ", .{});
try renderSharpeCell(out, color, review_view.sharpeIntent(t.sharpe_3y), t.sharpe_3y, col_sharpe, t.reweight_flags.sharpe_3y);
try out.print(" ", .{});
try renderSharpeCell(out, color, review_view.sharpeIntent(t.sharpe_10y), t.sharpe_10y, col_sharpe, t.reweight_flags.sharpe_10y);
try out.print(" ", .{});
try renderPctCellOpt(out, color, review_view.maxddIntent(t.maxdd_5y), t.maxdd_5y, col_maxdd, t.reweight_flags.maxdd_5y);
try out.print(" ", .{});
try renderPctCellOpt(out, color, .muted, t.tax_pct, col_tax, false);
try out.print("\n", .{});
}
// Cell renderers
//
// Each renderer formats the value into a stack buffer, pads to the
// target display width via `format.padLeftToCols` (so multibyte
// content like `` aligns correctly Zig's `{s:>N}` byte-padding
// would under-pad by two cols), then emits with the intent's color.
fn renderPctCellOpt(
out: *std.Io.Writer,
color: bool,
intent: fmt.StyleIntent,
v: ?f64,
width: usize,
flag: bool,
) !void {
if (v) |val| {
try renderPctCell(out, color, intent, val, width, flag);
} else {
try renderEmDashCell(out, color, width);
}
}
fn renderPctCell(
out: *std.Io.Writer,
color: bool,
intent: fmt.StyleIntent,
v: f64,
width: usize,
flag: bool,
) !void {
var content_buf: [32]u8 = undefined;
const content = fmt.fmtPct(&content_buf, v, .{ .asterisk = flag });
var pad_buf: [64]u8 = undefined;
const padded = fmt.padLeftToCols(&pad_buf, content, width);
try cli.printIntent(out, color, intent, "{s}", .{padded});
}
fn renderSignedPctCell(
out: *std.Io.Writer,
color: bool,
intent: fmt.StyleIntent,
v: ?f64,
width: usize,
flag: bool,
) !void {
if (v == null) return renderEmDashCell(out, color, width);
var content_buf: [32]u8 = undefined;
const content = fmt.fmtPctOpt(&content_buf, v, .{ .signed = true, .asterisk = flag });
var pad_buf: [64]u8 = undefined;
const padded = fmt.padLeftToCols(&pad_buf, content, width);
try cli.printIntent(out, color, intent, "{s}", .{padded});
}
fn renderSharpeCell(
out: *std.Io.Writer,
color: bool,
intent: fmt.StyleIntent,
v: ?f64,
width: usize,
flag: bool,
) !void {
if (v == null) return renderEmDashCell(out, color, width);
var content_buf: [32]u8 = undefined;
const content = fmt.fmtSharpeOpt(&content_buf, v, .{ .asterisk = flag });
var pad_buf: [64]u8 = undefined;
const padded = fmt.padLeftToCols(&pad_buf, content, width);
try cli.printIntent(out, color, intent, "{s}", .{padded});
}
fn renderEmDashCell(out: *std.Io.Writer, color: bool, width: usize) !void {
var pad_buf: [64]u8 = undefined;
const padded = fmt.padLeftToCols(&pad_buf, fmt.no_data_sentinel, width);
try cli.printIntent(out, color, .muted, "{s}", .{padded});
}
// Tests
const testing = std.testing;
test "parseArgs: empty args returns default" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{};
const parsed = try parseArgs(&ctx, &args);
try testing.expect(parsed.sort == null);
try testing.expectEqual(review_view.SortDirection.desc, parsed.sort_dir);
}
test "parseArgs: --sort 3y-sharpe is accepted" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--sort", "3y-sharpe" };
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqual(review_view.SortField.sharpe_3y, parsed.sort.?);
}
test "parseArgs: --sort BOGUS errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--sort", "bogus-field" };
try testing.expectError(error.InvalidSortField, parseArgs(&ctx, &args));
}
test "parseArgs: --asc flips direction" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{ "--asc", "--sort", "weight" };
const parsed = try parseArgs(&ctx, &args);
try testing.expectEqual(review_view.SortDirection.asc, parsed.sort_dir);
try testing.expectEqual(review_view.SortField.weight, parsed.sort.?);
}
test "parseArgs: positional arg errors" {
var ctx: framework.RunCtx = undefined;
ctx.io = std.testing.io;
const args = [_][]const u8{"VTI"};
try testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args));
}
test "joinSortFields: contains all field names" {
const joined = joinSortFields();
for (review_view.sort_field_names) |name| {
try testing.expect(std.mem.indexOf(u8, joined, name) != null);
}
}
test "renderPctCell: writes percent representation, intent-aware" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderPctCell(&w, false, .normal, 0.1234, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "12.3%") != null);
}
test "renderPctCell: reweight flag appends asterisk" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderPctCell(&w, false, .normal, 0.1234, 8, true);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "12.3%*") != null);
}
test "renderSignedPctCell: positive number shows + sign" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSignedPctCell(&w, false, .positive, 0.1, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "+10.0%") != null);
}
test "renderSignedPctCell: negative number shows - sign" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSignedPctCell(&w, false, .negative, -0.05, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "-5.0%") != null);
}
test "renderSignedPctCell: null renders em-dash" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSignedPctCell(&w, false, .muted, null, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "") != null);
}
test "renderPctCellOpt: null em-dashes; value pads to width" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderPctCellOpt(&w, false, .muted, null, 8, false);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "") != null);
var buf2: [128]u8 = undefined;
var w2: std.Io.Writer = .fixed(&buf2);
try renderPctCellOpt(&w2, false, .normal, 0.20, 8, false);
try testing.expect(std.mem.indexOf(u8, w2.buffered(), "20.0%") != null);
}
test "renderSharpeCell: two-decimal precision" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSharpeCell(&w, false, .positive, 1.234, 8, false);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "1.23") != null);
}
test "renderSharpeCell: null renders em-dash" {
var buf: [128]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
try renderSharpeCell(&w, false, .muted, null, 8, false);
try testing.expect(std.mem.indexOf(u8, w.buffered(), "") != null);
}
test "renderRow: writes complete row with all fields" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const r: review_view.ReviewRow = .{
.symbol = "VTI",
.sector_mid = "Diversified",
.tax_pct = 0.40,
.weight = 0.33,
.return_1y = 0.15,
.return_3y = 0.18,
.return_5y = 0.12,
.return_10y = 0.14,
.vol_3y = 0.16,
.vol_10y = 0.17,
.sharpe_3y = 1.10,
.sharpe_10y = 0.85,
.maxdd_5y = 0.25,
};
try renderRow(&w, false, r);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
try testing.expect(std.mem.indexOf(u8, out, "Diversified") != null);
try testing.expect(std.mem.indexOf(u8, out, "+15.0%") != null);
try testing.expect(std.mem.indexOf(u8, out, "1.10") != null);
try testing.expect(std.mem.indexOf(u8, out, "25.0%") != null);
// Tax% renders at the END of the row.
try testing.expect(std.mem.indexOf(u8, out, "40.0%") != null);
}
test "renderRow: nulls render as em-dashes" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const r: review_view.ReviewRow = .{
.symbol = "NEW",
.sector_mid = "Bonds",
.tax_pct = null,
.weight = 0.05,
.return_1y = null,
.return_3y = null,
.return_5y = null,
.return_10y = null,
.vol_3y = null,
.vol_10y = null,
.sharpe_3y = null,
.sharpe_10y = null,
.maxdd_5y = null,
};
try renderRow(&w, false, r);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "NEW") != null);
// Many em-dash cells expected on this row (returns + risk + tax%).
try testing.expect(std.mem.count(u8, out, "") >= 8);
}
test "renderTotalsRow: renders Total label and weighted aggregates" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const t: review_view.ReviewTotals = .{
.weight = 1.0,
.return_1y = 0.20,
.return_3y = 0.15,
.return_5y = 0.13,
.return_10y = 0.14,
.vol_3y = 0.13,
.vol_10y = 0.16,
.sharpe_3y = 1.05,
.sharpe_10y = 0.95,
.maxdd_5y = 0.22,
.tax_pct = 0.50,
.reweight_flags = .{},
};
try renderTotalsRow(&w, false, t);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Total") != null);
try testing.expect(std.mem.indexOf(u8, out, "+20.0%") != null);
try testing.expect(std.mem.indexOf(u8, out, "100.0%") != null);
}
test "renderTotalsRow: reweight flag adds asterisk" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const t: review_view.ReviewTotals = .{
.weight = 1.0,
.return_1y = null,
.return_3y = null,
.return_5y = null,
.return_10y = null,
.vol_3y = 0.13,
.vol_10y = null,
.sharpe_3y = null,
.sharpe_10y = null,
.maxdd_5y = null,
.tax_pct = null,
.reweight_flags = .{ .vol_3y = true },
};
try renderTotalsRow(&w, false, t);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "13.0%*") != null);
}
test "anyReweightFlag: detects any flag" {
try testing.expectEqual(false, anyReweightFlag(.{}));
try testing.expectEqual(true, anyReweightFlag(.{ .vol_3y = true }));
try testing.expectEqual(true, anyReweightFlag(.{ .return_10y = true }));
try testing.expectEqual(true, anyReweightFlag(.{ .maxdd_5y = true }));
}
test "render: emits header, separator, rows, and totals" {
var buf: [16384]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
var rows = [_]review_view.ReviewRow{
.{
.symbol = "VTI",
.sector_mid = "Diversified",
.tax_pct = 1.0,
.weight = 0.6,
.return_1y = 0.15,
.return_3y = 0.18,
.return_5y = 0.12,
.return_10y = 0.14,
.vol_3y = 0.16,
.vol_10y = 0.17,
.sharpe_3y = 1.10,
.sharpe_10y = 0.85,
.maxdd_5y = 0.25,
},
.{
.symbol = "BND",
.sector_mid = "Bonds",
.tax_pct = 0.0,
.weight = 0.4,
.return_1y = 0.04,
.return_3y = 0.03,
.return_5y = 0.02,
.return_10y = 0.03,
.vol_3y = 0.05,
.vol_10y = 0.06,
.sharpe_3y = 0.20,
.sharpe_10y = 0.15,
.maxdd_5y = 0.08,
},
};
const view: review_view.ReviewView = .{
.rows = rows[0..],
.totals = .{
.weight = 1.0,
.return_1y = 0.10,
.return_3y = 0.12,
.return_5y = 0.08,
.return_10y = 0.10,
.vol_3y = 0.11,
.vol_10y = 0.12,
.sharpe_3y = 0.85,
.sharpe_10y = 0.65,
.maxdd_5y = 0.18,
.tax_pct = 0.6,
.reweight_flags = .{},
},
.as_of = zfin.Date.fromYmd(2026, 6, 4),
.total_liquid = 1_000_000.0,
.portfolio_path = "test_portfolio.srf",
};
try render(&w, false, view);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Portfolio Review") != null);
try testing.expect(std.mem.indexOf(u8, out, "test_portfolio.srf") != null);
try testing.expect(std.mem.indexOf(u8, out, "Symbol") != null);
try testing.expect(std.mem.indexOf(u8, out, "VTI") != null);
try testing.expect(std.mem.indexOf(u8, out, "BND") != null);
try testing.expect(std.mem.indexOf(u8, out, "Total") != null);
// Tax% header should be the LAST column header.
const tax_idx = std.mem.indexOf(u8, out, "Tax%") orelse unreachable;
const dd_idx = std.mem.indexOf(u8, out, "5Y-MaxDD") orelse unreachable;
try testing.expect(tax_idx > dd_idx);
}
test "render: emits reweight footnote when any flag set" {
var buf: [16384]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const view: review_view.ReviewView = .{
.rows = &.{},
.totals = .{
.weight = 0.0,
.return_1y = null,
.return_3y = null,
.return_5y = null,
.return_10y = null,
.vol_3y = null,
.vol_10y = null,
.sharpe_3y = null,
.sharpe_10y = null,
.maxdd_5y = null,
.tax_pct = null,
.reweight_flags = .{ .vol_10y = true },
},
.as_of = zfin.Date.fromYmd(2026, 6, 4),
.total_liquid = 0,
.portfolio_path = "x.srf",
};
try render(&w, false, view);
const out = w.buffered();
try testing.expect(std.mem.indexOf(u8, out, "Reweighted") != null);
}

View file

@ -36,6 +36,7 @@ const std = @import("std");
const Date = @import("../Date.zig");
const risk = @import("../analytics/risk.zig");
const shiller = @import("shiller.zig");
const review = @import("../views/review.zig");
/// A hand-maintained data source that nags once a year if it hasn't
/// been refreshed by its annual `(due_month, due_day)`.
@ -69,6 +70,13 @@ pub const entries = [_]StaleEntry{
.due_day = 1,
.source_file = "src/data/shiller.zig",
},
.{
.name = "Review tab MaxDD color thresholds",
.last_updated = review.maxdd_thresholds_last_reviewed,
.due_month = 6,
.due_day = 1,
.source_file = "src/views/review.zig",
},
};
/// Write a warning line for each entry in `entries` that is overdue
@ -284,7 +292,7 @@ test "silent when today is one day before due" {
test "real registry compiles and is non-empty" {
// Guard that the registry stays wired up; doesn't assert any
// particular nag behavior (real dates drift over time).
try std.testing.expect(entries.len >= 2);
try std.testing.expect(entries.len >= 3);
for (entries) |e| {
try std.testing.expect(e.name.len > 0);
try std.testing.expect(e.source_file.len > 0);

View file

@ -298,6 +298,151 @@ pub fn padRightToCols(buf: []u8, content: []const u8, target_cols: usize) []cons
return buf[0 .. content.len + pad];
}
/// Left-pad `content` to `target_cols` display columns by writing
/// spaces *before* the content. The buffer must be large enough
/// to fit `pad + content.len` bytes; if not, returns content
/// unchanged.
///
/// Useful for right-aligning numeric cells that may contain
/// multibyte sentinels like ``. The byte-padding `{s:>N}` form
/// under-pads multibyte content (3 bytes for `` = 1 display
/// column, so `{s:>8}` produces a 6-col-wide cell instead of 8).
///
/// `content` is COPIED into `buf` after the leading spaces, so
/// the caller can pass any slice (it doesn't have to live in
/// `buf`). Returns the slice covering the full padded result.
pub fn padLeftToCols(buf: []u8, content: []const u8, target_cols: usize) []const u8 {
const have = displayCols(content);
if (have >= target_cols) return content;
const pad = target_cols - have;
if (pad + content.len > buf.len) return content;
@memset(buf[0..pad], ' ');
@memcpy(buf[pad..][0..content.len], content);
return buf[0 .. pad + content.len];
}
/// Truncate `content` to at most `max_cols` display columns,
/// returning a borrowed sub-slice of the input. Display-column
/// aware: walks UTF-8 sequences so a multibyte glyph isn't sliced
/// mid-codepoint and so column accounting matches what the user
/// sees on screen.
///
/// When `content` already fits, returns it unchanged. When it
/// doesn't, returns the longest prefix that fits in `max_cols`
/// columns. No ellipsis or marker is appended callers that
/// want one should append it themselves to the returned slice
/// before padding to width.
///
/// Used by both the analysis tab's sector breakdown rows and the
/// review tab's per-holding sector cells, so a sector name that
/// overflows in one place overflows the same way in the other.
pub fn truncateToCols(content: []const u8, max_cols: usize) []const u8 {
if (max_cols == 0) return content[0..0];
var byte_idx: usize = 0;
var col_idx: usize = 0;
while (byte_idx < content.len and col_idx < max_cols) {
const b = content[byte_idx];
const seq_len: usize = if (b < 0x80)
1
else if (b < 0xC0)
// Continuation byte at the head of a sequence is malformed;
// skip one byte to avoid an infinite loop.
1
else if (b < 0xE0)
2
else if (b < 0xF0)
3
else
4;
if (byte_idx + seq_len > content.len) break;
byte_idx += seq_len;
col_idx += 1;
}
return content[0..byte_idx];
}
// Percent / Sharpe / "no-data" cell formatters
//
// Shared formatters used by the review tab + view, and any other
// surface that needs to render decimals as 1.5%-style percent
// strings, optionally with a `+` for positives, optionally with
// a trailing reweight asterisk. Multiple TUI tabs and CLI
// commands had near-identical inline copies of this logic before
// these helpers existed; keep callers using these so a future
// "render percent like X" decision lands in one place.
/// Sentinel rendered for null inputs in `fmtPctOpt`/`fmtSharpeOpt`.
/// Chosen to match the rest of the codebase's "no data" convention
/// (``, the em-dash). Public so layout code can compute its
/// display width consistently.
pub const no_data_sentinel: []const u8 = "";
/// Options for `fmtPctOpt`.
pub const PctOpts = struct {
/// Number of decimal places. Most surfaces use 1 (review table,
/// analysis bars); a few use 2 (perf command's "ann." rows via
/// `performance.formatReturn`). Clamped at 6 to keep the
/// stack buffer math sane.
decimals: u8 = 1,
/// Prefix `+` on positive values. Used by trailing-return cells
/// where the user reads the sign as a win/loss; not used by
/// vol or magnitude-only fields where everything is positive
/// by definition.
signed: bool = false,
/// Append a trailing `*` asterisk. Review tab uses this to mark
/// portfolio-totals cells whose math required dropping a
/// holding from the window.
asterisk: bool = false,
};
/// Format an optional decimal as a percent string (e.g. `0.1234`
/// `"12.3%"`). Returns the `no_data_sentinel` when the input
/// is null. Buffer must be at least ~16 bytes for typical inputs.
///
/// One formatter for every percent-shaped cell review tab,
/// review CLI command, and (future) any other surface. Avoids
/// the proliferation of near-duplicate `formatPctOpt` /
/// `printSignedPct` / `fmtPercent` helpers each tab used to
/// carry around.
pub fn fmtPctOpt(buf: []u8, v: ?f64, opts: PctOpts) []const u8 {
const val = v orelse return no_data_sentinel;
return fmtPct(buf, val, opts);
}
/// Same as `fmtPctOpt` but takes a non-optional value.
pub fn fmtPct(buf: []u8, v: f64, opts: PctOpts) []const u8 {
const sign: []const u8 = if (opts.signed and v >= 0) "+" else "";
const star: []const u8 = if (opts.asterisk) "*" else "";
return switch (opts.decimals) {
0 => std.fmt.bufPrint(buf, "{s}{d:.0}%{s}", .{ sign, v * 100.0, star }),
1 => std.fmt.bufPrint(buf, "{s}{d:.1}%{s}", .{ sign, v * 100.0, star }),
2 => std.fmt.bufPrint(buf, "{s}{d:.2}%{s}", .{ sign, v * 100.0, star }),
3 => std.fmt.bufPrint(buf, "{s}{d:.3}%{s}", .{ sign, v * 100.0, star }),
else => std.fmt.bufPrint(buf, "{s}{d:.4}%{s}", .{ sign, v * 100.0, star }),
} catch "?";
}
/// Options for `fmtSharpeOpt`. Sharpe is a unitless ratio so it
/// renders as a plain decimal (no `%`), typically two places.
pub const SharpeOpts = struct {
decimals: u8 = 2,
asterisk: bool = false,
};
/// Format an optional Sharpe ratio (or any unitless ratio) as a
/// fixed-decimal string. Returns the `no_data_sentinel` when the
/// input is null.
pub fn fmtSharpeOpt(buf: []u8, v: ?f64, opts: SharpeOpts) []const u8 {
const val = v orelse return no_data_sentinel;
const star: []const u8 = if (opts.asterisk) "*" else "";
return switch (opts.decimals) {
0 => std.fmt.bufPrint(buf, "{d:.0}{s}", .{ val, star }),
1 => std.fmt.bufPrint(buf, "{d:.1}{s}", .{ val, star }),
2 => std.fmt.bufPrint(buf, "{d:.2}{s}", .{ val, star }),
else => std.fmt.bufPrint(buf, "{d:.3}{s}", .{ val, star }),
} catch "?";
}
/// Render an em-dash (``) horizontally centered inside a cell
/// of `width` display columns, padded with spaces on both
/// sides. Used for table cells where the value is unavailable
@ -1799,3 +1944,94 @@ test "centerDash: undersized buffer returns less than `width` cols" {
// so it returns content unchanged. Output is 4 spaces.
try std.testing.expectEqualStrings(" ", out);
}
test "truncateToCols: shorter than max returns unchanged" {
try std.testing.expectEqualStrings("Foo", truncateToCols("Foo", 5));
try std.testing.expectEqualStrings("", truncateToCols("", 5));
}
test "truncateToCols: ASCII at boundary" {
try std.testing.expectEqualStrings("Hello", truncateToCols("Hello, world", 5));
try std.testing.expectEqualStrings("Hello", truncateToCols("Hello", 5));
try std.testing.expectEqualStrings("Hell", truncateToCols("Hello", 4));
}
test "truncateToCols: zero max returns empty slice" {
try std.testing.expectEqualStrings("", truncateToCols("Anything", 0));
}
test "truncateToCols: multibyte glyphs counted as one column each" {
// Em-dash is 3 bytes / 1 column. Three em-dashes in 9 bytes,
// counted as 3 columns. Truncating to 2 columns should yield
// the first two em-dashes (6 bytes).
const dashes = "———xyz"; // 3 dashes (9 bytes) + 3 ASCII = 6 cols
try std.testing.expectEqualStrings("——", truncateToCols(dashes, 2));
try std.testing.expectEqualStrings("———", truncateToCols(dashes, 3));
try std.testing.expectEqualStrings("———x", truncateToCols(dashes, 4));
}
test "truncateToCols: never slices a multibyte sequence in half" {
const s = "a—b"; // 'a' (1B/1col), em-dash (3B/1col), 'b' (1B/1col) = 5B/3cols
// Truncating to 1 col gets just 'a'.
try std.testing.expectEqualStrings("a", truncateToCols(s, 1));
// 2 cols gets 'a' + em-dash.
try std.testing.expectEqualStrings("a—", truncateToCols(s, 2));
// 3 cols (full string fits).
try std.testing.expectEqualStrings("a—b", truncateToCols(s, 3));
}
test "fmtPct: default decimals is 1" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("12.3%", fmtPct(&buf, 0.1234, .{}));
}
test "fmtPct: signed positive shows + prefix" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("+12.3%", fmtPct(&buf, 0.1234, .{ .signed = true }));
}
test "fmtPct: signed negative does not double-sign" {
var buf: [16]u8 = undefined;
// {d} already produces the leading `-`; opts.signed only adds `+`.
try std.testing.expectEqualStrings("-5.0%", fmtPct(&buf, -0.05, .{ .signed = true }));
}
test "fmtPct: asterisk appended after percent" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("12.3%*", fmtPct(&buf, 0.1234, .{ .asterisk = true }));
try std.testing.expectEqualStrings("+12.3%*", fmtPct(&buf, 0.1234, .{ .signed = true, .asterisk = true }));
}
test "fmtPct: decimals selector" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("12%", fmtPct(&buf, 0.1234, .{ .decimals = 0 }));
try std.testing.expectEqualStrings("12.34%", fmtPct(&buf, 0.1234, .{ .decimals = 2 }));
try std.testing.expectEqualStrings("12.340%", fmtPct(&buf, 0.1234, .{ .decimals = 3 }));
}
test "fmtPctOpt: null returns sentinel" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings(no_data_sentinel, fmtPctOpt(&buf, null, .{}));
}
test "fmtPctOpt: value forwards to fmtPct with the same opts" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("+12.3%", fmtPctOpt(&buf, 0.1234, .{ .signed = true }));
}
test "fmtSharpeOpt: default 2 decimals" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("1.25", fmtSharpeOpt(&buf, 1.25, .{}));
try std.testing.expectEqualStrings("-0.30", fmtSharpeOpt(&buf, -0.3, .{}));
}
test "fmtSharpeOpt: null returns sentinel" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings(no_data_sentinel, fmtSharpeOpt(&buf, null, .{}));
}
test "fmtSharpeOpt: asterisk and decimals" {
var buf: [16]u8 = undefined;
try std.testing.expectEqualStrings("1.25*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true }));
try std.testing.expectEqualStrings("1.3*", fmtSharpeOpt(&buf, 1.25, .{ .asterisk = true, .decimals = 1 }));
}

View file

@ -25,6 +25,7 @@ const command_modules = .{
// Portfolio analysis
.portfolio = @import("commands/portfolio.zig"),
.analysis = @import("commands/analysis.zig"),
.review = @import("commands/review.zig"),
.projections = @import("commands/projections.zig"),
.milestones = @import("commands/milestones.zig"),

View file

@ -21,6 +21,7 @@ const input_buffer = @import("tui/input_buffer.zig");
const tab_modules = .{
.portfolio = @import("tui/portfolio_tab.zig"),
.analysis = @import("tui/analysis_tab.zig"),
.review = @import("tui/review_tab.zig"),
.projections = @import("tui/projections_tab.zig"),
.history = @import("tui/history_tab.zig"),
.quote = @import("tui/quote_tab.zig"),
@ -149,6 +150,20 @@ pub const StyledLine = struct {
graphemes: ?[]const []const u8 = null,
// Optional per-cell style array (same length as graphemes). Enables color gradients.
cell_styles: ?[]const vaxis.Style = null,
// Optional list of column-range style spans. Used by lines that need
// more than one secondary style (e.g. the review tab's wide table
// where every numeric cell may have its own intent color). Spans
// are applied in order; the LAST matching span wins for any cell.
// Ranges are display-column-based (`[start, end)`), matching
// `alt_start`/`alt_end`. When set, `alt_*` is ignored.
spans: ?[]const StyleSpan = null,
};
/// Display-column range with a style override. See `StyledLine.spans`.
pub const StyleSpan = struct {
start: usize,
end: usize,
style: vaxis.Style,
};
/// Pre-resolved row in the help overlay: a key string (possibly
@ -1528,14 +1543,24 @@ pub const App = struct {
fn deinitData(self: *App) void {
self.symbol_data.deinit(self.allocator);
tab_modules.earnings.tab.deinit(&self.states.earnings, self);
tab_modules.options.tab.deinit(&self.states.options, self);
tab_modules.portfolio.tab.deinit(&self.states.portfolio, self);
tab_modules.analysis.tab.deinit(&self.states.analysis, self);
// Comptime walk every tab in the registry. Hand-enumerated
// lists drift review_tab and performance_tab were silently
// missed in earlier hand-edits, leaking their allocator-backed
// state on quit. The framework's tab_modules registry is the
// single source of truth; iterating it here makes "add a new
// tab" a one-edit change in tui.zig.
//
// Order doesn't matter: each tab's deinit only touches its
// own State plus app-shared fields it explicitly owns. The
// cross-tab `app.portfolio.deinit` runs after, in case a tab
// wants to read shared portfolio data during its own deinit
// (currently none do, but the ordering is the safest default).
inline for (std.meta.fields(@TypeOf(tab_modules))) |field| {
const Module = @field(tab_modules, field.name);
const state_ptr = &@field(self.states, field.name);
Module.tab.deinit(state_ptr, self);
}
self.portfolio.deinit(self.allocator);
tab_modules.history.tab.deinit(&self.states.history, self);
tab_modules.projections.tab.deinit(&self.states.projections, self);
tab_modules.quote.tab.deinit(&self.states.quote, self);
if (self.portfolio_resolved) |rp| rp.deinit();
if (self.portfolio_paths.len > 0) self.allocator.free(self.portfolio_paths);
}
@ -1703,7 +1728,16 @@ pub const App = struct {
var bi: usize = 0;
while (bi < line.text.len and col < width) {
var s = line.style;
if (line.alt_style) |alt| {
// `spans` (if present) takes precedence over `alt_*`.
// Iterate forward; the LAST span that contains `col`
// wins. Spans are expected to be small (<= ~15 per
// line for the review tab's column count), so a
// linear scan per cell is fine.
if (line.spans) |spans| {
for (spans) |sp| {
if (col >= sp.start and col < sp.end) s = sp.style;
}
} else if (line.alt_style) |alt| {
if (col >= line.alt_start and col < line.alt_end) s = alt;
}
const byte = line.text[bi];

View file

@ -369,12 +369,18 @@ fn granularityLabel(g: zfin.analysis.Granularity) []const u8 {
pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 {
const pct = item.weight * 100.0;
const bar = try buildBlockBar(arena, item.weight, bar_width);
// Build label padded to label_width
const lbl = item.label;
const lbl_len = @min(lbl.len, label_width);
const padded_label = try arena.alloc(u8, label_width);
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
// Apply the shared sector-label abbreviation (so e.g. "Communication
// Services" becomes "Comm. Services" — same rule the review tab
// uses), then display-column-truncate to the cell width. Padding
// to display columns (rather than byte length) keeps multibyte
// sector names like "Comm. Services" aligned with neighbours.
const abbrev = zfin.analysis.abbreviateSector(item.label);
const trimmed = fmt.truncateToCols(abbrev, label_width);
const have_cols = fmt.displayCols(trimmed);
const pad_cols = if (have_cols < label_width) label_width - have_cols else 0;
const padded_label = try arena.alloc(u8, trimmed.len + pad_cols);
@memcpy(padded_label[0..trimmed.len], trimmed);
if (pad_cols > 0) @memset(padded_label[trimmed.len..], ' ');
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {f}", .{
padded_label, bar, pct, Money.from(item.value),
});

View file

@ -253,18 +253,32 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" };
for (0..4) |i| {
if (risk_arr[i]) |rm| {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{
risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0,
}), .style = th.contentStyle() });
} else {
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{
risk_labels[i],
"",
"",
"",
}), .style = th.mutedStyle() });
}
// Format vol / Sharpe / MaxDD via the shared
// `fmt.fmtPctOpt` / `fmt.fmtSharpeOpt` family. Width-pad
// via display-column-aware `padLeftToCols` so the em-dash
// sentinel for a missing window aligns the same way the
// numeric branch does `{d:>14}` byte-padding would
// under-pad the 3-byte em-dash by 2 cols.
const cell_width: usize = 13;
const vol_v: ?f64 = if (risk_arr[i]) |rm| rm.volatility else null;
const sharpe_v: ?f64 = if (risk_arr[i]) |rm| rm.sharpe else null;
const dd_v: ?f64 = if (risk_arr[i]) |rm| rm.max_drawdown else null;
var vol_buf: [16]u8 = undefined;
var sharpe_buf: [16]u8 = undefined;
var dd_buf: [16]u8 = undefined;
var p1: [32]u8 = undefined;
var p2: [32]u8 = undefined;
var p3: [32]u8 = undefined;
const vol_cell = fmt.padLeftToCols(&p1, fmt.fmtPctOpt(&vol_buf, vol_v, .{}), cell_width);
const sharpe_cell = fmt.padLeftToCols(&p2, fmt.fmtSharpeOpt(&sharpe_buf, sharpe_v, .{}), cell_width);
const dd_cell = fmt.padLeftToCols(&p3, fmt.fmtPctOpt(&dd_buf, dd_v, .{}), cell_width);
const style = if (risk_arr[i] != null) th.contentStyle() else th.mutedStyle();
try lines.append(arena, .{
.text = try std.fmt.allocPrint(arena, " {s:<20} {s} {s} {s}", .{
risk_labels[i], vol_cell, sharpe_cell, dd_cell,
}),
.style = style,
});
}
}

1254
src/tui/review_tab.zig Normal file

File diff suppressed because it is too large Load diff

1178
src/views/review.zig Normal file

File diff suppressed because it is too large Load diff