add new review command/tab
This commit is contained in:
parent
79ffbeb078
commit
6fbbf48486
18 changed files with 4672 additions and 144 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
138
TODO.md
|
|
@ -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.**
|
||||
|
|
|
|||
62
src/Date.zig
62
src/Date.zig
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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(""));
|
||||
}
|
||||
|
|
|
|||
704
src/analytics/portfolio_risk.zig
Normal file
704
src/analytics/portfolio_risk.zig
Normal 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 20–40% 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 200→100 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", .{});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
754
src/commands/review.zig
Normal 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,
|
||||
÷nd_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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
236
src/format.zig
236
src/format.zig
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
||||
|
|
|
|||
50
src/tui.zig
50
src/tui.zig
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
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
1178
src/views/review.zig
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue