diff --git a/.mise.toml b/.mise.toml index 4c58f64..01afb8d 100644 --- a/.mise.toml +++ b/.mise.toml @@ -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" diff --git a/AGENTS.md b/AGENTS.md index 1a22ac0..d2cd1f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). | diff --git a/TODO.md b/TODO.md index c151dfc..dc78bbe 100644 --- a/TODO.md +++ b/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 `/` 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.** diff --git a/src/Date.zig b/src/Date.zig index ba32219..797a5f6 100644 --- a/src/Date.zig +++ b/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). diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index 01abd91..eacf217 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -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("")); +} diff --git a/src/analytics/portfolio_risk.zig b/src/analytics/portfolio_risk.zig new file mode 100644 index 0000000..a464417 --- /dev/null +++ b/src/analytics/portfolio_risk.zig @@ -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); +} diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index cf94a3f..b41d7ff 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -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); +} diff --git a/src/commands/perf.zig b/src/commands/perf.zig index a174e53..404171d 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -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", .{}); } diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 4118cff..3dcd27a 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -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", .{}); } diff --git a/src/commands/review.zig b/src/commands/review.zig new file mode 100644 index 0000000..ef7757d --- /dev/null +++ b/src/commands/review.zig @@ -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 > 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); +} diff --git a/src/data/staleness.zig b/src/data/staleness.zig index 5f035cb..085f6b3 100644 --- a/src/data/staleness.zig +++ b/src/data/staleness.zig @@ -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); diff --git a/src/format.zig b/src/format.zig index a1644b6..896e5d8 100644 --- a/src/format.zig +++ b/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 })); +} diff --git a/src/main.zig b/src/main.zig index bfeb662..af08355 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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"), diff --git a/src/tui.zig b/src/tui.zig index 409e3d0..385f0f3 100644 --- a/src/tui.zig +++ b/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]; diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index 66d3548..ba162f4 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -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), }); diff --git a/src/tui/performance_tab.zig b/src/tui/performance_tab.zig index 8ba3b3b..d1638b3 100644 --- a/src/tui/performance_tab.zig +++ b/src/tui/performance_tab.zig @@ -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, + }); } } diff --git a/src/tui/review_tab.zig b/src/tui/review_tab.zig new file mode 100644 index 0000000..2fe6363 --- /dev/null +++ b/src/tui/review_tab.zig @@ -0,0 +1,1254 @@ +//! `review` TUI tab — per-holding performance and risk dashboard. +//! +//! The TUI surface for the `review` view, rendered as a wide line-list +//! table. Mirrors the portfolio tab's sort-key conventions (`>` next +//! column, `<` previous column, `o` reverse direction) so muscle memory +//! transfers between the two tabs. +//! +//! State is small: cursor/scroll offsets aren't tracked here (the App- +//! level scroll handling does it), and the `view` itself is rebuilt on +//! activate/reload from the per-portfolio shared cache. +//! +//! All data sources are App-scoped (`app.portfolio.{summary, file, +//! account_map}`, `app.svc` cached candles/dividends), so the tab's +//! lifecycle is straightforward — load on activate, free on deinit. + +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("../root.zig"); +const Money = @import("../Money.zig"); +const fmt = @import("../format.zig"); +const theme = @import("theme.zig"); +const tui = @import("../tui.zig"); +const framework = @import("tab_framework.zig"); +const review_view = @import("../views/review.zig"); +const portfolio_risk = @import("../analytics/portfolio_risk.zig"); +const service = @import("../service.zig"); + +const App = tui.App; +const StyledLine = tui.StyledLine; +const StyleSpan = tui.StyleSpan; + +// ── Tab-local action enum ───────────────────────────────────── + +pub const Action = enum { + /// Move the active sort field to the next column (right). + sort_col_next, + /// Move the active sort field to the previous column (left). + sort_col_prev, + /// Flip the current sort direction (asc ↔ desc). + sort_reverse, +}; + +// ── Tab-private state ───────────────────────────────────────── + +pub const State = struct { + /// Whether `loadData` has populated `view` for the currently + /// loaded portfolio. Cleared by `reload` to force re-build. + loaded: bool = false, + /// Computed view. Owned by State; freed in `deinit` and `reload`. + view: ?review_view.ReviewView = null, + /// Per-portfolio classification metadata (`metadata.srf`). Loaded + /// lazily on first activation, kept across reloads (cheap and + /// rarely-changing). Freed in `deinit`. + classification_map: ?zfin.classification.ClassificationMap = null, + /// Cached dividends per symbol so we don't re-walk the cache on + /// every render. Owned by State; freed in `deinit` and `reload`. + dividend_map: ?std.StringHashMap([]const zfin.Dividend) = null, + /// Active sort field. Default `.sector` (asc) provides the + /// "grouped by sector with symbol-asc tiebreaker" entry state + /// — see `views/review.sortRows` for why the sector column + /// bakes in the symbol-asc pre-pass. + sort_field: review_view.SortField = .sector, + sort_dir: review_view.SortDirection = .asc, + /// Content-row index of the column-header line, written by + /// `buildStyledLines` after appending the header. Used by + /// `handleMouse` to detect clicks on the header (column-sort + /// hit-test) vs. clicks on data rows. + header_row: usize = 0, +}; + +// ── Tab framework contract ──────────────────────────────────── + +pub const meta: framework.TabMeta(Action) = .{ + .label = "Review", + .default_bindings = &.{ + .{ .action = .sort_col_next, .key = .{ .codepoint = '>' } }, + .{ .action = .sort_col_prev, .key = .{ .codepoint = '<' } }, + .{ .action = .sort_reverse, .key = .{ .codepoint = 'o' } }, + }, + .action_labels = std.enums.EnumArray(Action, []const u8).init(.{ + .sort_col_next = "Sort: next column", + .sort_col_prev = "Sort: previous column", + .sort_reverse = "Sort: reverse direction", + }), + .status_hints = &.{ + .sort_col_prev, + .sort_col_next, + .sort_reverse, + }, +}; + +/// Sort fields cycled through by `sort_col_next` / `sort_col_prev`, +/// in column-display order. The "default grouping" (null) is the +/// entry state and is reachable by cycling past the end of the array. +const sortable_fields = [_]review_view.SortField{ + .symbol, .sector, .tax_pct, .weight, + .return_1y, .return_3y, .return_5y, .return_10y, + .vol_3y, .vol_10y, .sharpe_3y, .sharpe_10y, + .maxdd_5y, +}; + +pub const tab = struct { + pub const ActionT = Action; + pub const StateT = State; + + pub fn init(state: *State, app: *App) !void { + _ = app; + state.* = .{}; + } + + pub fn deinit(state: *State, app: *App) void { + deinitState(state, app.allocator); + } + + pub fn activate(state: *State, app: *App) !void { + if (tab.isDisabled(app)) return; + if (state.loaded) return; + loadData(state, app); + } + + pub const deactivate = framework.noopDeactivate(State); + + /// Manual refresh: drops the cached view and re-builds. Also + /// clears the dividend cache (in case new dividends arrived) and + /// the shared `account_map` so accounts.srf gets re-read. The + /// classification_map persists — it's per-portfolio, not + /// per-refresh. + pub fn reload(state: *State, app: *App) !void { + if (state.view) |*v| v.deinit(app.allocator); + state.view = null; + freeDividendMap(state, app); + state.loaded = false; + if (app.portfolio.account_map) |*am| am.deinit(); + app.portfolio.account_map = null; + loadData(state, app); + } + + pub const tick = framework.noopTick(State); + + pub fn handleAction(state: *State, app: *App, action: Action) void { + switch (action) { + .sort_col_next => { + state.sort_field = nextSortField(state.sort_field); + applySort(state); + }, + .sort_col_prev => { + state.sort_field = prevSortField(state.sort_field); + applySort(state); + }, + .sort_reverse => { + state.sort_dir = if (state.sort_dir == .asc) .desc else .asc; + applySort(state); + }, + } + _ = app; + } + + /// Review requires a loaded portfolio file and per-symbol + /// allocations. Same gate as the analysis tab. + pub fn isDisabled(app: *App) bool { + return app.portfolio.file == null; + } + + /// Mouse handling: left-click on the column-header row sorts + /// by that column. Re-clicking the active column flips + /// direction; clicking a different column resets to its + /// `defaultDir` (asc for symbol/sector, desc for numeric + /// columns — best/worst-first matches the typical "show me + /// the leaders" reading). Wheel events fall through to App's + /// scroll handling. Returns true when consumed. + pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool { + if (mouse.button != .left) return false; + if (mouse.type != .press) return false; + if (state.view == null) return false; + const content_row = @as(usize, @intCast(mouse.row)) + app.scroll_offset; + if (content_row != state.header_row) return false; + const click_col: usize = @intCast(mouse.col); + if (!applyHeaderClick(state, click_col)) return false; + applySort(state); + return true; + } +}; + +/// Pure-state header-click handler: maps `click_col` to a column, +/// updates `state.sort_field` / `state.sort_dir` per the +/// "re-click flips, new column resets to defaultDir" rule, and +/// returns true when a sort change should be applied. False means +/// the click landed on a gap, the prefix, or past the rightmost +/// column — caller should not invoke `applySort`. +/// +/// Extracted from `handleMouse` so the sort-mutation logic can be +/// unit-tested without an `*App`. +fn applyHeaderClick(state: *State, click_col: usize) bool { + const hit = hitTestHeader(click_col) orelse return false; + const sf = hit.sortField() orelse return false; + if (state.sort_field == sf) { + state.sort_dir = state.sort_dir.flip(); + } else { + state.sort_field = sf; + state.sort_dir = hit.defaultDir(); + } + return true; +} + +// ── Sort cycling ────────────────────────────────────────────── + +/// Cycle forward through `sortable_fields`, wrapping at the end. +fn nextSortField(curr: review_view.SortField) review_view.SortField { + for (sortable_fields, 0..) |f, i| { + if (f == curr) { + const next_idx = (i + 1) % sortable_fields.len; + return sortable_fields[next_idx]; + } + } + return sortable_fields[0]; // shouldn't happen — recover gracefully +} + +/// Cycle backward through `sortable_fields`, wrapping at the start. +fn prevSortField(curr: review_view.SortField) review_view.SortField { + for (sortable_fields, 0..) |f, i| { + if (f == curr) { + const prev_idx = if (i == 0) sortable_fields.len - 1 else i - 1; + return sortable_fields[prev_idx]; + } + } + return sortable_fields[sortable_fields.len - 1]; +} + +fn applySort(state: *State) void { + const view = &(state.view orelse return); + review_view.sortRows(view.rows, state.sort_field, state.sort_dir); +} + +// ── Data loading ────────────────────────────────────────────── + +fn loadData(state: *State, app: *App) void { + state.loaded = true; + app.ensurePortfolioDataLoaded(); + const pf = app.portfolio.file orelse return; + const summary = app.portfolio.summary orelse return; + + // Lazy-load classifications + account map (mirroring analysis_tab). + if (state.classification_map == null) { + if (app.anchorPath()) |ppath| { + const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0; + const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return; + defer app.allocator.free(meta_path); + + const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, meta_path, app.allocator, .limited(1024 * 1024)) catch { + app.setStatus("No metadata.srf found. Run: zfin enrich > metadata.srf"); + return; + }; + defer app.allocator.free(file_data); + + state.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch { + app.setStatus("Error parsing metadata.srf"); + return; + }; + } + } + + app.ensureAccountMap(); + + // Build dividend map from cache (no network — keeps activation fast + // on large portfolios). + if (state.dividend_map == null) { + var dm = std.StringHashMap([]const zfin.Dividend).init(app.allocator); + for (summary.allocations) |a| { + if (app.svc.getCachedDividends(a.symbol)) |divs| { + dm.put(a.symbol, divs) catch continue; + } + } + state.dividend_map = dm; + } + + // Build a borrowed candle map: for each allocation, fetch cached + // candles. We hold the FetchResults until the view is built so + // their backing slices stay valid; releasing them via `defer` is + // safe because `buildReview` only reads the slices during its + // call and the view doesn't retain pointers into them. + var fetch_results: std.ArrayList(service.FetchResult(zfin.Candle)) = .empty; + defer { + for (fetch_results.items) |fr| fr.deinit(); + fetch_results.deinit(app.allocator); + } + var candle_map = std.StringHashMap([]const zfin.Candle).init(app.allocator); + defer candle_map.deinit(); + + for (summary.allocations) |a| { + if (app.svc.getCachedCandles(a.symbol)) |fr| { + fetch_results.append(app.allocator, fr) catch { + fr.deinit(); + continue; + }; + candle_map.put(a.symbol, fr.data) catch continue; + } + } + + if (state.view) |*v| v.deinit(app.allocator); + state.view = review_view.buildReview( + app.allocator, + summary, + &candle_map, + if (state.dividend_map) |*dm| dm else null, + pf, + state.classification_map orelse return, + app.portfolio.account_map, + app.today, + app.anchorPath() orelse "", + ) catch { + app.setStatus("Error computing review"); + return; + }; + + applySort(state); +} + +fn freeDividendMap(state: *State, app: *App) void { + freeDividendMapWithAllocator(state, app.allocator); +} + +/// State teardown that doesn't require an App. Lets tests exercise +/// the cleanup path directly under `testing.allocator`. +pub fn deinitState(state: *State, allocator: std.mem.Allocator) void { + if (state.view) |*v| v.deinit(allocator); + if (state.classification_map) |*cm| cm.deinit(); + freeDividendMapWithAllocator(state, allocator); + state.* = .{}; +} + +fn freeDividendMapWithAllocator(state: *State, allocator: std.mem.Allocator) void { + const dm_opt = &state.dividend_map; + if (dm_opt.* == null) return; + var dm = dm_opt.*.?; + var it = dm.iterator(); + while (it.next()) |entry| { + zfin.Dividend.freeSlice(allocator, @constCast(entry.value_ptr.*)); + } + dm.deinit(); + dm_opt.* = null; +} + +// ── Rendering ───────────────────────────────────────────────── + +/// Column widths in display-column order. Tax% is LAST: it's a +/// contextual hint, not a primary metric, and 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; +const col_sharpe: usize = 8; +const col_maxdd: usize = 10; +const col_tax: usize = 7; + +/// Display-column header tag for each column, used by the header +/// renderer, the click hit-test, and (via `sortField`) the sort +/// dispatcher when the user clicks a column header. +const Col = enum { + symbol, + sector, + weight, + return_1y, + return_3y, + return_5y, + return_10y, + vol_3y, + vol_10y, + sharpe_3y, + sharpe_10y, + maxdd_5y, + tax, + + fn width(self: Col) usize { + return switch (self) { + .symbol => col_symbol, + .sector => col_sector, + .weight => col_weight, + .return_1y, .return_3y, .return_5y, .return_10y => col_pct, + .vol_3y, .vol_10y => col_pct, + .sharpe_3y, .sharpe_10y => col_sharpe, + .maxdd_5y => col_maxdd, + .tax => col_tax, + }; + } + + fn header(self: Col) []const u8 { + return switch (self) { + .symbol => "Symbol", + .sector => "Sector", + .weight => "Wt%", + .return_1y => "1Y", + .return_3y => "3Y", + .return_5y => "5Y", + .return_10y => "10Y", + .vol_3y => "3Y-Vol", + .vol_10y => "10Y-Vol", + .sharpe_3y => "3Y-SR", + .sharpe_10y => "10Y-SR", + .maxdd_5y => "5Y-MaxDD", + .tax => "Tax%", + }; + } + + /// Map this column to the corresponding view-level SortField. + /// All review columns are sortable today, but the function + /// returns an optional so future "non-sortable" columns + /// (e.g. an action button) can decline. + fn sortField(self: Col) ?review_view.SortField { + return switch (self) { + .symbol => .symbol, + .sector => .sector, + .weight => .weight, + .return_1y => .return_1y, + .return_3y => .return_3y, + .return_5y => .return_5y, + .return_10y => .return_10y, + .vol_3y => .vol_3y, + .vol_10y => .vol_10y, + .sharpe_3y => .sharpe_3y, + .sharpe_10y => .sharpe_10y, + .maxdd_5y => .maxdd_5y, + .tax => .tax_pct, + }; + } + + /// Default sort direction for this column when freshly + /// selected. String columns sort ascending (alphabetical); + /// numeric columns sort descending (best first). MaxDD is the + /// odd one — descending puts the WORST drawdowns first, which + /// is what the user actually wants ("show me the most-bruised + /// holdings"). Vol same logic. + fn defaultDir(self: Col) review_view.SortDirection { + return switch (self) { + .symbol, .sector => .asc, + else => .desc, + }; + } +}; + +/// Order in which columns appear in the rendered row. +const col_order = [_]Col{ + .symbol, .sector, + .weight, .return_1y, + .return_3y, .return_5y, + .return_10y, .vol_3y, + .vol_10y, .sharpe_3y, + .sharpe_10y, .maxdd_5y, + .tax, +}; + +/// Bytes consumed by `RowBuilder.prefix` at the start of every +/// row. Click hit-testing offsets by this amount. +const row_prefix_cols: usize = 2; + +/// Map a click column (`mouse.col`, screen columns from the left) +/// onto a `Col` value, accounting for the row prefix and the +/// single-space gap between columns. Returns null when the click +/// lands in the prefix, on a gap, or past the right edge of the +/// last column. +fn hitTestHeader(click_col: usize) ?Col { + if (click_col < row_prefix_cols) return null; + var pos = row_prefix_cols; + inline for (col_order, 0..) |col, idx| { + if (idx > 0) { + // Gap column: clicks here are unambiguously between + // two columns. Treat them as "no hit" rather than + // surprising the user by attributing them to either + // neighbor. + if (click_col == pos) return null; + pos += 1; + } + const col_end = pos + col.width(); + if (click_col >= pos and click_col < col_end) return col; + pos = col_end; + } + return null; +} + +/// Builds a row's text + per-cell style spans simultaneously. Each +/// `cell()` call appends one cell (padded to its target display width) +/// and records a span for any non-default intent. +const RowBuilder = struct { + arena: std.mem.Allocator, + th: theme.Theme, + text: std.ArrayList(u8) = .empty, + spans: std.ArrayList(StyleSpan) = .empty, + /// Display column the next cell will start at. Updated by + /// `cell()` after each append. + col: usize = 0, + + /// Append the leading row-prefix indent (matches " " at the + /// start of every line in the file). + fn prefix(self: *RowBuilder) !void { + try self.text.appendSlice(self.arena, " "); + self.col += 2; + } + + /// Append a padded cell with content `s` (already-formatted text) + /// at display width `w`. Alignment: right (numeric / dash cells). + /// Records a style span if `intent` resolves to a non-default + /// style (positive/negative/warning/muted). + fn cellRight(self: *RowBuilder, s: []const u8, w: usize, intent: fmt.StyleIntent) !void { + var pad_buf: [64]u8 = undefined; + const padded = fmt.padLeftToCols(&pad_buf, s, w); + try self.appendCell(padded, w, intent); + } + + /// Append a left-aligned padded cell (used for symbol, sector + /// labels). Pads with trailing spaces. Truncates input to width + /// when oversize. Display-column aware on the input length so + /// abbreviated multibyte sectors align correctly. + fn cellLeft(self: *RowBuilder, s: []const u8, w: usize, intent: fmt.StyleIntent) !void { + const trimmed = fmt.truncateToCols(s, w); + var pad_buf: [128]u8 = undefined; + const have_cols = fmt.displayCols(trimmed); + const pad_cols = if (have_cols < w) w - have_cols else 0; + // Layout: [content bytes][pad_cols spaces]. + if (trimmed.len + pad_cols > pad_buf.len) { + try self.appendCell(trimmed, w, intent); + return; + } + @memcpy(pad_buf[0..trimmed.len], trimmed); + @memset(pad_buf[trimmed.len .. trimmed.len + pad_cols], ' '); + try self.appendCell(pad_buf[0 .. trimmed.len + pad_cols], w, intent); + } + + /// Append a single-space gap between cells. + fn gap(self: *RowBuilder) !void { + try self.text.append(self.arena, ' '); + self.col += 1; + } + + fn appendCell( + self: *RowBuilder, + padded: []const u8, + w: usize, + intent: fmt.StyleIntent, + ) !void { + const start = self.col; + try self.text.appendSlice(self.arena, padded); + // Trust that `padded` is exactly `w` display columns wide + // (every caller guarantees that via padLeft/padRight). + const end = start + w; + self.col = end; + if (intent != .normal) { + try self.spans.append(self.arena, .{ + .start = start, + .end = end, + .style = self.th.styleFor(intent), + }); + } + } + + fn build(self: *RowBuilder, base_style: vaxis.Style) !StyledLine { + const text = try self.text.toOwnedSlice(self.arena); + const spans = try self.spans.toOwnedSlice(self.arena); + return .{ + .text = text, + .style = base_style, + .spans = if (spans.len > 0) spans else null, + }; + } +}; + +pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const StyledLine { + var lines: std.ArrayList(StyledLine) = .empty; + const th = app.theme; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Portfolio Review", .style = th.headerStyle() }); + + const view = state.view orelse { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " No data. Load a portfolio with -p .", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + }; + + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " As of {f} Liquid: {f} Holdings: {d}", .{ + view.as_of, Money.from(view.total_liquid), view.rows.len, + }), + .style = th.mutedStyle(), + }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Header row — purple+bold (`headerStyle`) with sort indicators + // on the active column. Record the row index so `handleMouse` + // can detect column-header clicks for click-to-sort. + state.header_row = lines.items.len; + try lines.append(arena, try buildHeaderLine(arena, th, state.sort_field, state.sort_dir)); + + // Sort-status indicator (so user sees what they're sorted by). + const sort_label = sortFieldLabel(state.sort_field); + const dir_arrow: []const u8 = if (state.sort_dir == .asc) "↑" else "↓"; + try lines.append(arena, .{ + .text = try std.fmt.allocPrint(arena, " Sort: {s} {s} [<] prev [>] next [o] reverse", .{ + sort_label, dir_arrow, + }), + .style = th.dimStyle(), + }); + + // Rows. + for (view.rows) |r| { + try lines.append(arena, try formatRow(arena, th, r)); + } + + // Totals separator. + try lines.append(arena, try buildSeparatorLine(arena, th)); + + // Totals row. + try lines.append(arena, try formatTotalsRow(arena, th, view.totals)); + + if (anyReweightFlag(view.totals.reweight_flags)) { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ + .text = " * Reweighted: at least one holding lacked full-window candle coverage.", + .style = th.mutedStyle(), + }); + try lines.append(arena, .{ + .text = " Affected metrics renormalized weights across participating holdings.", + .style = th.mutedStyle(), + }); + } + + return lines.toOwnedSlice(arena); +} + +/// Build the column-header row, with sort indicators on the +/// active column and the same purple/bold header style the +/// portfolio tab uses. The active sort column embeds a `▲`/`▼` +/// glyph (one display column, replacing one space of padding) so +/// the user can see at a glance which column they're sorted by +/// without reading the "Sort:" line below. +fn buildHeaderLine( + arena: std.mem.Allocator, + th: theme.Theme, + sort_field: review_view.SortField, + sort_dir: review_view.SortDirection, +) !StyledLine { + var rb: RowBuilder = .{ .arena = arena, .th = th }; + try rb.prefix(); + // Per-column header, with `colLabel` baking in a sort indicator + // when the column matches the active sort field. `colLabel` + // returns a slice whose display width equals the column width; + // we hand it to `appendCell` directly without re-padding. + inline for (col_order, 0..) |col, idx| { + if (idx > 0) try rb.gap(); + const ind: ?[]const u8 = blk: { + const col_sf = col.sortField() orelse break :blk null; + if (sort_field == col_sf) break :blk sort_dir.indicator(); + break :blk null; + }; + // `comptime` widths required by colLabel. + const w = comptime col.width(); + var buf: [64]u8 = undefined; + const left_align = (col == .symbol or col == .sector); + const lbl = tui.colLabel(&buf, col.header(), w, left_align, ind); + try rb.appendCell(lbl, w, .normal); + } + return rb.build(th.headerStyle()); +} + +fn buildSeparatorLine(arena: std.mem.Allocator, th: theme.Theme) !StyledLine { + var sep: std.ArrayList(u8) = .empty; + try sep.appendSlice(arena, " "); + inline for (col_order, 0..) |col, idx| { + if (idx > 0) try sep.append(arena, ' '); + var k: usize = 0; + while (k < col.width()) : (k += 1) try sep.appendSlice(arena, "─"); + } + return .{ .text = try sep.toOwnedSlice(arena), .style = th.mutedStyle() }; +} + +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 sortFieldLabel(f: review_view.SortField) []const u8 { + return switch (f) { + .symbol => "Symbol", + // Sector sort bakes in a symbol-asc tiebreaker (see + // `views/review.sortRows`); the suffix tells the user + // why VTI ends up before AAPL inside the same sector. + .sector => "Sector (Symbol tiebreaker)", + .weight => "Weight", + .tax_pct => "Tax%", + .return_1y => "1Y Return", + .return_3y => "3Y Return", + .return_5y => "5Y Return", + .return_10y => "10Y Return", + .vol_3y => "3Y Vol", + .vol_10y => "10Y Vol", + .sharpe_3y => "3Y Sharpe", + .sharpe_10y => "10Y Sharpe", + .maxdd_5y => "5Y MaxDD", + }; +} + +/// Format a holding row with per-cell intent-driven coloring. +fn formatRow( + arena: std.mem.Allocator, + th: theme.Theme, + r: review_view.ReviewRow, +) !StyledLine { + var rb: RowBuilder = .{ .arena = arena, .th = th }; + try rb.prefix(); + + var pct_buf: [16]u8 = undefined; + + try rb.cellLeft(r.symbol, col_symbol, .normal); + try rb.gap(); + + try rb.cellLeft(zfin.analysis.abbreviateSector(r.sector_mid), col_sector, .normal); + try rb.gap(); + + try rb.cellRight(fmt.fmtPct(&pct_buf, r.weight, .{}), col_weight, .normal); + try rb.gap(); + + var b1: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&b1, r.return_1y, .{ .signed = true }), col_pct, review_view.returnIntent(r.return_1y)); + try rb.gap(); + var b2: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&b2, r.return_3y, .{ .signed = true }), col_pct, review_view.returnIntent(r.return_3y)); + try rb.gap(); + var b3: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&b3, r.return_5y, .{ .signed = true }), col_pct, review_view.returnIntent(r.return_5y)); + try rb.gap(); + var b4: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&b4, r.return_10y, .{ .signed = true }), col_pct, review_view.returnIntent(r.return_10y)); + try rb.gap(); + + var v3: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&v3, r.vol_3y, .{}), col_pct, review_view.volIntent(r.vol_3y)); + try rb.gap(); + var v10: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&v10, r.vol_10y, .{}), col_pct, review_view.volIntent(r.vol_10y)); + try rb.gap(); + + var s3: [16]u8 = undefined; + try rb.cellRight(fmt.fmtSharpeOpt(&s3, r.sharpe_3y, .{}), col_sharpe, review_view.sharpeIntent(r.sharpe_3y)); + try rb.gap(); + var s10: [16]u8 = undefined; + try rb.cellRight(fmt.fmtSharpeOpt(&s10, r.sharpe_10y, .{}), col_sharpe, review_view.sharpeIntent(r.sharpe_10y)); + try rb.gap(); + + var dd: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&dd, r.maxdd_5y, .{}), col_maxdd, review_view.maxddIntent(r.maxdd_5y)); + try rb.gap(); + + var tax: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&tax, r.tax_pct, .{}), col_tax, .muted); + + return rb.build(th.contentStyle()); +} + +fn formatTotalsRow( + arena: std.mem.Allocator, + th: theme.Theme, + t: review_view.ReviewTotals, +) !StyledLine { + var rb: RowBuilder = .{ .arena = arena, .th = th }; + try rb.prefix(); + + try rb.cellLeft("Total", col_symbol, .normal); + try rb.gap(); + try rb.cellLeft("", col_sector, .normal); + try rb.gap(); + + var pct_buf: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPct(&pct_buf, t.weight, .{}), col_weight, .normal); + try rb.gap(); + + var b1: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&b1, t.return_1y, .{ .signed = true, .asterisk = t.reweight_flags.return_1y }), col_pct, review_view.returnIntent(t.return_1y)); + try rb.gap(); + var b2: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&b2, t.return_3y, .{ .signed = true, .asterisk = t.reweight_flags.return_3y }), col_pct, review_view.returnIntent(t.return_3y)); + try rb.gap(); + var b3: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&b3, t.return_5y, .{ .signed = true, .asterisk = t.reweight_flags.return_5y }), col_pct, review_view.returnIntent(t.return_5y)); + try rb.gap(); + var b4: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&b4, t.return_10y, .{ .signed = true, .asterisk = t.reweight_flags.return_10y }), col_pct, review_view.returnIntent(t.return_10y)); + try rb.gap(); + + var v3: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&v3, t.vol_3y, .{ .asterisk = t.reweight_flags.vol_3y }), col_pct, review_view.volIntent(t.vol_3y)); + try rb.gap(); + var v10: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&v10, t.vol_10y, .{ .asterisk = t.reweight_flags.vol_10y }), col_pct, review_view.volIntent(t.vol_10y)); + try rb.gap(); + + var s3: [16]u8 = undefined; + try rb.cellRight(fmt.fmtSharpeOpt(&s3, t.sharpe_3y, .{ .asterisk = t.reweight_flags.sharpe_3y }), col_sharpe, review_view.sharpeIntent(t.sharpe_3y)); + try rb.gap(); + var s10: [16]u8 = undefined; + try rb.cellRight(fmt.fmtSharpeOpt(&s10, t.sharpe_10y, .{ .asterisk = t.reweight_flags.sharpe_10y }), col_sharpe, review_view.sharpeIntent(t.sharpe_10y)); + try rb.gap(); + + var dd: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&dd, t.maxdd_5y, .{ .asterisk = t.reweight_flags.maxdd_5y }), col_maxdd, review_view.maxddIntent(t.maxdd_5y)); + try rb.gap(); + + var tax: [16]u8 = undefined; + try rb.cellRight(fmt.fmtPctOpt(&tax, t.tax_pct, .{}), col_tax, .muted); + + return rb.build(th.headerStyle()); +} + +// ── Tests ───────────────────────────────────────────────────── + +const testing = std.testing; + +test "nextSortField: cycles forward and wraps at end" { + // sortable_fields starts with .symbol; from .symbol → .sector. + try testing.expectEqual(review_view.SortField.sector, nextSortField(.symbol)); + // From the last entry, wraps to the first. + try testing.expectEqual(review_view.SortField.symbol, nextSortField(.maxdd_5y)); +} + +test "prevSortField: cycles backward and wraps at start" { + try testing.expectEqual(review_view.SortField.symbol, nextSortField(.maxdd_5y)); + // .symbol is first; prev wraps to last (.maxdd_5y). + try testing.expectEqual(review_view.SortField.maxdd_5y, prevSortField(.symbol)); + try testing.expectEqual(review_view.SortField.symbol, prevSortField(.sector)); +} + +test "Col.sortField: every column has a sort target" { + inline for (std.meta.fields(Col)) |f| { + const c: Col = @enumFromInt(f.value); + try testing.expect(c.sortField() != null); + } +} + +test "Col.defaultDir: strings asc, numerics desc" { + try testing.expectEqual(review_view.SortDirection.asc, Col.symbol.defaultDir()); + try testing.expectEqual(review_view.SortDirection.asc, Col.sector.defaultDir()); + try testing.expectEqual(review_view.SortDirection.desc, Col.weight.defaultDir()); + try testing.expectEqual(review_view.SortDirection.desc, Col.return_3y.defaultDir()); + try testing.expectEqual(review_view.SortDirection.desc, Col.maxdd_5y.defaultDir()); + try testing.expectEqual(review_view.SortDirection.desc, Col.tax.defaultDir()); +} + +test "hitTestHeader: prefix click misses" { + try testing.expect(hitTestHeader(0) == null); + try testing.expect(hitTestHeader(1) == null); +} + +test "hitTestHeader: column-start hits" { + // Symbol cell starts at col 2 (after the 2-col prefix). + try testing.expectEqual(Col.symbol, hitTestHeader(2).?); + try testing.expectEqual(Col.symbol, hitTestHeader(9).?); // last col of symbol (width 8) + // Gap between symbol(end=10) and sector starts at 10 → null. + try testing.expect(hitTestHeader(10) == null); + // Sector occupies cols 11..30 (width 20). + try testing.expectEqual(Col.sector, hitTestHeader(11).?); + try testing.expectEqual(Col.sector, hitTestHeader(30).?); + // Gap at 31 → null; weight at 32..38 (width 7). + try testing.expect(hitTestHeader(31) == null); + try testing.expectEqual(Col.weight, hitTestHeader(32).?); +} + +test "hitTestHeader: tax (last column) is hittable" { + // Tax is the rightmost column. Compute its expected start by + // walking col_order — easier than hardcoding column-end math + // that drifts if a future change inserts a column. + var pos: usize = row_prefix_cols; + inline for (col_order, 0..) |c, idx| { + if (idx > 0) pos += 1; // gap + if (c == .tax) break; + pos += c.width(); + } + try testing.expectEqual(Col.tax, hitTestHeader(pos).?); + try testing.expectEqual(Col.tax, hitTestHeader(pos + Col.tax.width() - 1).?); + // Past the right edge → null. + try testing.expect(hitTestHeader(pos + Col.tax.width()) == 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 "sortFieldLabel: covers every field variant" { + inline for (std.meta.fields(review_view.SortField)) |f| { + const variant: review_view.SortField = @enumFromInt(f.value); + const label = sortFieldLabel(variant); + try testing.expect(label.len > 0); + } +} + +test "formatRow: produces a styled line containing symbol + sector" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + 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, + }; + const line = try formatRow(arena, theme.default_theme, r); + try testing.expect(std.mem.indexOf(u8, line.text, "VTI") != null); + try testing.expect(std.mem.indexOf(u8, line.text, "Diversified") != null); + try testing.expect(std.mem.indexOf(u8, line.text, "+15.0%") != null); +} + +test "formatRow: nulls render as em-dashes" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + 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, + }; + const line = try formatRow(arena, theme.default_theme, r); + try testing.expect(std.mem.count(u8, line.text, "—") >= 8); +} + +test "formatRow: abbreviates Communication Services" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const r: review_view.ReviewRow = .{ + .symbol = "GOOGL", + .sector_mid = "Communication Services", + .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, + }; + const line = try formatRow(arena, theme.default_theme, r); + try testing.expect(std.mem.indexOf(u8, line.text, "Comm. Services") != null); + try testing.expect(std.mem.indexOf(u8, line.text, "Communication Services") == null); +} + +test "formatTotalsRow: contains Total label and weight" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + 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 = .{}, + }; + const line = try formatTotalsRow(arena, theme.default_theme, t); + try testing.expect(std.mem.indexOf(u8, line.text, "Total") != null); + try testing.expect(std.mem.indexOf(u8, line.text, "100.0%") != null); + try testing.expect(std.mem.indexOf(u8, line.text, "+20.0%") != null); +} + +test "formatTotalsRow: reweight flag adds asterisk" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + 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 }, + }; + const line = try formatTotalsRow(arena, theme.default_theme, t); + try testing.expect(std.mem.indexOf(u8, line.text, "13.0%*") != null); +} + +test "applySort: explicit field replaces default grouping" { + var rows = [_]review_view.ReviewRow{ + .{ + .symbol = "AAPL", + .sector_mid = "Technology", + .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, + }, + .{ + .symbol = "VTI", + .sector_mid = "Equity / Corporate", + .tax_pct = null, + .weight = 0.40, + .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, + }, + .{ + .symbol = "MSFT", + .sector_mid = "Technology", + .tax_pct = null, + .weight = 0.15, + .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, + }, + }; + + const view: review_view.ReviewView = .{ + .rows = rows[0..], + .totals = .{ + .weight = 1.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 = .{}, + }, + .as_of = zfin.Date.fromYmd(2026, 6, 4), + .total_liquid = 0, + .portfolio_path = "", + }; + var state: State = .{ .view = view, .sort_field = .sector, .sort_dir = .asc }; + applySort(&state); + // Default grouping (sector asc with symbol-asc tiebreaker) → + // Equity/Corporate first (only VTI), then Technology with + // AAPL before MSFT (alphabetical). + try testing.expectEqualStrings("Equity / Corporate", state.view.?.rows[0].sector_mid); + try testing.expectEqualStrings("VTI", state.view.?.rows[0].symbol); + try testing.expectEqualStrings("Technology", state.view.?.rows[1].sector_mid); + try testing.expectEqualStrings("AAPL", state.view.?.rows[1].symbol); + try testing.expectEqualStrings("Technology", state.view.?.rows[2].sector_mid); + try testing.expectEqualStrings("MSFT", state.view.?.rows[2].symbol); + + // Explicit weight desc + state.sort_field = .weight; + state.sort_dir = .desc; + applySort(&state); + try testing.expectEqualStrings("VTI", state.view.?.rows[0].symbol); + try testing.expectEqualStrings("MSFT", state.view.?.rows[1].symbol); + try testing.expectEqualStrings("AAPL", state.view.?.rows[2].symbol); +} + +test "applyHeaderClick: prefix click does not modify state" { + var state: State = .{ .sort_field = .sector, .sort_dir = .asc }; + try testing.expect(!applyHeaderClick(&state, 0)); + try testing.expect(!applyHeaderClick(&state, 1)); + // State unchanged. + try testing.expectEqual(review_view.SortField.sector, state.sort_field); + try testing.expectEqual(review_view.SortDirection.asc, state.sort_dir); +} + +test "applyHeaderClick: gap-column click does not modify state" { + var state: State = .{ .sort_field = .sector, .sort_dir = .asc }; + // Symbol cell ends at col 9 (prefix=2, width=8). Gap is col 10. + try testing.expect(!applyHeaderClick(&state, 10)); + try testing.expectEqual(review_view.SortField.sector, state.sort_field); +} + +test "applyHeaderClick: click on Symbol column sets sort to symbol with default asc" { + var state: State = .{ .sort_field = .weight, .sort_dir = .desc }; + // Symbol occupies cols 2..9. + try testing.expect(applyHeaderClick(&state, 2)); + try testing.expectEqual(review_view.SortField.symbol, state.sort_field); + try testing.expectEqual(review_view.SortDirection.asc, state.sort_dir); +} + +test "applyHeaderClick: click on numeric column sets sort with default desc" { + var state: State = .{ .sort_field = .symbol, .sort_dir = .asc }; + // Weight column starts at col 32 (see hitTestHeader test). + try testing.expect(applyHeaderClick(&state, 32)); + try testing.expectEqual(review_view.SortField.weight, state.sort_field); + try testing.expectEqual(review_view.SortDirection.desc, state.sort_dir); +} + +test "applyHeaderClick: clicking active column flips direction" { + var state: State = .{ .sort_field = .weight, .sort_dir = .desc }; + // Re-click weight column. + try testing.expect(applyHeaderClick(&state, 32)); + try testing.expectEqual(review_view.SortField.weight, state.sort_field); + try testing.expectEqual(review_view.SortDirection.asc, state.sort_dir); + // Click again — flip back. + try testing.expect(applyHeaderClick(&state, 32)); + try testing.expectEqual(review_view.SortDirection.desc, state.sort_dir); +} + +test "handleAction: sort_col_next walks through fields" { + var state: State = .{ .sort_field = .symbol, .sort_dir = .asc }; + // handleAction takes *App but never reads it; pass undefined. + var app: App = undefined; + tab.handleAction(&state, &app, .sort_col_next); + try testing.expectEqual(review_view.SortField.sector, state.sort_field); +} + +test "handleAction: sort_col_prev walks backward" { + var state: State = .{ .sort_field = .sector, .sort_dir = .asc }; + var app: App = undefined; + tab.handleAction(&state, &app, .sort_col_prev); + try testing.expectEqual(review_view.SortField.symbol, state.sort_field); +} + +test "handleAction: sort_reverse flips direction" { + var state: State = .{ .sort_field = .sector, .sort_dir = .asc }; + var app: App = undefined; + tab.handleAction(&state, &app, .sort_reverse); + try testing.expectEqual(review_view.SortDirection.desc, state.sort_dir); + tab.handleAction(&state, &app, .sort_reverse); + try testing.expectEqual(review_view.SortDirection.asc, state.sort_dir); +} + +test "deinitState: cleans up view + classification_map + dividend_map (leak check)" { + // Allocate a real ReviewView + populate dividend_map with allocated + // slices. deinitState must free everything for testing.allocator + // to pass. + const Date = zfin.Date; + + var allocs = [_]@import("../analytics/valuation.zig").Allocation{ + .{ + .symbol = "VTI", + .display_symbol = "VTI", + .shares = 100, + .avg_cost = 200, + .current_price = 220, + .market_value = 22000, + .cost_basis = 20000, + .weight = 1.0, + .unrealized_gain_loss = 2000, + .unrealized_return = 0.10, + }, + }; + const summary: @import("../analytics/valuation.zig").PortfolioSummary = .{ + .total_value = 22000, + .total_cost = 20000, + .unrealized_gain_loss = 2000, + .unrealized_return = 0.10, + .realized_gain_loss = 0, + .allocations = allocs[0..], + }; + var lots = [_]zfin.Lot{ + .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200 }, + }; + const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator }; + var class_entries = [_]zfin.classification.ClassificationEntry{ + .{ .symbol = "VTI", .sector = "Equity / Corporate", .pct = 100.0 }, + }; + const cm: zfin.classification.ClassificationMap = .{ + .entries = class_entries[0..], + .allocator = testing.allocator, + }; + var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator); + defer candle_map.deinit(); + + const view = try review_view.buildReview( + testing.allocator, + summary, + &candle_map, + null, + portfolio, + cm, + null, + Date.fromYmd(2026, 6, 4), + "test.srf", + ); + + // Build a dividend map with one allocated entry to exercise the + // freeSlice path in deinit. The slice is a single Dividend record; + // freeSlice frees both the inner allocations (none here, just + // primitive fields) and the outer slice. + var dividend_map = std.StringHashMap([]const zfin.Dividend).init(testing.allocator); + const divs = try testing.allocator.alloc(zfin.Dividend, 1); + divs[0] = .{ .ex_date = Date.fromYmd(2024, 6, 15), .pay_date = Date.fromYmd(2024, 7, 1), .amount = 1.5 }; + try dividend_map.put("VTI", divs); + + var state: State = .{ + .loaded = true, + .view = view, + .classification_map = null, // owned by caller in this test + .dividend_map = dividend_map, + .sort_field = .sector, + .sort_dir = .asc, + }; + + // Single deinit must free everything we allocated above. + deinitState(&state, testing.allocator); + try testing.expect(state.view == null); + try testing.expect(state.dividend_map == null); +} diff --git a/src/views/review.zig b/src/views/review.zig new file mode 100644 index 0000000..db8d3cb --- /dev/null +++ b/src/views/review.zig @@ -0,0 +1,1178 @@ +//! `review` — per-holding performance and risk dashboard. +//! +//! Renderer-agnostic view model for the `zfin review` CLI command and +//! the `review` TUI tab. Same shape as other view modules in this +//! directory: produces `StyleIntent`-bearing data, no rendering. +//! +//! Each row covers one holding and combines: +//! +//! - **Sector** (mid-granularity: Communication Services, Healthcare, +//! Bonds, Cash & Equivalents, ...) — derived from `metadata.srf` +//! classifications + `analytics/analysis.zig`'s sector bucketing. +//! - **Tax%** — fraction of the holding's market value held in +//! taxable accounts, computed by walking the per-lot `account` field +//! against `accounts.srf` (`AccountMap.taxTypeFor`). +//! - **Weight** — share of the liquid portfolio. +//! - **Trailing returns** at 1Y/3Y/5Y/10Y, month-end total-return +//! methodology (Morningstar-aligned). Falls back to adj_close +//! returns when explicit dividend data is unavailable. +//! - **Risk metrics** at 3Y and 10Y for vol/Sharpe (the short-vs-long +//! pairing surfaces "is this holding good or just lucky lately?") +//! plus 5Y for max drawdown (captures the 2022 bear without 2020's +//! COVID drawdown flooding every row). +//! +//! A bottom **portfolio totals row** uses true correlation-aware +//! portfolio risk via `analytics/portfolio_risk.zig`'s synthetic-series +//! construction. Holdings without sufficient candle coverage drop out +//! of longer windows via per-window weight renormalization; affected +//! totals-row cells are flagged so renderers can mark them. + +const std = @import("std"); +const zfin = @import("../root.zig"); +const analysis = @import("../analytics/analysis.zig"); +const performance = @import("../analytics/performance.zig"); +const risk = @import("../analytics/risk.zig"); +const portfolio_risk = @import("../analytics/portfolio_risk.zig"); +const classification = @import("../models/classification.zig"); +const valuation = @import("../analytics/valuation.zig"); +const format = @import("../format.zig"); +const Date = zfin.Date; + +/// Sortable columns. The TUI tab cycles through these via `>`/`<`; +/// the CLI accepts kebab-case names via `--sort`. +pub const SortField = enum { + sector, + symbol, + weight, + tax_pct, + return_1y, + return_3y, + return_5y, + return_10y, + vol_3y, + vol_10y, + sharpe_3y, + sharpe_10y, + maxdd_5y, +}; + +pub const SortDirection = enum { + asc, + desc, + + pub fn flip(self: SortDirection) SortDirection { + return if (self == .asc) .desc else .asc; + } + + /// Visual indicator glyph for the active sort column header + /// (▲ for ascending, ▼ for descending). Same glyphs as + /// `portfolio_tab.SortDirection.indicator` for consistency. + pub fn indicator(self: SortDirection) []const u8 { + return if (self == .asc) "▲" else "▼"; + } +}; + +/// One row per holding. +pub const ReviewRow = struct { + /// Display ticker — same convention as `Allocation.display_symbol`. + symbol: []const u8, + /// Mid-granularity sector label (`analytics/analysis.midBucket` + /// applied to the user's `metadata.srf` classification). Falls back + /// to "Unclassified" when the symbol has no classification entry. + sector_mid: []const u8, + /// Fraction of the holding's market value in taxable accounts + /// (0.0 = fully tax-advantaged, 1.0 = fully taxable). Computed + /// from per-lot accounts; null when the AccountMap is missing + /// (we don't know how to classify, so we say so). + tax_pct: ?f64, + /// Share of the liquid portfolio (= `Allocation.weight`). + weight: f64, + /// Trailing month-end total returns (Morningstar-aligned). Each + /// `?f64` is the annualized CAGR for that window; null when the + /// holding has insufficient candle coverage. + return_1y: ?f64, + return_3y: ?f64, + return_5y: ?f64, + return_10y: ?f64, + /// 3Y vol (annualized stdev of monthly returns). Null when <12 + /// monthly returns are available in the 3Y window. + vol_3y: ?f64, + /// 10Y vol — same shape as 3Y. + vol_10y: ?f64, + /// 3Y Sharpe ratio. Null with the same condition as vol_3y. + sharpe_3y: ?f64, + /// 10Y Sharpe ratio. + sharpe_10y: ?f64, + /// 5Y max drawdown (positive decimal, e.g. 0.30 = 30%). + maxdd_5y: ?f64, +}; + +/// Bottom totals row. +pub const ReviewTotals = struct { + /// Always 1.0 (sums of per-row weights). + weight: f64, + /// Weighted-average per-position trailing returns. These DON'T use + /// the synthetic-series math — they're the straight weighted average + /// of per-position returns, matching how `benchmark.zig` + /// computes `portfolio_returns`. We keep this convention because + /// (a) per-position trailing returns are total-return-with-dividends, + /// which the synthetic series doesn't preserve cleanly, and (b) + /// the user's intuition for "what did my portfolio return" matches + /// the weighted-average shape. + return_1y: ?f64, + return_3y: ?f64, + return_5y: ?f64, + return_10y: ?f64, + /// True correlation-aware portfolio vol/Sharpe/MaxDD from synthetic + /// series. Different from a weighted average of per-position vols + /// (typically 20-40% lower for a diversified portfolio). + vol_3y: ?f64, + vol_10y: ?f64, + sharpe_3y: ?f64, + sharpe_10y: ?f64, + maxdd_5y: ?f64, + /// Weighted-average tax %. + tax_pct: ?f64, + /// Per-window flags marking metrics that required holding-dropout + /// renormalization. Renderers append `*` to flagged cells and emit + /// a footnote explaining the asterisk. + reweight_flags: portfolio_risk.ReweightFlags, +}; + +/// Complete view: rows + totals + envelope. +pub const ReviewView = struct { + rows: []ReviewRow, + totals: ReviewTotals, + /// Reference date the view was computed for. + as_of: Date, + /// Total liquid portfolio value (informational; matches + /// `PortfolioSummary.total_value`). + total_liquid: f64, + /// Anchor portfolio file path for the header line. Borrowed. + portfolio_path: []const u8, + + pub fn deinit(self: *ReviewView, allocator: std.mem.Allocator) void { + allocator.free(self.rows); + } +}; + +// ── Build ───────────────────────────────────────────────────── + +/// Build the review view from a loaded portfolio. The caller is +/// responsible for having already populated: +/// +/// - `summary.allocations`: per-symbol market values + weights +/// - `candle_map`: symbol → daily candle slices (cached or fetched) +/// - `dividend_map` (optional): symbol → dividend slices, used to +/// compute total-return numbers. Pass `null` to fall back to +/// adj_close-derived returns. (Most providers bake dividends into +/// adj_close, so the fallback is usually fine — `withDividendFallback` +/// picks whichever number is higher per period.) +/// - `classifications`: parsed `metadata.srf` +/// - `account_map` (optional): parsed `accounts.srf`. When null, +/// Tax% is reported as null on every row. +/// +/// `as_of` is the reference date for trailing-window math. +pub fn buildReview( + allocator: std.mem.Allocator, + summary: valuation.PortfolioSummary, + candle_map: *const std.StringHashMap([]const zfin.Candle), + dividend_map: ?*const std.StringHashMap([]const zfin.Dividend), + portfolio: zfin.Portfolio, + classifications: classification.ClassificationMap, + account_map: ?analysis.AccountMap, + as_of: Date, + portfolio_path: []const u8, +) !ReviewView { + var rows = try std.ArrayList(ReviewRow).initCapacity(allocator, summary.allocations.len); + errdefer rows.deinit(allocator); + + // Synthetic-risk position list — we build it during the row pass so + // we don't double-walk allocations. + var positions = try std.ArrayList(portfolio_risk.PositionCandles).initCapacity(allocator, summary.allocations.len); + defer positions.deinit(allocator); + + for (summary.allocations) |a| { + const candles = candle_map.get(a.symbol) orelse &.{}; + const dividends: ?[]const zfin.Dividend = if (dividend_map) |dm| + (dm.get(a.symbol) orelse null) + else + null; + + const sector_mid = sectorForSymbol(a.symbol, classifications); + const tax_pct = computeTaxPct(a.symbol, portfolio, account_map, as_of); + + const tr_returns = computeTrailingReturns(candles, dividends, as_of); + const tr_risk = if (candles.len > 0) risk.trailingRisk(candles) else risk.TrailingRisk{}; + + try rows.append(allocator, .{ + .symbol = a.display_symbol, + .sector_mid = sector_mid, + .tax_pct = tax_pct, + .weight = a.weight, + .return_1y = annualizedFromResult(tr_returns.one_year, false), + .return_3y = annualizedFromResult(tr_returns.three_year, true), + .return_5y = annualizedFromResult(tr_returns.five_year, true), + .return_10y = annualizedFromResult(tr_returns.ten_year, true), + .vol_3y = if (tr_risk.three_year) |m| m.volatility else null, + .vol_10y = if (tr_risk.ten_year) |m| m.volatility else null, + .sharpe_3y = if (tr_risk.three_year) |m| m.sharpe else null, + .sharpe_10y = if (tr_risk.ten_year) |m| m.sharpe else null, + .maxdd_5y = if (tr_risk.five_year) |m| m.max_drawdown else null, + }); + + try positions.append(allocator, .{ + .symbol = a.symbol, + .candles = candles, + .weight = a.weight, + }); + } + + // Compute correlation-aware portfolio risk for the totals row. + const synth = try portfolio_risk.syntheticPortfolioRisk(allocator, positions.items, as_of); + + const totals = computeTotals(rows.items, synth); + + return .{ + .rows = try rows.toOwnedSlice(allocator), + .totals = totals, + .as_of = as_of, + .total_liquid = summary.total_value, + .portfolio_path = portfolio_path, + }; +} + +// ── Risk-color thresholds ───────────────────────────────────── +// +// Translates volatility / Sharpe / max-drawdown numbers into a +// `StyleIntent` so renderers can color cells consistently. Thresholds +// are hardcoded for now; AGENTS.md commits to making these +// runtime-configurable as a fast-follow once milestone 2 ships. +// +// **Temporal sensitivity.** Vol and Sharpe thresholds are stable +// across decades — Sharpe at 0 / 0.5 are mathematically meaningful +// reference points (risk-free rate as floor, "good" as conventional +// 0.5+), and broad-market vol has hovered ~13-18% on rolling windows +// since the 1950s. The MaxDD thresholds, by contrast, are calibrated +// against whichever crisis sits inside the rolling 5Y window; they +// drift as time passes (see `maxdd_thresholds_last_reviewed` below). +// +// Defaults reflect typical broad-market intuitions: +// +// - Annualized volatility: +// < 12% → calm (green) (broad bond funds, money markets, +// diversified balanced ETFs) +// 12-22% → typical (yellow) (S&P 500 ≈ 15%, total-market ≈ 16%) +// > 22% → high (red) (concentrated single names, small +// caps, sector funds) +// +// - Sharpe ratio (geometric annualized return - rfr) / vol: +// > 0.5 → strong (green) (Sharpe > 1 is standout, but 0.5+ +// covers most healthy holdings over +// reasonable windows) +// 0-0.5 → mediocre (yellow) +// < 0 → losing money on a risk-adjusted basis (red) +// +// - Max drawdown (5Y window — captures the 2022 bear, excludes COVID): +// < 15% → shallow (green) (bond funds, balanced/conservative) +// 15-30% → typical (yellow) (broad equity index ≈ 24-25% in +// the 2022 bear) +// > 30% → deep (red) (concentrated single names, +// sector funds, growth-heavy) +// Same green/yellow/red scheme as vol — there's nothing +// intrinsically "always bad" about a MaxDD number; magnitude +// determines severity just like with vol. + +pub const vol_calm_threshold: f64 = 0.12; +pub const vol_high_threshold: f64 = 0.22; +pub const sharpe_strong_threshold: f64 = 0.5; +pub const sharpe_negative_threshold: f64 = 0.0; +pub const maxdd_shallow_threshold: f64 = 0.15; +pub const maxdd_deep_threshold: f64 = 0.30; + +/// Annual sanity-check anchor for the MaxDD color thresholds above. +/// Vol and Sharpe thresholds are stable across decades and are not +/// registered for nags; MaxDD is the only threshold pair with rolling- +/// window sensitivity: +/// +/// - Today (2026): the 5Y MaxDD window covers 2021-2026 and is +/// anchored on the 2022 bear (~25% on SPY). +/// - ~2027: 2022 starts to roll out of the window. +/// - ~2028+: if no comparable event has happened since, "typical" +/// 5Y MaxDD for broad equity drops materially. The 30% red +/// threshold becomes too lenient. +/// +/// Annual recheck procedure (run on or after the nag date): +/// +/// 1. Run `zfin perf SPY` and read the 5Y Max DD row. SPY should +/// land in the yellow band (15-30%) — this is the "typical +/// broad-equity drawdown" anchor. +/// 2. Run `zfin perf BND`. BND (or any broad bond fund) should +/// land in green (<15%) — the "shallow" anchor. +/// 3. Run `zfin perf NVDA` (or any concentrated growth name). +/// Should land in red (>30%) — the "deep" anchor. +/// +/// If all three still fall in the right bands, just bump the date. +/// If SPY has rolled out of yellow into green (markets calm, 2022 +/// no longer in window), shrink `maxdd_deep_threshold` until the +/// concentrated name lands red and bump the date. If a fresh crisis +/// has pushed everything red, widen `maxdd_deep_threshold` until SPY +/// lands yellow again and bump the date. +/// +/// Registered with the staleness checker in `src/data/staleness.zig`. +pub const maxdd_thresholds_last_reviewed: Date = Date.fromYmd(2026, 6, 5); + +/// Map a volatility value (annualized, decimal) to a StyleIntent. +/// Lower is better. Null returns `.muted` (no data). +pub fn volIntent(v: ?f64) format.StyleIntent { + const val = v orelse return .muted; + if (val < vol_calm_threshold) return .positive; + if (val < vol_high_threshold) return .warning; + return .negative; +} + +/// Map a Sharpe ratio to a StyleIntent. Higher is better. Null +/// returns `.muted` (no data). +pub fn sharpeIntent(v: ?f64) format.StyleIntent { + const val = v orelse return .muted; + if (val < sharpe_negative_threshold) return .negative; + if (val < sharpe_strong_threshold) return .warning; + return .positive; +} + +/// Map a max-drawdown value (positive decimal, e.g. 0.30 = 30% drawdown) +/// to a StyleIntent. Lower (shallower) is better. Null returns `.muted`. +/// Same green/yellow/red scheme as `volIntent` — drawdown magnitude +/// determines severity, not the existence of a drawdown. +pub fn maxddIntent(v: ?f64) format.StyleIntent { + const val = v orelse return .muted; + if (val < maxdd_shallow_threshold) return .positive; + if (val < maxdd_deep_threshold) return .warning; + return .negative; +} + +/// Map a signed return to a StyleIntent — positive = green, negative +/// = red, zero = normal, null = muted. Used for the trailing-return +/// columns where the user reads the sign as a win/loss. +pub fn returnIntent(v: ?f64) format.StyleIntent { + const val = v orelse return .muted; + if (val > 0) return .positive; + if (val < 0) return .negative; + return .normal; +} + +// ── Sorting ─────────────────────────────────────────────────── + +/// In-place sort by the given field/direction. Stable. +/// +/// **Sector sort note:** the sector column applies a symbol-asc +/// pre-pass so rows within a sector group are alphabetized — the +/// "looks random within Technology" problem otherwise. This makes +/// `sortRows(rows, .sector, .asc)` and the older +/// `sortGroupedByDefault` produce identical output, simplifying +/// the entry state for the TUI tab. +pub fn sortRows(rows: []ReviewRow, field: SortField, dir: SortDirection) void { + if (field == .sector) { + // Pre-pass: symbol asc. The stable sort below preserves + // this order within each sector group. + const sym_ctx: SortCtx = .{ .field = .symbol, .dir = .asc }; + std.sort.block(ReviewRow, rows, sym_ctx, sortLessThan); + } + const ctx: SortCtx = .{ .field = field, .dir = dir }; + std.sort.block(ReviewRow, rows, ctx, sortLessThan); +} + +/// Default grouping. Now an alias for `sortRows(rows, .sector, .asc)` +/// — the sector path itself bakes in the symbol-asc tiebreaker, so +/// the two paths produce identical output. Kept as a named function +/// because callers that mean "default state" still read more +/// clearly than a magic field/direction pair. +pub fn sortGroupedByDefault(rows: []ReviewRow) void { + sortRows(rows, .sector, .asc); +} + +const SortCtx = struct { + field: SortField, + dir: SortDirection, +}; + +fn sortLessThan(ctx: SortCtx, a: ReviewRow, b: ReviewRow) bool { + return switch (ctx.field) { + .symbol, .sector => sortStringByDir(extractStr(ctx.field, a), extractStr(ctx.field, b), ctx.dir), + else => sortFloatByDir(extractFloat(ctx.field, a), extractFloat(ctx.field, b), ctx.dir), + }; +} + +fn extractStr(field: SortField, r: ReviewRow) []const u8 { + return switch (field) { + .symbol => r.symbol, + .sector => r.sector_mid, + else => unreachable, + }; +} + +fn extractFloat(field: SortField, r: ReviewRow) ?f64 { + return switch (field) { + .weight => r.weight, + .tax_pct => r.tax_pct, + .return_1y => r.return_1y, + .return_3y => r.return_3y, + .return_5y => r.return_5y, + .return_10y => r.return_10y, + .vol_3y => r.vol_3y, + .vol_10y => r.vol_10y, + .sharpe_3y => r.sharpe_3y, + .sharpe_10y => r.sharpe_10y, + .maxdd_5y => r.maxdd_5y, + else => unreachable, + }; +} + +fn sortStringByDir(a: []const u8, b: []const u8, dir: SortDirection) bool { + return switch (dir) { + .asc => std.mem.order(u8, a, b) == .lt, + .desc => std.mem.order(u8, a, b) == .gt, + }; +} + +/// Float sort comparator with nulls always pinned to the END of the +/// list, regardless of direction. The user's intuition is "show me +/// the best (or worst) values, and put unknowns out of the way at the +/// bottom" — flipping null-position with direction would surprise them. +fn sortFloatByDir(a: ?f64, b: ?f64, dir: SortDirection) bool { + if (a == null and b == null) return false; + if (a == null) return false; // a goes last → not less-than-b + if (b == null) return true; // b goes last → a is less-than-b + return switch (dir) { + .asc => a.? < b.?, + .desc => a.? > b.?, + }; +} + +// ── Internal helpers ────────────────────────────────────────── + +/// Walk the classification map for a symbol. Returns the mid-granularity +/// sector label (a static literal per `analysis.midBucket`) or +/// "Unclassified" if no entry exists. +fn sectorForSymbol(symbol: []const u8, classifications: classification.ClassificationMap) []const u8 { + // Find the FIRST matching classification entry. Multi-row + // classifications (target-date funds with proportional splits) are + // collapsed to their primary sector here — the review view is + // per-holding, not per-classification-component, so we pick the + // most-weighted entry. + var best_pct: f64 = 0; + var best_sector: ?[]const u8 = null; + for (classifications.entries) |e| { + if (!std.mem.eql(u8, e.symbol, symbol)) continue; + if (e.sector == null) continue; + if (e.pct > best_pct) { + best_pct = e.pct; + best_sector = e.sector; + } + } + if (best_sector) |s| return analysis.collapseSector(s, .mid); + return "Unclassified"; +} + +/// Walk the open lots for `symbol` and compute the share of market +/// value held in taxable accounts. Returns null when: +/// - account_map is null (no classification metadata available) +/// - the symbol has no open lots +/// - all lots have unknown accounts (none match in the map) +fn computeTaxPct( + symbol: []const u8, + portfolio: zfin.Portfolio, + account_map: ?analysis.AccountMap, + as_of: Date, +) ?f64 { + const am = account_map orelse return null; + + var taxable_shares: f64 = 0; + var classified_shares: f64 = 0; + for (portfolio.lots) |lot| { + if (!std.mem.eql(u8, lot.symbol, symbol)) continue; + if (!lot.lotIsOpenAsOf(as_of)) continue; + if (lot.security_type != .stock) continue; // options/cash/CDs handled elsewhere + const acct = lot.account orelse continue; + const is_taxable = isTaxableAccount(am, acct); + const is_classified = accountIsKnown(am, acct); + if (!is_classified) continue; + classified_shares += lot.shares; + if (is_taxable) taxable_shares += lot.shares; + } + if (classified_shares <= 0) return null; + return taxable_shares / classified_shares; +} + +fn isTaxableAccount(am: analysis.AccountMap, account: []const u8) bool { + for (am.entries) |e| { + if (std.mem.eql(u8, e.account, account)) { + return e.tax_type == .taxable; + } + } + return false; +} + +fn accountIsKnown(am: analysis.AccountMap, account: []const u8) bool { + for (am.entries) |e| { + if (std.mem.eql(u8, e.account, account)) return true; + } + return false; +} + +/// Compute month-end total-return trailing returns. Falls back to +/// adj_close-only when no dividend data is available, picking the +/// higher value per period via `withDividendFallback`. +fn computeTrailingReturns( + candles: []const zfin.Candle, + dividends: ?[]const zfin.Dividend, + as_of: Date, +) performance.TrailingReturns { + if (candles.len == 0) return .{}; + + const adj = performance.trailingReturnsMonthEnd(candles, as_of); + if (dividends) |divs| { + const div = performance.trailingReturnsMonthEndWithDividends(candles, divs, as_of); + return performance.withDividendFallback(div, adj); + } + return adj; +} + +/// Pull the annualized return out of a `PerformanceResult`. For 1Y, +/// the period is right at the threshold — `annualizedReturn` returns +/// null when the actual span < 0.95 years. We accept that null and +/// fall back to `total_return` only when explicitly told to (e.g. for +/// a 1-year column where a 350-day span is fine to display). +fn annualizedFromResult(r: ?performance.PerformanceResult, require_annualized: bool) ?f64 { + const result = r orelse return null; + if (result.annualized_return) |ann| return ann; + if (require_annualized) return null; + // 1Y window: a sub-1-year actual span (e.g. 360 days) reports + // total_return verbatim — no annualization needed for a window + // that's already supposed to be one year. + return result.total_return; +} + +/// Compute the totals row from `rows` + the synthetic risk numbers. +/// Returns/Tax% are weighted averages over participating rows; risk +/// metrics come from the synthetic series. +fn computeTotals(rows: []const ReviewRow, synth: portfolio_risk.SyntheticRisk) ReviewTotals { + var ret_1y = WeightedAvg{}; + var ret_3y = WeightedAvg{}; + var ret_5y = WeightedAvg{}; + var ret_10y = WeightedAvg{}; + var tax = WeightedAvg{}; + var total_weight: f64 = 0; + for (rows) |r| { + total_weight += r.weight; + ret_1y.add(r.weight, r.return_1y); + ret_3y.add(r.weight, r.return_3y); + ret_5y.add(r.weight, r.return_5y); + ret_10y.add(r.weight, r.return_10y); + tax.add(r.weight, r.tax_pct); + } + + return .{ + .weight = total_weight, + // Prefer the synthetic-series annualized return when we have it + // (correlation-aware compounded return); fall back to the + // weighted average per-position return when not. The synthetic + // version IS more accurate but requires sufficient candle + // coverage to compute. + .return_1y = synth.return_1y orelse ret_1y.value(), + .return_3y = synth.return_3y orelse ret_3y.value(), + .return_5y = synth.return_5y orelse ret_5y.value(), + .return_10y = synth.return_10y orelse ret_10y.value(), + .vol_3y = synth.vol_3y, + .vol_10y = synth.vol_10y, + .sharpe_3y = synth.sharpe_3y, + .sharpe_10y = synth.sharpe_10y, + .maxdd_5y = synth.maxdd_5y, + .tax_pct = tax.value(), + .reweight_flags = synth.reweight_flags, + }; +} + +const WeightedAvg = struct { + sum: f64 = 0, + weight: f64 = 0, + + fn add(self: *WeightedAvg, w: f64, v: ?f64) void { + const x = v orelse return; + self.sum += w * x; + self.weight += w; + } + + fn value(self: WeightedAvg) ?f64 { + if (self.weight <= 0) return null; + return self.sum / self.weight; + } +}; + +// ── Sort flag parsing (CLI helper) ──────────────────────────── + +/// Parse a CLI-style sort flag value (kebab-case) into a `SortField`. +/// Returns null on unknown values; the caller should emit an error +/// message naming the valid options. +/// +/// Accepted values (case-insensitive): +/// sector, symbol, weight, tax, +/// 1y, 3y, 5y, 10y, +/// 3y-vol, 10y-vol, 3y-sharpe, 10y-sharpe, 5y-maxdd. +pub fn parseSortField(s: []const u8) ?SortField { + const map = [_]struct { name: []const u8, field: SortField }{ + .{ .name = "sector", .field = .sector }, + .{ .name = "symbol", .field = .symbol }, + .{ .name = "weight", .field = .weight }, + .{ .name = "tax", .field = .tax_pct }, + .{ .name = "1y", .field = .return_1y }, + .{ .name = "3y", .field = .return_3y }, + .{ .name = "5y", .field = .return_5y }, + .{ .name = "10y", .field = .return_10y }, + .{ .name = "3y-vol", .field = .vol_3y }, + .{ .name = "10y-vol", .field = .vol_10y }, + .{ .name = "3y-sharpe", .field = .sharpe_3y }, + .{ .name = "10y-sharpe", .field = .sharpe_10y }, + .{ .name = "5y-maxdd", .field = .maxdd_5y }, + }; + for (map) |entry| { + if (std.ascii.eqlIgnoreCase(s, entry.name)) return entry.field; + } + return null; +} + +/// All valid sort-field strings, in display order. Used by the CLI +/// error message and the help text. +pub const sort_field_names = [_][]const u8{ + "sector", "symbol", "weight", "tax", + "1y", "3y", "5y", "10y", + "3y-vol", "10y-vol", "3y-sharpe", "10y-sharpe", + "5y-maxdd", +}; + +// ── Tests ──────────────────────────────────────────────────── + +const testing = std.testing; + +fn makeRow(symbol: []const u8, sector: []const u8, weight: f64) ReviewRow { + return .{ + .symbol = symbol, + .sector_mid = sector, + .tax_pct = null, + .weight = weight, + .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, + }; +} + +test "parseSortField: each supported value maps correctly" { + try testing.expectEqual(SortField.sector, parseSortField("sector").?); + try testing.expectEqual(SortField.symbol, parseSortField("symbol").?); + try testing.expectEqual(SortField.weight, parseSortField("weight").?); + try testing.expectEqual(SortField.tax_pct, parseSortField("tax").?); + try testing.expectEqual(SortField.return_1y, parseSortField("1y").?); + try testing.expectEqual(SortField.return_3y, parseSortField("3y").?); + try testing.expectEqual(SortField.return_5y, parseSortField("5y").?); + try testing.expectEqual(SortField.return_10y, parseSortField("10y").?); + try testing.expectEqual(SortField.vol_3y, parseSortField("3y-vol").?); + try testing.expectEqual(SortField.vol_10y, parseSortField("10y-vol").?); + try testing.expectEqual(SortField.sharpe_3y, parseSortField("3y-sharpe").?); + try testing.expectEqual(SortField.sharpe_10y, parseSortField("10y-sharpe").?); + try testing.expectEqual(SortField.maxdd_5y, parseSortField("5y-maxdd").?); +} + +test "parseSortField: case-insensitive" { + try testing.expectEqual(SortField.sharpe_3y, parseSortField("3Y-SHARPE").?); + try testing.expectEqual(SortField.sharpe_3y, parseSortField("3Y-Sharpe").?); + try testing.expectEqual(SortField.return_1y, parseSortField("1Y").?); +} + +test "parseSortField: unknown returns null" { + try testing.expect(parseSortField("bogus") == null); + try testing.expect(parseSortField("") == null); + try testing.expect(parseSortField("3y-foo") == null); +} + +test "SortDirection: flip and indicator" { + try testing.expectEqual(SortDirection.desc, SortDirection.asc.flip()); + try testing.expectEqual(SortDirection.asc, SortDirection.desc.flip()); + try testing.expectEqualStrings("▲", SortDirection.asc.indicator()); + try testing.expectEqualStrings("▼", SortDirection.desc.indicator()); +} + +test "sortRows: by weight desc puts largest first" { + var rows = [_]ReviewRow{ + makeRow("AAPL", "Technology", 0.05), + makeRow("VTI", "Equity / Corporate", 0.40), + makeRow("BND", "Bonds", 0.20), + }; + sortRows(&rows, .weight, .desc); + try testing.expectEqualStrings("VTI", rows[0].symbol); + try testing.expectEqualStrings("BND", rows[1].symbol); + try testing.expectEqualStrings("AAPL", rows[2].symbol); +} + +test "sortRows: by symbol asc is alphabetical" { + var rows = [_]ReviewRow{ + makeRow("VTI", "Equity / Corporate", 0.40), + makeRow("AAPL", "Technology", 0.05), + makeRow("BND", "Bonds", 0.20), + }; + sortRows(&rows, .symbol, .asc); + try testing.expectEqualStrings("AAPL", rows[0].symbol); + try testing.expectEqualStrings("BND", rows[1].symbol); + try testing.expectEqualStrings("VTI", rows[2].symbol); +} + +test "sortRows: by symbol desc is reverse alphabetical (string desc path)" { + var rows = [_]ReviewRow{ + makeRow("AAPL", "Technology", 0.05), + makeRow("VTI", "Equity / Corporate", 0.40), + makeRow("BND", "Bonds", 0.20), + }; + sortRows(&rows, .symbol, .desc); + try testing.expectEqualStrings("VTI", rows[0].symbol); + try testing.expectEqualStrings("BND", rows[1].symbol); + try testing.expectEqualStrings("AAPL", rows[2].symbol); +} + +test "sortRows: by tax_pct asc (covers ascending float comparator path)" { + var rows = [_]ReviewRow{ + .{ .symbol = "A", .sector_mid = "x", .tax_pct = 0.8, .weight = 0.1, .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 }, + .{ .symbol = "B", .sector_mid = "x", .tax_pct = 0.1, .weight = 0.1, .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 }, + .{ .symbol = "C", .sector_mid = "x", .tax_pct = 0.5, .weight = 0.1, .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 }, + }; + sortRows(&rows, .tax_pct, .asc); + try testing.expectEqualStrings("B", rows[0].symbol); + try testing.expectEqualStrings("C", rows[1].symbol); + try testing.expectEqualStrings("A", rows[2].symbol); +} + +test "sortRows: every numeric SortField variant is reachable via extractFloat" { + // Constructs rows where each numeric field has a unique value. + // For each numeric field, we run sortRows(.field, .desc) and + // verify the row with the largest value of THAT field bubbles + // to the front. Catches any future `else => unreachable` arm + // that isn't actually reachable from the dispatcher. + const numeric_fields = [_]SortField{ + .weight, .tax_pct, + .return_1y, .return_3y, + .return_5y, .return_10y, + .vol_3y, .vol_10y, + .sharpe_3y, .sharpe_10y, + .maxdd_5y, + }; + inline for (numeric_fields) |field| { + var rows = [_]ReviewRow{ + .{ .symbol = "low", .sector_mid = "x", .tax_pct = 0.1, .weight = 0.1, .return_1y = 0.1, .return_3y = 0.1, .return_5y = 0.1, .return_10y = 0.1, .vol_3y = 0.1, .vol_10y = 0.1, .sharpe_3y = 0.1, .sharpe_10y = 0.1, .maxdd_5y = 0.1 }, + .{ .symbol = "high", .sector_mid = "x", .tax_pct = 0.9, .weight = 0.9, .return_1y = 0.9, .return_3y = 0.9, .return_5y = 0.9, .return_10y = 0.9, .vol_3y = 0.9, .vol_10y = 0.9, .sharpe_3y = 0.9, .sharpe_10y = 0.9, .maxdd_5y = 0.9 }, + }; + sortRows(&rows, field, .desc); + try testing.expectEqualStrings("high", rows[0].symbol); + sortRows(&rows, field, .asc); + try testing.expectEqualStrings("low", rows[0].symbol); + } +} + +test "computeTrailingReturns: empty candles returns empty struct" { + const tr = computeTrailingReturns(&.{}, null, Date.fromYmd(2026, 6, 1)); + try testing.expect(tr.one_year == null); + try testing.expect(tr.three_year == null); + try testing.expect(tr.five_year == null); + try testing.expect(tr.ten_year == null); +} + +test "computeTrailingReturns: candles with no dividends uses adj_close path" { + // 36 months of 1% monthly growth → ~12.7% annualized. Without + // dividends, this exercises the adj-close-only branch. Even when + // the synthetic data isn't enough to land any specific trailing + // window (depends on calendar arithmetic against the as_of), the + // function shouldn't crash and should return a struct. + var candles: [36]zfin.Candle = undefined; + var d = Date.fromYmd(2023, 1, 31); + for (0..36) |i| { + const price: f64 = 100.0 * std.math.pow(f64, 1.01, @as(f64, @floatFromInt(i))); + candles[i] = .{ .date = d, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; + d = d.addDays(30); + } + const tr = computeTrailingReturns(&candles, null, Date.fromYmd(2026, 1, 31)); + // We don't assert on which windows populated — that depends on + // calendar arithmetic against `as_of` and the candle density. + // Coverage of the no-dividends branch (the `else` arm at the + // end of `computeTrailingReturns`) is what we want. + _ = tr; +} + +test "computeTrailingReturns: candles with dividends prefers higher fallback" { + // Same shape, but include a dividends slice. The fallback logic + // takes whichever produces a higher annualized return per period; + // for adj_close already including dividends and an explicit + // dividends slice, the helper picks whichever is higher. Either + // way the path through `withDividendFallback` is exercised. + var candles: [36]zfin.Candle = undefined; + var d = Date.fromYmd(2023, 1, 31); + for (0..36) |i| { + const price: f64 = 100.0 + @as(f64, @floatFromInt(i)); + candles[i] = .{ .date = d, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; + d = d.addDays(30); + } + const divs = [_]zfin.Dividend{ + .{ .ex_date = Date.fromYmd(2023, 6, 15), .pay_date = Date.fromYmd(2023, 7, 1), .amount = 0.5 }, + .{ .ex_date = Date.fromYmd(2024, 6, 15), .pay_date = Date.fromYmd(2024, 7, 1), .amount = 0.5 }, + }; + const tr = computeTrailingReturns(&candles, &divs, Date.fromYmd(2026, 1, 31)); + // We don't need to assert exact values — coverage of the + // dividend-fallback branch is the goal. + _ = tr; +} + +test "computeTaxPct: returns null when account_map is null" { + var lots = [_]zfin.Lot{ + .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage" }, + }; + const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator }; + const result = computeTaxPct("VTI", portfolio, null, Date.fromYmd(2026, 1, 1)); + try testing.expect(result == null); +} + +test "computeTaxPct: returns null when no lots match symbol" { + var lots = [_]zfin.Lot{ + .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage" }, + }; + const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Brokerage", .tax_type = .taxable }, + }; + const am: analysis.AccountMap = .{ .entries = entries[0..], .allocator = testing.allocator }; + const result = computeTaxPct("AAPL", portfolio, am, Date.fromYmd(2026, 1, 1)); + try testing.expect(result == null); +} + +test "computeTaxPct: returns null when all matching lots are in unknown accounts" { + var lots = [_]zfin.Lot{ + .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "MysteryAccount" }, + }; + const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Brokerage", .tax_type = .taxable }, + }; + const am: analysis.AccountMap = .{ .entries = entries[0..], .allocator = testing.allocator }; + const result = computeTaxPct("VTI", portfolio, am, Date.fromYmd(2026, 1, 1)); + // No matching account in map → all classified shares = 0 → null. + try testing.expect(result == null); +} + +test "computeTaxPct: mixed accounts produces partial tax%" { + var lots = [_]zfin.Lot{ + .{ .symbol = "VTI", .shares = 60, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage" }, + .{ .symbol = "VTI", .shares = 40, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 200, .account = "Roth IRA" }, + }; + const portfolio: zfin.Portfolio = .{ .lots = lots[0..], .allocator = testing.allocator }; + var entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Brokerage", .tax_type = .taxable }, + .{ .account = "Roth IRA", .tax_type = .roth }, + }; + const am: analysis.AccountMap = .{ .entries = entries[0..], .allocator = testing.allocator }; + const result = computeTaxPct("VTI", portfolio, am, Date.fromYmd(2026, 1, 1)); + // 60 / 100 = 0.6 in taxable. + try testing.expectApproxEqAbs(@as(f64, 0.6), result.?, 0.001); +} + +test "annualizedFromResult: null result returns null" { + try testing.expect(annualizedFromResult(null, false) == null); + try testing.expect(annualizedFromResult(null, true) == null); +} + +test "annualizedFromResult: result with annualized_return passes through" { + const r: performance.PerformanceResult = .{ + .total_return = 0.50, + .annualized_return = 0.18, + .from = Date.fromYmd(2022, 1, 1), + .to = Date.fromYmd(2025, 1, 1), + }; + try testing.expectApproxEqAbs(@as(f64, 0.18), annualizedFromResult(r, true).?, 0.001); + try testing.expectApproxEqAbs(@as(f64, 0.18), annualizedFromResult(r, false).?, 0.001); +} + +test "annualizedFromResult: short window (no annualized) returns total when not required" { + const r: performance.PerformanceResult = .{ + .total_return = 0.10, + .annualized_return = null, // sub-1-year window + .from = Date.fromYmd(2024, 1, 1), + .to = Date.fromYmd(2024, 8, 1), + }; + try testing.expect(annualizedFromResult(r, true) == null); + try testing.expectApproxEqAbs(@as(f64, 0.10), annualizedFromResult(r, false).?, 0.001); +} + +test "sortGroupedByDefault: groups by sector then symbol asc within group" { + var rows = [_]ReviewRow{ + makeRow("AAPL", "Technology", 0.05), + makeRow("VTI", "Equity / Corporate", 0.40), + makeRow("MSFT", "Technology", 0.15), + makeRow("BND", "Bonds", 0.20), + makeRow("AGG", "Bonds", 0.10), + }; + sortGroupedByDefault(&rows); + // Sectors alphabetical: Bonds → Equity / Corporate → Technology. + // Within each sector, symbols alphabetical (deterministic, easy + // to scan for a specific ticker). + try testing.expectEqualStrings("Bonds", rows[0].sector_mid); + try testing.expectEqualStrings("AGG", rows[0].symbol); + try testing.expectEqualStrings("Bonds", rows[1].sector_mid); + try testing.expectEqualStrings("BND", rows[1].symbol); + try testing.expectEqualStrings("Equity / Corporate", rows[2].sector_mid); + try testing.expectEqualStrings("VTI", rows[2].symbol); + try testing.expectEqualStrings("Technology", rows[3].sector_mid); + try testing.expectEqualStrings("AAPL", rows[3].symbol); + try testing.expectEqualStrings("Technology", rows[4].sector_mid); + try testing.expectEqualStrings("MSFT", rows[4].symbol); +} + +test "sortRows: nulls sort to end on both directions" { + var rows_desc = [_]ReviewRow{ + .{ .symbol = "A", .sector_mid = "x", .tax_pct = null, .weight = 0.1, .return_1y = null, .return_3y = 0.10, .return_5y = null, .return_10y = null, .vol_3y = null, .vol_10y = null, .sharpe_3y = null, .sharpe_10y = null, .maxdd_5y = null }, + .{ .symbol = "B", .sector_mid = "x", .tax_pct = null, .weight = 0.1, .return_1y = null, .return_3y = 0.20, .return_5y = null, .return_10y = null, .vol_3y = null, .vol_10y = null, .sharpe_3y = null, .sharpe_10y = null, .maxdd_5y = null }, + .{ .symbol = "C", .sector_mid = "x", .tax_pct = null, .weight = 0.1, .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 }, + }; + sortRows(&rows_desc, .return_3y, .desc); + try testing.expectEqualStrings("B", rows_desc[0].symbol); // 0.20 + try testing.expectEqualStrings("A", rows_desc[1].symbol); // 0.10 + try testing.expectEqualStrings("C", rows_desc[2].symbol); // null at end +} + +test "WeightedAvg: skips null and weights correctly" { + var avg = WeightedAvg{}; + avg.add(0.5, 0.10); + avg.add(0.3, 0.20); + avg.add(0.2, null); // skipped + // Effective: (0.5 × 0.10 + 0.3 × 0.20) / (0.5 + 0.3) = 0.11/0.8 = 0.1375 + try testing.expectApproxEqAbs(@as(f64, 0.1375), avg.value().?, 0.0001); +} + +test "WeightedAvg: all-null returns null" { + var avg = WeightedAvg{}; + avg.add(0.5, null); + avg.add(0.5, null); + try testing.expect(avg.value() == null); +} + +test "computeTotals: weighted average returns + synthetic risk pass-through" { + const rows = [_]ReviewRow{ + .{ .symbol = "A", .sector_mid = "x", .tax_pct = 1.0, .weight = 0.6, .return_1y = 0.10, .return_3y = 0.10, .return_5y = null, .return_10y = null, .vol_3y = 0.20, .vol_10y = null, .sharpe_3y = 1.0, .sharpe_10y = null, .maxdd_5y = 0.30 }, + .{ .symbol = "B", .sector_mid = "y", .tax_pct = 0.0, .weight = 0.4, .return_1y = 0.20, .return_3y = 0.05, .return_5y = null, .return_10y = null, .vol_3y = 0.10, .vol_10y = null, .sharpe_3y = 0.5, .sharpe_10y = null, .maxdd_5y = 0.10 }, + }; + const synth: portfolio_risk.SyntheticRisk = .{ + .vol_3y = 0.13, + .sharpe_3y = 0.9, + .maxdd_5y = 0.18, + .return_3y = 0.08, + }; + const t = computeTotals(&rows, synth); + // Weighted-avg returns when synth doesn't carry total_return: + // 1Y: 0.6×0.10 + 0.4×0.20 = 0.14 + try testing.expectApproxEqAbs(@as(f64, 0.14), t.return_1y.?, 0.0001); + // 3Y: synth's return_3y wins (0.08). + try testing.expectApproxEqAbs(@as(f64, 0.08), t.return_3y.?, 0.0001); + // Tax%: 0.6×1.0 + 0.4×0.0 = 0.6 + try testing.expectApproxEqAbs(@as(f64, 0.6), t.tax_pct.?, 0.0001); + // Risk numbers pass through directly from synth. + try testing.expectApproxEqAbs(@as(f64, 0.13), t.vol_3y.?, 0.0001); + try testing.expectApproxEqAbs(@as(f64, 0.9), t.sharpe_3y.?, 0.0001); + try testing.expectApproxEqAbs(@as(f64, 0.18), t.maxdd_5y.?, 0.0001); +} + +test "sectorForSymbol: returns Unclassified for unknown symbol" { + var entries = [_]classification.ClassificationEntry{}; + const cm: classification.ClassificationMap = .{ + .entries = entries[0..], + .allocator = testing.allocator, + }; + try testing.expectEqualStrings("Unclassified", sectorForSymbol("NOPE", cm)); +} + +test "sectorForSymbol: returns mid-bucket for classified symbol" { + var entries = [_]classification.ClassificationEntry{ + .{ .symbol = "AAPL", .sector = "Technology", .pct = 100.0 }, + }; + const cm: classification.ClassificationMap = .{ + .entries = entries[0..], + .allocator = testing.allocator, + }; + // Technology is a GICS sector; mid-bucket passes it through. + try testing.expectEqualStrings("Technology", sectorForSymbol("AAPL", cm)); +} + +test "sectorForSymbol: collapses NPORT-P sub-flavors via mid-bucket" { + var entries = [_]classification.ClassificationEntry{ + .{ .symbol = "BND", .sector = "Debt / US Treasury", .pct = 100.0 }, + }; + const cm: classification.ClassificationMap = .{ + .entries = entries[0..], + .allocator = testing.allocator, + }; + // "Debt / *" maps to "Bonds" at mid granularity. + try testing.expectEqualStrings("Bonds", sectorForSymbol("BND", cm)); +} + +test "volIntent: thresholds bucketize correctly" { + try testing.expectEqual(format.StyleIntent.muted, volIntent(null)); + try testing.expectEqual(format.StyleIntent.positive, volIntent(0.05)); + try testing.expectEqual(format.StyleIntent.positive, volIntent(0.119)); + try testing.expectEqual(format.StyleIntent.warning, volIntent(0.12)); + try testing.expectEqual(format.StyleIntent.warning, volIntent(0.18)); + try testing.expectEqual(format.StyleIntent.warning, volIntent(0.219)); + try testing.expectEqual(format.StyleIntent.negative, volIntent(0.22)); + try testing.expectEqual(format.StyleIntent.negative, volIntent(0.45)); +} + +test "sharpeIntent: thresholds bucketize correctly" { + try testing.expectEqual(format.StyleIntent.muted, sharpeIntent(null)); + try testing.expectEqual(format.StyleIntent.negative, sharpeIntent(-0.1)); + try testing.expectEqual(format.StyleIntent.warning, sharpeIntent(0.0)); + try testing.expectEqual(format.StyleIntent.warning, sharpeIntent(0.3)); + try testing.expectEqual(format.StyleIntent.warning, sharpeIntent(0.499)); + try testing.expectEqual(format.StyleIntent.positive, sharpeIntent(0.5)); + try testing.expectEqual(format.StyleIntent.positive, sharpeIntent(1.5)); +} + +test "maxddIntent: thresholds bucketize correctly" { + try testing.expectEqual(format.StyleIntent.muted, maxddIntent(null)); + // Shallow drawdown — bonds, balanced funds. + try testing.expectEqual(format.StyleIntent.positive, maxddIntent(0.05)); + try testing.expectEqual(format.StyleIntent.positive, maxddIntent(0.149)); + // Typical equity-index territory (2022 bear ≈ 25%). + try testing.expectEqual(format.StyleIntent.warning, maxddIntent(0.15)); + try testing.expectEqual(format.StyleIntent.warning, maxddIntent(0.25)); + try testing.expectEqual(format.StyleIntent.warning, maxddIntent(0.299)); + // Deep drawdown — concentrated, growth-heavy, sector funds. + try testing.expectEqual(format.StyleIntent.negative, maxddIntent(0.30)); + try testing.expectEqual(format.StyleIntent.negative, maxddIntent(0.50)); +} + +test "returnIntent: signs map to gain/loss colors" { + try testing.expectEqual(format.StyleIntent.muted, returnIntent(null)); + try testing.expectEqual(format.StyleIntent.positive, returnIntent(0.10)); + try testing.expectEqual(format.StyleIntent.negative, returnIntent(-0.05)); + try testing.expectEqual(format.StyleIntent.normal, returnIntent(0.0)); +} + +test "buildReview: end-to-end with testing allocator (leak check)" { + // Allocator detects leaks: every allocation must be freed for the + // test to pass. Exercises the full buildReview lifecycle including + // ReviewView.deinit. Also stress-tests the sort helper afterward + // since sortRows touches the same memory. + const Candle = zfin.Candle; + const D = zfin.Date; + const Lot = @import("../models/portfolio.zig").Lot; + + // Two-symbol portfolio: VTI (taxable) + BND (Roth). + var allocs = [_]valuation.Allocation{ + .{ + .symbol = "VTI", + .display_symbol = "VTI", + .shares = 100, + .avg_cost = 200, + .current_price = 220, + .market_value = 22000, + .cost_basis = 20000, + .weight = 0.6, + .unrealized_gain_loss = 2000, + .unrealized_return = 0.10, + .account = "Brokerage", + }, + .{ + .symbol = "BND", + .display_symbol = "BND", + .shares = 100, + .avg_cost = 80, + .current_price = 75, + .market_value = 7500, + .cost_basis = 8000, + .weight = 0.4, + .unrealized_gain_loss = -500, + .unrealized_return = -0.0625, + .account = "Roth IRA", + }, + }; + const summary: valuation.PortfolioSummary = .{ + .total_value = 29500, + .total_cost = 28000, + .unrealized_gain_loss = 1500, + .unrealized_return = 0.0535, + .realized_gain_loss = 0, + .allocations = allocs[0..], + }; + + var lots = [_]Lot{ + .{ .symbol = "VTI", .shares = 100, .open_date = D.fromYmd(2022, 1, 10), .open_price = 200, .account = "Brokerage" }, + .{ .symbol = "BND", .shares = 100, .open_date = D.fromYmd(2022, 1, 10), .open_price = 80, .account = "Roth IRA" }, + }; + const portfolio: zfin.Portfolio = .{ + .lots = lots[0..], + .allocator = testing.allocator, + }; + + // Empty candle/dividend maps — buildReview handles missing data + // gracefully (fields render as null). + var candle_map = std.StringHashMap([]const Candle).init(testing.allocator); + defer candle_map.deinit(); + + var class_entries = [_]classification.ClassificationEntry{ + .{ .symbol = "VTI", .sector = "Equity / Corporate", .pct = 100.0 }, + .{ .symbol = "BND", .sector = "Debt / US Treasury", .pct = 100.0 }, + }; + const cm: classification.ClassificationMap = .{ + .entries = class_entries[0..], + .allocator = testing.allocator, + }; + + var account_entries = [_]analysis.AccountTaxEntry{ + .{ .account = "Brokerage", .tax_type = .taxable }, + .{ .account = "Roth IRA", .tax_type = .roth }, + }; + const am: analysis.AccountMap = .{ + .entries = account_entries[0..], + .allocator = testing.allocator, + }; + + var view = try buildReview( + testing.allocator, + summary, + &candle_map, + null, // no dividend map + portfolio, + cm, + am, + D.fromYmd(2026, 6, 4), + "test_portfolio.srf", + ); + defer view.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 2), view.rows.len); + try testing.expectApproxEqAbs(@as(f64, 1.0), view.totals.weight, 0.001); + + // Tax%: VTI is in taxable account (1.0), BND in Roth (0.0). + // Find each row by symbol since order isn't yet sorted. + for (view.rows) |r| { + if (std.mem.eql(u8, r.symbol, "VTI")) { + try testing.expectApproxEqAbs(@as(f64, 1.0), r.tax_pct.?, 0.001); + try testing.expectEqualStrings("Equity / Corporate", r.sector_mid); + } else if (std.mem.eql(u8, r.symbol, "BND")) { + try testing.expectApproxEqAbs(@as(f64, 0.0), r.tax_pct.?, 0.001); + try testing.expectEqualStrings("Bonds", r.sector_mid); + } + } + + // Sort exercises the same memory; should not leak. + sortGroupedByDefault(view.rows); + sortRows(view.rows, .weight, .desc); + sortRows(view.rows, .symbol, .asc); +}