diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3843eac --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.zig-cache/ +zig-out/ +.env +*.srf diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..0ac46dc --- /dev/null +++ b/.mise.toml @@ -0,0 +1,3 @@ +[tools] +zig = "0.15.2" +zls = "0.15.1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2252f67 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Emil Lerch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d031060 --- /dev/null +++ b/README.md @@ -0,0 +1,345 @@ +# zfin + +A financial data library, CLI, and terminal UI written in Zig. Tracks portfolios, analyzes trailing returns, displays options chains, earnings history, and more -- all from the terminal. + +## Quick start + +```bash +# Set at least one API key (see "API keys" below) +export TWELVEDATA_API_KEY=your_key + +# Build +zig build + +# CLI usage +zig build run -- perf VTI # trailing returns +zig build run -- quote AAPL # real-time quote +zig build run -- options AAPL # options chains +zig build run -- earnings MSFT # earnings history + +# Interactive TUI +zig build run -- i # auto-loads portfolio.srf from cwd +zig build run -- i -p portfolio.srf -w watchlist.srf +zig build run -- i -s AAPL # start with a symbol, no portfolio +``` + +Requires Zig 0.15.2 or later. + +## Data providers + +zfin aggregates data from multiple free-tier APIs. Each provider is used for the data it does best, and aggressive caching keeps usage well within free-tier limits. + +### Provider summary + +| Data type | Provider | Auth | Free-tier limit | Cache TTL | +|---|---|---|---|---| +| Daily candles (OHLCV) | TwelveData | `TWELVEDATA_API_KEY` | 8 req/min, 800/day | 24 hours | +| Real-time quotes | TwelveData | `TWELVEDATA_API_KEY` | 8 req/min, 800/day | Never cached | +| Dividends | Polygon | `POLYGON_API_KEY` | 5 req/min | 7 days | +| Splits | Polygon | `POLYGON_API_KEY` | 5 req/min | 7 days | +| Options chains | CBOE | None required | ~30 req/min (self-imposed) | 1 hour | +| Earnings | Finnhub | `FINNHUB_API_KEY` | 60 req/min | 24 hours | +| ETF profiles | Alpha Vantage | `ALPHAVANTAGE_API_KEY` | 25 req/day | 30 days | + +### TwelveData + +**Used for:** daily candles and real-time quotes. + +- Endpoint: `https://api.twelvedata.com/time_series` and `/quote` +- Free tier: 8 API credits per minute, 800 per day. Each symbol in a request costs 1 credit (batch requests do NOT reduce credit cost). +- Candles are fetched with a 10-year + 60-day lookback window for trailing return calculations. +- Returns split-adjusted but NOT dividend-adjusted prices. Total returns are computed separately using Polygon dividend data. +- Quotes are never cached (always a live fetch, ~15 min delay on free tier). + +### Polygon + +**Used for:** dividend history and stock splits. + +- Endpoints: `https://api.polygon.io/v3/reference/dividends` and `/v3/reference/splits` +- Free tier: 5 requests per minute, unlimited daily. Full historical dividend/split data. +- Dividend endpoint uses cursor-based pagination (automatically followed). +- Provides dividend type classification (regular, special, supplemental). + +### CBOE + +**Used for:** options chains. + +- Endpoint: `https://cdn.cboe.com/api/global/delayed_quotes/options/{SYMBOL}.json` +- No API key required. Data is 15-minute delayed during market hours. +- Returns all expirations with full chains including greeks (delta, gamma, theta, vega), bid/ask, volume, open interest, and implied volatility. +- OCC option symbols are parsed to extract expiration, strike, and contract type. + +### Finnhub + +**Used for:** earnings calendar (historical and upcoming). + +- Endpoint: `https://finnhub.io/api/v1/calendar/earnings` +- Free tier: 60 requests per minute. +- Fetches 5 years back and 1 year forward from today. +- Note: Finnhub requires TLS 1.2. Since Zig's HTTP client only supports TLS 1.3, requests to Finnhub automatically fall back to system `curl`. + +### Alpha Vantage + +**Used for:** ETF profiles (expense ratio, holdings, sector weights). + +- Endpoint: `https://www.alphavantage.co/query?function=ETF_PROFILE` +- Free tier: 25 requests per day. Used sparingly -- ETF profiles rarely change. + +## API keys + +Set keys as environment variables or in a `.env` file (searched in the executable's parent directory, then cwd): + +```bash +TWELVEDATA_API_KEY=your_key # Required for candles and quotes +POLYGON_API_KEY=your_key # Required for dividends/splits (total returns) +FINNHUB_API_KEY=your_key # Required for earnings data +ALPHAVANTAGE_API_KEY=your_key # Required for ETF profiles +``` + +The cache directory defaults to `~/.cache/zfin` and can be overridden with `ZFIN_CACHE_DIR`. + +Not all keys are required. Without a key, the corresponding data simply won't be available: + +| Key | Without it | +|---|---| +| `TWELVEDATA_API_KEY` | No candles, quotes, or trailing returns | +| `POLYGON_API_KEY` | No dividends -- trailing returns show price-only (no total return) | +| `FINNHUB_API_KEY` | No earnings data (tab disabled) | +| `ALPHAVANTAGE_API_KEY` | No ETF profiles | + +CBOE options require no API key. + +## Caching strategy + +Every data fetch follows the same pattern: + +1. Check local cache (`~/.cache/zfin/{SYMBOL}/{data_type}.srf`) +2. If cached file exists and is within TTL -- deserialize and return (no network) +3. Otherwise -- fetch from provider -- serialize to cache -- return + +Cache files use [SRF](https://github.com/lobo/srf) (Simple Record Format), a line-oriented key-value format. Freshness is determined by file modification time vs. the TTL for that data type. + +| Data type | TTL | Rationale | +|---|---|---| +| Daily candles | 24 hours | Only changes once per trading day | +| Dividends | 7 days | Declared well in advance | +| Splits | 7 days | Rare corporate events | +| Options | 1 hour | Prices change continuously during market hours | +| Earnings | 24 hours | Quarterly events, estimates update periodically | +| ETF profiles | 30 days | Holdings/weights change slowly | +| Quotes | Never cached | Intended for live price checks | + +Manual refresh (`r` / `F5` in TUI) invalidates the cache for the current tab's data before re-fetching. + +### Rate limiting + +Each provider has a client-side token-bucket rate limiter that prevents exceeding free-tier limits: + +| Provider | Rate limit | +|---|---| +| TwelveData | 8/minute | +| Polygon | 5/minute | +| Finnhub | 60/minute | +| CBOE | 30/minute | +| Alpha Vantage | 25/day | + +The limiter blocks until a token is available, spreading bursts of requests automatically rather than failing with 429 errors. + +## CLI commands + +``` +zfin [args] + +Commands: + perf Trailing returns (1yr/3yr/5yr/10yr, price + total) + quote Real-time quote + history Last 30 days price history + divs Dividend history with TTM yield + splits Split history + options Options chains (all expirations) + earnings Earnings history and upcoming events + etf ETF profile (expense ratio, holdings, sectors) + portfolio Portfolio analysis from .srf file + cache stats Show cached symbols + cache clear Delete all cached data + interactive, i Launch interactive TUI + help Show usage +``` + +### Interactive TUI flags + +``` +zfin i [options] + + -p, --portfolio Portfolio file (.srf format) + -w, --watchlist Watchlist file (default: watchlist.srf if present) + -s, --symbol Start with a specific symbol + --default-keys Print default keybindings config to stdout + --default-theme Print default theme config to stdout +``` + +If no portfolio or symbol is specified and `portfolio.srf` exists in the current directory, it is loaded automatically. + +## Interactive TUI + +The TUI has five tabs: Portfolio, Quote, Performance, Options, and Earnings. + +### Tabs + +**Portfolio** -- navigable list of positions with market value, gain/loss, weight, and purchase date. Multi-lot positions can be expanded to show individual lots with per-lot gain/loss, capital gains indicator (ST/LT), and account name. + +**Quote** -- current price, OHLCV, daily change, and a 60-day ASCII chart with recent history table. + +**Performance** -- trailing returns using two methodologies (as-of-date and month-end), matching Morningstar's "Trailing Returns" and "Performance" pages respectively. Shows price-only and total return (with dividend reinvestment) when Polygon data is available. Also shows risk metrics (volatility, Sharpe ratio, max drawdown). + +**Options** -- all expirations in a navigable list. Expand any expiration to see calls and puts inline. Calls and puts sections are independently collapsible. Near-the-money filter limits strikes shown (default +/- 8, adjustable with Ctrl+1-9). ITM strikes are marked with `|`. Monthly expirations display in normal color, weeklies are dimmed. + +**Earnings** -- historical and upcoming earnings events with EPS estimate/actual, surprise amount and percentage. Future events are dimmed. Tab is disabled for ETFs. + +### Keybindings + +All keybindings are configurable via `~/.config/zfin/keys.srf`. Generate the default config: + +```bash +zfin i --default-keys > ~/.config/zfin/keys.srf +``` + +Default keybindings: + +| Key | Action | +|---|---| +| `q`, `Ctrl+c` | Quit | +| `r`, `F5` | Refresh current tab (invalidates cache) | +| `h`, Left | Previous tab | +| `l`, Right, Tab | Next tab | +| `1`-`5` | Jump to tab | +| `j`, Down | Select next row | +| `k`, Up | Select previous row | +| `Enter` | Expand/collapse (positions, expirations, calls/puts) | +| `s` | Select symbol from portfolio for other tabs | +| `/` | Enter symbol search | +| `e` | Edit portfolio/watchlist in `$EDITOR` | +| `c` | Toggle all calls collapsed/expanded (options tab) | +| `p` | Toggle all puts collapsed/expanded (options tab) | +| `Ctrl+1`-`Ctrl+9` | Set options near-the-money filter to +/- N strikes | +| `g` | Scroll to top | +| `G` | Scroll to bottom | +| `Ctrl+d` | Half-page down | +| `Ctrl+u` | Half-page up | +| `PageDown` | Page down | +| `PageUp` | Page up | +| `?` | Help screen | + +Mouse: scroll wheel navigates, left-click selects rows and switches tabs, double-click expands/collapses. + +### Theme + +The TUI uses a dark theme inspired by Monokai/opencode. Customize via `~/.config/zfin/theme.srf`: + +```bash +zfin i --default-theme > ~/.config/zfin/theme.srf +``` + +Colors are specified as `#rrggbb` hex values. The theme uses RGB colors (not terminal color indices) to work correctly with transparent terminal backgrounds. + +## Portfolio format + +Portfolios are SRF files with one lot per line: + +``` +#!srfv1 +symbol::VTI,shares:num:100,open_date::2024-01-15,open_price:num:220.50 +symbol::AAPL,shares:num:50,open_date::2024-03-01,open_price:num:170.00 +symbol::AAPL,shares:num:25,open_date::2023-06-15,open_price:num:155.00,account::Roth IRA +symbol::AMZN,shares:num:10,open_date::2022-03-15,open_price:num:150.25,close_date::2024-01-15,close_price:num:185.50 +``` + +### Lot fields + +| Field | Type | Required | Description | +|---|---|---|---| +| `symbol` | string | Yes | Ticker symbol | +| `shares` | number | Yes | Number of shares | +| `open_date` | string | Yes | Purchase date (YYYY-MM-DD) | +| `open_price` | number | Yes | Purchase price per share | +| `close_date` | string | No | Sale date (null = open lot) | +| `close_price` | number | No | Sale price per share | +| `note` | string | No | Tag or note | +| `account` | string | No | Account name (e.g. "Roth IRA", "Brokerage") | + +Open lots (no `close_date`) contribute to positions. Closed lots (with `close_date` and `close_price`) show realized P&L. The `account` field is displayed in the lot detail view when a position is expanded. + +### Watchlist format + +A watchlist is an SRF file with just symbol fields: + +``` +#!srfv1 +symbol::NVDA +symbol::TSLA +symbol::GOOG +``` + +Watchlist symbols appear at the bottom of the portfolio tab. They show the latest cached price but no position data. Press Enter or double-click to jump to the Quote tab for that symbol. + +## Architecture + +``` +src/ + root.zig Library root, exports all public types + config.zig Configuration from env vars / .env files + service.zig DataService: cache-check -> fetch -> cache -> return + models/ + candle.zig OHLCV price bars + date.zig Date type with arithmetic, snapping, formatting + dividend.zig Dividend records with type classification + split.zig Stock splits + option.zig Option contracts and chains + earnings.zig Earnings events with surprise calculation + etf_profile.zig ETF profiles with holdings and sectors + portfolio.zig Lots, positions, and portfolio aggregation + quote.zig Real-time quote data + ticker_info.zig Security metadata + providers/ + provider.zig Type-erased provider interface (vtable) + twelvedata.zig TwelveData: candles, quotes + polygon.zig Polygon: dividends, splits + finnhub.zig Finnhub: earnings + cboe.zig CBOE: options chains (no API key) + alphavantage.zig Alpha Vantage: ETF profiles + analytics/ + performance.zig Trailing returns (as-of-date + month-end) + risk.zig Volatility, Sharpe, drawdown, portfolio summary + cache/ + store.zig SRF file cache with TTL freshness checks + net/ + http.zig HTTP client with retries and TLS 1.2 fallback + rate_limiter.zig Token-bucket rate limiter + cli/ + main.zig CLI entry point and all commands + tui/ + main.zig Interactive TUI application + keybinds.zig Configurable keybinding system + theme.zig Configurable color theme +``` + +### Dependencies + +| Dependency | Source | Purpose | +|---|---|---| +| [SRF](https://github.com/lobo/srf) | Local (`../../srf`) | Cache file format and portfolio/watchlist parsing | +| [libvaxis](https://github.com/rockorager/libvaxis) | Git (v0.5.1) | Terminal UI rendering | + +## Building + +```bash +zig build # build the zfin binary +zig build test # run all tests +zig build run -- # build and run +``` + +The compiled binary is at `zig-out/bin/zfin`. + +## License + +MIT diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..6752514 --- /dev/null +++ b/build.zig @@ -0,0 +1,102 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + // External dependencies + const srf_dep = b.dependency("srf", .{ + .target = target, + .optimize = optimize, + }); + + const vaxis_dep = b.dependency("vaxis", .{ + .target = target, + .optimize = optimize, + }); + + // Library module -- the public API for consumers of zfin + const mod = b.addModule("zfin", .{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .imports = &.{ + .{ .name = "srf", .module = srf_dep.module("srf") }, + }, + }); + + // TUI module (imported by the unified binary) + const tui_mod = b.addModule("tui", .{ + .root_source_file = b.path("src/tui/main.zig"), + .target = target, + .imports = &.{ + .{ .name = "zfin", .module = mod }, + .{ .name = "srf", .module = srf_dep.module("srf") }, + .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, + }, + }); + + // Unified executable (CLI + TUI in one binary) + const exe = b.addExecutable(.{ + .name = "zfin", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/cli/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zfin", .module = mod }, + .{ .name = "srf", .module = srf_dep.module("srf") }, + .{ .name = "tui", .module = tui_mod }, + }, + }), + }); + b.installArtifact(exe); + + // Run step: `zig build run -- ` + const run_step = b.step("run", "Run the zfin CLI"); + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // Tests + const test_step = b.step("test", "Run all tests"); + + const mod_tests = b.addTest(.{ .root_module = mod }); + test_step.dependOn(&b.addRunArtifact(mod_tests).step); + + const exe_tests = b.addTest(.{ .root_module = exe.root_module }); + test_step.dependOn(&b.addRunArtifact(exe_tests).step); + + const tui_tests = b.addTest(.{ .root_module = b.createModule(.{ + .root_source_file = b.path("src/tui/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "zfin", .module = mod }, + .{ .name = "srf", .module = srf_dep.module("srf") }, + .{ .name = "vaxis", .module = vaxis_dep.module("vaxis") }, + }, + }) }); + test_step.dependOn(&b.addRunArtifact(tui_tests).step); + + // Docs + const lib = b.addLibrary(.{ + .name = "zfin", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "srf", .module = srf_dep.module("srf") }, + }, + }), + }); + const docs_step = b.step("docs", "Generate documentation"); + docs_step.dependOn(&b.addInstallDirectory(.{ + .source_dir = lib.getEmittedDocs(), + .install_dir = .prefix, + .install_subdir = "docs", + }).step); +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..3644e9d --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,20 @@ +.{ + .name = .zfin, + .version = "0.0.0", + .fingerprint = 0x77a9b4c7d676e027, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .srf = .{ + .path = "../../srf", + }, + .vaxis = .{ + .url = "git+https://github.com/rockorager/libvaxis.git#67bbc1ee072aa390838c66caf4ed47edee282dc4", + .hash = "vaxis-0.5.1-BWNV_IxJCQC5OGNaXQfNnqgn9_Vku0PMgey-dplubcQK", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/src/analytics/performance.zig b/src/analytics/performance.zig new file mode 100644 index 0000000..91a5d9c --- /dev/null +++ b/src/analytics/performance.zig @@ -0,0 +1,501 @@ +const std = @import("std"); +const Date = @import("../models/date.zig").Date; +const Candle = @import("../models/candle.zig").Candle; +const Dividend = @import("../models/dividend.zig").Dividend; + +/// Performance calculation results, Morningstar-style. +pub const PerformanceResult = struct { + /// Total return over the period (e.g., 0.25 = 25%) + total_return: f64, + /// Annualized return (for periods > 1 year) + annualized_return: ?f64, + /// Start date used + from: Date, + /// End date used + to: Date, +}; + +/// Compute total return from adjusted close prices. +/// Candles must be sorted by date ascending. +/// `from` snaps forward (first trading day on/after), `to` snaps backward. +pub fn totalReturnFromAdjClose(candles: []const Candle, from: Date, to: Date) ?PerformanceResult { + return totalReturnFromAdjCloseSnap(candles, from, to, .forward); +} + +/// Same as totalReturnFromAdjClose but both dates snap backward +/// (last trading day on or before). Used for month-end methodology where +/// both from and to represent month-end reference dates. +fn totalReturnFromAdjCloseBackward(candles: []const Candle, from: Date, to: Date) ?PerformanceResult { + return totalReturnFromAdjCloseSnap(candles, from, to, .backward); +} + +fn totalReturnFromAdjCloseSnap(candles: []const Candle, from: Date, to: Date, start_dir: SearchDirection) ?PerformanceResult { + const start = findNearestCandle(candles, from, start_dir) orelse return null; + const end = findNearestCandle(candles, to, .backward) orelse return null; + + if (start.adj_close == 0) return null; + + const total = (end.adj_close / start.adj_close) - 1.0; + const years = Date.yearsBetween(start.date, end.date); + + return .{ + .total_return = total, + .annualized_return = if (years >= 1.0) + std.math.pow(f64, 1.0 + total, 1.0 / years) - 1.0 + else + null, + .from = start.date, + .to = end.date, + }; +} + +/// Compute total return with manual dividend reinvestment. +/// Uses raw close prices and dividend records independently. +/// Candles and dividends must be sorted by date ascending. +/// `from` snaps forward, `to` snaps backward. +pub fn totalReturnWithDividends( + candles: []const Candle, + dividends: []const Dividend, + from: Date, + to: Date, +) ?PerformanceResult { + return totalReturnWithDividendsSnap(candles, dividends, from, to, .forward); +} + +/// Same as totalReturnWithDividends but both dates snap backward. +fn totalReturnWithDividendsBackward( + candles: []const Candle, + dividends: []const Dividend, + from: Date, + to: Date, +) ?PerformanceResult { + return totalReturnWithDividendsSnap(candles, dividends, from, to, .backward); +} + +fn totalReturnWithDividendsSnap( + candles: []const Candle, + dividends: []const Dividend, + from: Date, + to: Date, + start_dir: SearchDirection, +) ?PerformanceResult { + const start = findNearestCandle(candles, from, start_dir) orelse return null; + const end = findNearestCandle(candles, to, .backward) orelse return null; + + if (start.close == 0) return null; + + // Simulate: start with 1 share, reinvest dividends at ex-date close + var shares: f64 = 1.0; + + for (dividends) |div| { + if (div.ex_date.lessThan(start.date)) continue; + if (end.date.lessThan(div.ex_date)) break; + + // Find close price on or near the ex-date + const price_candle = findNearestCandle(candles, div.ex_date, .backward) orelse continue; + if (price_candle.close > 0) { + shares += (div.amount * shares) / price_candle.close; + } + } + + const final_value = shares * end.close; + const total = (final_value / start.close) - 1.0; + const years = Date.yearsBetween(start.date, end.date); + + return .{ + .total_return = total, + .annualized_return = if (years >= 1.0) + std.math.pow(f64, 1.0 + total, 1.0 / years) - 1.0 + else + null, + .from = start.date, + .to = end.date, + }; +} + +/// Convenience: compute 1yr, 3yr, 5yr, 10yr trailing returns from adjusted close. +/// Uses the last available date as the endpoint. +pub const TrailingReturns = struct { + one_year: ?PerformanceResult = null, + three_year: ?PerformanceResult = null, + five_year: ?PerformanceResult = null, + ten_year: ?PerformanceResult = null, +}; + +/// Trailing returns from exact calendar date N years ago to latest candle date. +/// Start dates snap forward to the next trading day (e.g., weekend → Monday). +pub fn trailingReturns(candles: []const Candle) TrailingReturns { + if (candles.len == 0) return .{}; + + const end_date = candles[candles.len - 1].date; + + return .{ + .one_year = totalReturnFromAdjClose(candles, end_date.subtractYears(1), end_date), + .three_year = totalReturnFromAdjClose(candles, end_date.subtractYears(3), end_date), + .five_year = totalReturnFromAdjClose(candles, end_date.subtractYears(5), end_date), + .ten_year = totalReturnFromAdjClose(candles, end_date.subtractYears(10), end_date), + }; +} + +/// Same as trailingReturns but with dividend reinvestment. +pub fn trailingReturnsWithDividends( + candles: []const Candle, + dividends: []const Dividend, +) TrailingReturns { + if (candles.len == 0) return .{}; + + const end_date = candles[candles.len - 1].date; + + return .{ + .one_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(1), end_date), + .three_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(3), end_date), + .five_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(5), end_date), + .ten_year = totalReturnWithDividends(candles, dividends, end_date.subtractYears(10), end_date), + }; +} + +/// Morningstar-style trailing returns using month-end reference dates. +/// End date = last calendar day of prior month. Start date = that month-end minus N years. +/// Both dates snap backward to the last trading day on or before, matching +/// Morningstar's "last business day of the month" convention. +pub fn trailingReturnsMonthEnd(candles: []const Candle, today: Date) TrailingReturns { + if (candles.len == 0) return .{}; + + // End reference = last day of the prior month (snaps backward to last trading day) + const month_end = today.lastDayOfPriorMonth(); + + return .{ + .one_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(1), month_end), + .three_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(3), month_end), + .five_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(5), month_end), + .ten_year = totalReturnFromAdjCloseBackward(candles, month_end.subtractYears(10), month_end), + }; +} + +/// Same as trailingReturnsMonthEnd but with dividend reinvestment. +pub fn trailingReturnsMonthEndWithDividends( + candles: []const Candle, + dividends: []const Dividend, + today: Date, +) TrailingReturns { + if (candles.len == 0) return .{}; + + const month_end = today.lastDayOfPriorMonth(); + + return .{ + .one_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(1), month_end), + .three_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(3), month_end), + .five_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(5), month_end), + .ten_year = totalReturnWithDividendsBackward(candles, dividends, month_end.subtractYears(10), month_end), + }; +} + +const SearchDirection = enum { forward, backward }; + +/// Maximum calendar days a snapped candle can be from the target date. +/// Covers weekends + holidays (e.g., Christmas week). Beyond this, the data +/// is likely missing and the result would be misleading. +const max_snap_days: i32 = 10; + +fn findNearestCandle(candles: []const Candle, target: Date, direction: SearchDirection) ?Candle { + if (candles.len == 0) return null; + + // Binary search: lo = first index where candles[lo].date >= target + var lo: usize = 0; + var hi: usize = candles.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (candles[mid].date.lessThan(target)) { + lo = mid + 1; + } else { + hi = mid; + } + } + + const candidate = switch (direction) { + // First candle on or after target + .forward => if (lo < candles.len) candles[lo] else return null, + // Last candle on or before target + .backward => if (lo < candles.len and candles[lo].date.eql(target)) + candles[lo] + else if (lo > 0) + candles[lo - 1] + else + return null, + }; + + // Reject if the snap distance exceeds tolerance + const gap = candidate.date.days - target.days; + if (gap > max_snap_days or gap < -max_snap_days) return null; + + return candidate; +} + +/// Format a return value as a percentage string (e.g., "12.34%") +pub fn formatReturn(buf: []u8, value: f64) []const u8 { + return std.fmt.bufPrint(buf, "{d:.2}%", .{value * 100.0}) catch "??%"; +} + +test "total return simple" { + const candles = [_]Candle{ + .{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 101, .low = 99, .close = 100, .adj_close = 100, .volume = 1000 }, + .{ .date = Date.fromYmd(2024, 6, 28), .open = 110, .high = 111, .low = 109, .close = 110, .adj_close = 110, .volume = 1000 }, + .{ .date = Date.fromYmd(2024, 12, 31), .open = 120, .high = 121, .low = 119, .close = 120, .adj_close = 120, .volume = 1000 }, + }; + const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); + try std.testing.expect(result != null); + // 120/100 - 1 = 0.20 + try std.testing.expectApproxEqAbs(@as(f64, 0.20), result.?.total_return, 0.001); +} + +test "total return with dividends -- single dividend" { + // Stock at $100, pays $2 dividend, price stays $100. + // Without reinvestment: 0% return. + // With reinvestment: $2/$100 = 0.02 extra shares -> 1.02 * $100 / $100 - 1 = 2% + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 2), 100), + makeCandle(Date.fromYmd(2024, 3, 15), 100), + makeCandle(Date.fromYmd(2024, 12, 31), 100), + }; + const divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 2.0 }, + }; + const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); + try std.testing.expect(result != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.02), result.?.total_return, 0.0001); +} + +test "total return with dividends -- quarterly dividends" { + // Stock at $100 all year, pays $1 quarterly. Each $1 reinvested at $100 = 0.01 shares. + // After Q1: 1.01 shares + // After Q2: 1.01 + 1.01*1/100 = 1.01 * 1.01 = 1.0201 + // After Q3: 1.0201 * 1.01 = 1.030301 + // After Q4: 1.030301 * 1.01 = 1.04060401 + // Total return: 4.06% + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 2), 100), + makeCandle(Date.fromYmd(2024, 3, 15), 100), + makeCandle(Date.fromYmd(2024, 6, 14), 100), + makeCandle(Date.fromYmd(2024, 9, 13), 100), + makeCandle(Date.fromYmd(2024, 12, 13), 100), + makeCandle(Date.fromYmd(2024, 12, 31), 100), + }; + const divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2024, 9, 13), .amount = 1.0 }, + .{ .ex_date = Date.fromYmd(2024, 12, 13), .amount = 1.0 }, + }; + const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); + try std.testing.expect(result != null); + // (1.01)^4 - 1 = 0.04060401 + try std.testing.expectApproxEqAbs(@as(f64, 0.04060401), result.?.total_return, 0.0001); +} + +test "total return with dividends -- price growth plus dividends" { + // Start $100, end $120 (20% price return). + // One $3 dividend at mid-year when price is $110. + // Shares: 1 + 3/110 = 1.027273 + // Final value: 1.027273 * 120 = 123.2727 + // Total return: 123.2727 / 100 - 1 = 23.27% + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 2), 100), + makeCandle(Date.fromYmd(2024, 6, 14), 110), + makeCandle(Date.fromYmd(2024, 12, 31), 120), + }; + const divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 3.0 }, + }; + const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); + try std.testing.expect(result != null); + const expected = (1.0 + 3.0 / 110.0) * 120.0 / 100.0 - 1.0; // 0.23272727... + try std.testing.expectApproxEqAbs(expected, result.?.total_return, 0.0001); +} + +test "annualized return -- 3 year period" { + // 3 years: $100 -> $150. Total return = 50%. + // Annualized = (1.50)^(1/3) - 1 = 14.47% + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2021, 1, 4), 100), + makeCandle(Date.fromYmd(2024, 1, 2), 150), + }; + const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2021, 1, 1), Date.fromYmd(2024, 1, 3)); + try std.testing.expect(result != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.50), result.?.total_return, 0.001); + const ann = result.?.annualized_return.?; + // (1.50)^(1/years) - 1, years ~ 3.0 (via 365.25) + const years = Date.yearsBetween(Date.fromYmd(2021, 1, 4), Date.fromYmd(2024, 1, 2)); + const expected_ann = std.math.pow(f64, 1.50, 1.0 / years) - 1.0; + try std.testing.expectApproxEqAbs(expected_ann, ann, 0.0001); +} + +test "findNearestCandle -- exact match" { + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 2), 100), + makeCandle(Date.fromYmd(2024, 1, 3), 101), + makeCandle(Date.fromYmd(2024, 1, 4), 102), + }; + // Forward exact + const fwd = findNearestCandle(&candles, Date.fromYmd(2024, 1, 3), .forward).?; + try std.testing.expect(fwd.date.eql(Date.fromYmd(2024, 1, 3))); + // Backward exact + const bwd = findNearestCandle(&candles, Date.fromYmd(2024, 1, 3), .backward).?; + try std.testing.expect(bwd.date.eql(Date.fromYmd(2024, 1, 3))); +} + +test "findNearestCandle -- weekend snap" { + // Jan 4 2025 is Saturday, Jan 5 is Sunday + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2025, 1, 3), 100), // Friday + makeCandle(Date.fromYmd(2025, 1, 6), 101), // Monday + }; + // Forward from Saturday -> Monday + const fwd = findNearestCandle(&candles, Date.fromYmd(2025, 1, 4), .forward).?; + try std.testing.expect(fwd.date.eql(Date.fromYmd(2025, 1, 6))); + // Backward from Saturday -> Friday + const bwd = findNearestCandle(&candles, Date.fromYmd(2025, 1, 4), .backward).?; + try std.testing.expect(bwd.date.eql(Date.fromYmd(2025, 1, 3))); +} + +test "month-end trailing returns -- date windowing" { + // Verify month-end logic uses correct reference dates. + // "Today" = 2026-02-15, prior month end = 2026-01-31 + // 1yr window: 2025-01-31 to 2026-01-31 + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2025, 1, 31), 100), // Jan 31 2025 is Friday + makeCandle(Date.fromYmd(2025, 7, 1), 110), + makeCandle(Date.fromYmd(2026, 1, 30), 120), // Jan 31 is Sat, trading day is 30th + makeCandle(Date.fromYmd(2026, 2, 14), 125), + }; + const today = Date.fromYmd(2026, 2, 15); + const ret = trailingReturnsMonthEnd(&candles, today); + // Month-end = Jan 31 2026. backward snap -> Jan 30. + // Start = Jan 31 2025 (exact match, backward snap). End = Jan 30 2026. + // Return = 120/100 - 1 = 20% + try std.testing.expect(ret.one_year != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.20), ret.one_year.?.total_return, 0.001); +} + +test "month-end trailing returns -- weekend start snaps backward" { + // When the start month-end falls on a weekend, it should snap BACKWARD + // to the last trading day (Friday), not forward to Monday. + // This matches Morningstar's "last business day of the month" convention. + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2016, 1, 29), 100), // Friday (last biz day of Jan 2016) + makeCandle(Date.fromYmd(2016, 2, 1), 95), // Monday (NOT what we want) + makeCandle(Date.fromYmd(2026, 1, 30), 240), // End: Friday (last biz day of Jan 2026) + }; + // Jan 31 2016 is Sunday. Backward snap -> Jan 29 (Friday). + // Jan 31 2026 is Saturday. Backward snap -> Jan 30 (Friday). + // Return = 240/100 - 1 = 140% + const today = Date.fromYmd(2026, 2, 15); + const ret = trailingReturnsMonthEnd(&candles, today); + try std.testing.expect(ret.ten_year != null); + try std.testing.expectApproxEqAbs(@as(f64, 1.40), ret.ten_year.?.total_return, 0.001); + // Verify start date is Jan 29 (Friday), not Feb 1 (Monday) + try std.testing.expect(ret.ten_year.?.from.eql(Date.fromYmd(2016, 1, 29))); +} + +test "dividends outside window are excluded" { + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 2), 100), + makeCandle(Date.fromYmd(2024, 6, 14), 100), + makeCandle(Date.fromYmd(2024, 12, 31), 100), + }; + const divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2023, 12, 15), .amount = 5.0 }, // before window + .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 2.0 }, // inside + .{ .ex_date = Date.fromYmd(2025, 3, 15), .amount = 5.0 }, // after window + }; + const result = totalReturnWithDividends(&candles, &divs, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); + try std.testing.expect(result != null); + // Only the $2 mid-year dividend counts: 2/100 = 2% + try std.testing.expectApproxEqAbs(@as(f64, 0.02), result.?.total_return, 0.0001); +} + +test "zero price candle returns null" { + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 2), 0), + makeCandle(Date.fromYmd(2024, 12, 31), 100), + }; + const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); + try std.testing.expect(result == null); +} + +test "empty candles returns null" { + const candles = [_]Candle{}; + const result = totalReturnFromAdjClose(&candles, Date.fromYmd(2024, 1, 1), Date.fromYmd(2025, 1, 1)); + try std.testing.expect(result == null); +} + +fn makeCandle(date: Date, price: f64) Candle { + return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; +} + +// Morningstar reference data, captured 2026-02-24. +// +// AMZN Trailing Returns (as-of-date, from morningstar.com/stocks/xnas/amzn/trailing-returns): +// Day end 2026-02-24: 1yr=-1.95% 3yr=30.66% 5yr=5.71% 10yr=22.37% +// AMZN has no dividends, so price return = total return. +// +// VTI Trailing Returns (as-of-date, from morningstar.com/etfs/arcx/vti/trailing-returns): +// Day end 2026-02-24: 1yr=16.62% 3yr=21.01% 5yr=12.03% 10yr=15.10% (price) +// +// VTI Performance (month-end, from morningstar.com/etfs/arcx/vti/performance): +// Month-end Jan 31: 10yr total=15.10% 3yr total=20.20% (NAV ~20.24%) + +test "as-of-date trailing returns -- AMZN vs Morningstar" { + // Real AMZN split-adjusted closing prices from Twelve Data. + // AMZN pays no dividends, so adj_close == close. + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2016, 2, 24), 27.702), // 10yr start + makeCandle(Date.fromYmd(2021, 2, 24), 157.9765), // 5yr start + makeCandle(Date.fromYmd(2023, 2, 24), 93.50), // 3yr start + makeCandle(Date.fromYmd(2025, 2, 24), 212.71), // 1yr start + makeCandle(Date.fromYmd(2026, 2, 24), 208.56), // end (latest close) + }; + + const ret = trailingReturns(&candles); + + // 1yr: 208.56 / 212.71 - 1 = -1.95% + try std.testing.expect(ret.one_year != null); + try std.testing.expectApproxEqAbs(@as(f64, -0.0195), ret.one_year.?.total_return, 0.001); + + // 3yr: annualized. Morningstar shows 30.66%. + try std.testing.expect(ret.three_year != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.3066), ret.three_year.?.annualized_return.?, 0.002); + + // 5yr: annualized. Morningstar shows 5.71%. + try std.testing.expect(ret.five_year != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.0571), ret.five_year.?.annualized_return.?, 0.002); + + // 10yr: annualized. Morningstar shows 22.37%. + try std.testing.expect(ret.ten_year != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.2237), ret.ten_year.?.annualized_return.?, 0.002); +} + +test "as-of-date vs month-end -- different results from same data" { + // Demonstrates that as-of-date and month-end give different results + // when the latest close differs significantly from the month-end close. + // + // "Today" = 2026-02-25, month-end = Jan 31 2026 + // As-of end = Feb 24 (latest candle), month-end = Jan 30 (snap from Jan 31 Sat) + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2025, 1, 31), 100), // month-end 1yr start (Friday) + makeCandle(Date.fromYmd(2025, 2, 24), 100), // as-of 1yr start + makeCandle(Date.fromYmd(2025, 7, 1), 110), + makeCandle(Date.fromYmd(2026, 1, 30), 115), // month-end end (Friday, Jan 31 is Sat) + makeCandle(Date.fromYmd(2026, 2, 24), 120), // as-of end (latest) + }; + + // As-of-date: end=Feb 24 ($120), start=Feb 24 prior year ($100) → 20% + const asof = trailingReturns(&candles); + try std.testing.expect(asof.one_year != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.20), asof.one_year.?.total_return, 0.001); + + // Month-end: end=Jan 30 ($115), start=Jan 31 ($100) → 15% + const me = trailingReturnsMonthEnd(&candles, Date.fromYmd(2026, 2, 25)); + try std.testing.expect(me.one_year != null); + try std.testing.expectApproxEqAbs(@as(f64, 0.15), me.one_year.?.total_return, 0.001); +} + diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig new file mode 100644 index 0000000..d882b1a --- /dev/null +++ b/src/analytics/risk.zig @@ -0,0 +1,218 @@ +const std = @import("std"); +const Candle = @import("../models/candle.zig").Candle; +const Date = @import("../models/date.zig").Date; + +/// Daily return series statistics. +pub const RiskMetrics = struct { + /// Annualized standard deviation of returns + volatility: f64, + /// Sharpe ratio (assuming risk-free rate of ~4.5% -- current T-bill) + 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 daily returns used + sample_size: usize, +}; + +const risk_free_annual = 0.045; // ~4.5% annualized, current T-bill proxy +const trading_days_per_year: f64 = 252.0; + +/// Compute risk metrics from a series of daily candles. +/// Candles must be sorted by date ascending. +pub fn computeRisk(candles: []const Candle) ?RiskMetrics { + if (candles.len < 21) return null; // need at least ~1 month + + // Compute daily log returns + const n = candles.len - 1; + var sum: f64 = 0; + var sum_sq: f64 = 0; + var peak: f64 = candles[0].close; + var max_dd: f64 = 0; + var dd_start: ?Date = null; + var dd_trough: ?Date = null; + var current_dd_start: Date = candles[0].date; + + for (1..candles.len) |i| { + const prev = candles[i - 1].close; + const curr = candles[i].close; + if (prev <= 0 or curr <= 0) continue; + + const ret = (curr / prev) - 1.0; + sum += ret; + sum_sq += ret * ret; + + // Drawdown tracking + if (curr > peak) { + peak = curr; + current_dd_start = candles[i].date; + } + const dd = (peak - curr) / peak; + if (dd > max_dd) { + max_dd = dd; + dd_start = current_dd_start; + dd_trough = candles[i].date; + } + } + + const mean = sum / @as(f64, @floatFromInt(n)); + const variance = (sum_sq / @as(f64, @floatFromInt(n))) - (mean * mean); + const daily_vol = @sqrt(@max(variance, 0)); + const annual_vol = daily_vol * @sqrt(trading_days_per_year); + + const annual_return = mean * trading_days_per_year; + const sharpe = if (annual_vol > 0) (annual_return - risk_free_annual) / 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, + }; +} + +/// Portfolio-level metrics computed from weighted position data. +pub const PortfolioSummary = struct { + /// Total market value of open positions + total_value: f64, + /// Total cost basis of open positions + total_cost: f64, + /// Total unrealized P&L + unrealized_pnl: f64, + /// Total unrealized return (decimal) + unrealized_return: f64, + /// Total realized P&L from closed lots + realized_pnl: f64, + /// Per-symbol breakdown + allocations: []Allocation, + + pub fn deinit(self: *PortfolioSummary, allocator: std.mem.Allocator) void { + allocator.free(self.allocations); + } +}; + +pub const Allocation = struct { + symbol: []const u8, + shares: f64, + avg_cost: f64, + current_price: f64, + market_value: f64, + cost_basis: f64, + weight: f64, // fraction of total portfolio + unrealized_pnl: f64, + unrealized_return: f64, +}; + +/// Compute portfolio summary given positions and current prices. +/// `prices` maps symbol -> current price. +pub fn portfolioSummary( + allocator: std.mem.Allocator, + positions: []const @import("../models/portfolio.zig").Position, + prices: std.StringHashMap(f64), +) !PortfolioSummary { + var allocs = std.ArrayList(Allocation).empty; + errdefer allocs.deinit(allocator); + + var total_value: f64 = 0; + var total_cost: f64 = 0; + var total_realized: f64 = 0; + + for (positions) |pos| { + if (pos.shares <= 0) continue; + const price = prices.get(pos.symbol) orelse continue; + const mv = pos.shares * price; + total_value += mv; + total_cost += pos.total_cost; + total_realized += pos.realized_pnl; + + try allocs.append(allocator, .{ + .symbol = pos.symbol, + .shares = pos.shares, + .avg_cost = pos.avg_cost, + .current_price = price, + .market_value = mv, + .cost_basis = pos.total_cost, + .weight = 0, // filled below + .unrealized_pnl = mv - pos.total_cost, + .unrealized_return = if (pos.total_cost > 0) (mv / pos.total_cost) - 1.0 else 0, + }); + } + + // Fill weights + if (total_value > 0) { + for (allocs.items) |*a| { + a.weight = a.market_value / total_value; + } + } + + return .{ + .total_value = total_value, + .total_cost = total_cost, + .unrealized_pnl = total_value - total_cost, + .unrealized_return = if (total_cost > 0) (total_value / total_cost) - 1.0 else 0, + .realized_pnl = total_realized, + .allocations = try allocs.toOwnedSlice(allocator), + }; +} + +test "risk metrics basic" { + // Construct a simple price series: $100 going up $1/day for 60 days + var candles: [60]Candle = undefined; + for (0..60) |i| { + const price: f64 = 100.0 + @as(f64, @floatFromInt(i)); + candles[i] = .{ + .date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), + .open = price, .high = price, .low = price, + .close = price, .adj_close = price, .volume = 1000, + }; + } + const metrics = computeRisk(&candles); + try std.testing.expect(metrics != null); + const m = metrics.?; + // Monotonically increasing price -> 0 drawdown + try std.testing.expectApproxEqAbs(@as(f64, 0), m.max_drawdown, 0.001); + // Should have positive Sharpe + try std.testing.expect(m.sharpe > 0); + try std.testing.expect(m.volatility > 0); + try std.testing.expectEqual(@as(usize, 59), m.sample_size); +} + +test "max drawdown" { + const candles = [_]Candle{ + makeCandle(Date.fromYmd(2024, 1, 2), 100), + makeCandle(Date.fromYmd(2024, 1, 3), 110), + makeCandle(Date.fromYmd(2024, 1, 4), 120), // peak + makeCandle(Date.fromYmd(2024, 1, 5), 100), + makeCandle(Date.fromYmd(2024, 1, 8), 90), // trough: 25% drawdown from 120 + makeCandle(Date.fromYmd(2024, 1, 9), 95), + makeCandle(Date.fromYmd(2024, 1, 10), 100), + makeCandle(Date.fromYmd(2024, 1, 11), 105), + makeCandle(Date.fromYmd(2024, 1, 12), 110), + makeCandle(Date.fromYmd(2024, 1, 15), 115), + makeCandle(Date.fromYmd(2024, 1, 16), 118), + makeCandle(Date.fromYmd(2024, 1, 17), 120), + makeCandle(Date.fromYmd(2024, 1, 18), 122), + makeCandle(Date.fromYmd(2024, 1, 19), 125), + makeCandle(Date.fromYmd(2024, 1, 22), 128), + makeCandle(Date.fromYmd(2024, 1, 23), 130), + makeCandle(Date.fromYmd(2024, 1, 24), 132), + makeCandle(Date.fromYmd(2024, 1, 25), 135), + makeCandle(Date.fromYmd(2024, 1, 26), 137), + makeCandle(Date.fromYmd(2024, 1, 29), 140), + makeCandle(Date.fromYmd(2024, 1, 30), 142), + }; + const metrics = computeRisk(&candles); + try std.testing.expect(metrics != null); + // Max drawdown: (120 - 90) / 120 = 0.25 + try std.testing.expectApproxEqAbs(@as(f64, 0.25), metrics.?.max_drawdown, 0.001); + try std.testing.expect(metrics.?.drawdown_trough.?.eql(Date.fromYmd(2024, 1, 8))); +} + +fn makeCandle(date: Date, price: f64) Candle { + return .{ .date = date, .open = price, .high = price, .low = price, .close = price, .adj_close = price, .volume = 1000 }; +} diff --git a/src/cache/store.zig b/src/cache/store.zig new file mode 100644 index 0000000..ae6c23d --- /dev/null +++ b/src/cache/store.zig @@ -0,0 +1,995 @@ +const std = @import("std"); +const srf = @import("srf"); +const Date = @import("../models/date.zig").Date; +const Candle = @import("../models/candle.zig").Candle; +const Dividend = @import("../models/dividend.zig").Dividend; +const DividendType = @import("../models/dividend.zig").DividendType; +const Split = @import("../models/split.zig").Split; +const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; +const ReportTime = @import("../models/earnings.zig").ReportTime; +const EtfProfile = @import("../models/etf_profile.zig").EtfProfile; +const Holding = @import("../models/etf_profile.zig").Holding; +const SectorWeight = @import("../models/etf_profile.zig").SectorWeight; +const Lot = @import("../models/portfolio.zig").Lot; +const Portfolio = @import("../models/portfolio.zig").Portfolio; +const OptionsChain = @import("../models/option.zig").OptionsChain; +const OptionContract = @import("../models/option.zig").OptionContract; + +/// TTL durations in seconds for cache expiry. +pub const Ttl = struct { + /// Historical candles older than 1 day never expire + pub const candles_historical: i64 = -1; // infinite + /// Latest day's candle refreshes every 24h + pub const candles_latest: i64 = 24 * 3600; + /// Dividend data refreshes weekly + pub const dividends: i64 = 7 * 24 * 3600; + /// Split data refreshes weekly + pub const splits: i64 = 7 * 24 * 3600; + /// Options chains refresh hourly + pub const options: i64 = 3600; + /// Earnings refresh daily + pub const earnings: i64 = 24 * 3600; + /// ETF profiles refresh monthly + pub const etf_profile: i64 = 30 * 24 * 3600; +}; + +pub const DataType = enum { + candles_daily, + dividends, + splits, + options, + earnings, + etf_profile, + meta, + + pub fn fileName(self: DataType) []const u8 { + return switch (self) { + .candles_daily => "candles_daily.srf", + .dividends => "dividends.srf", + .splits => "splits.srf", + .options => "options.srf", + .earnings => "earnings.srf", + .etf_profile => "etf_profile.srf", + .meta => "meta.srf", + }; + } +}; + +/// Persistent SRF-backed cache with per-symbol, per-data-type files. +/// +/// Layout: +/// {cache_dir}/{SYMBOL}/candles_daily.srf +/// {cache_dir}/{SYMBOL}/dividends.srf +/// {cache_dir}/{SYMBOL}/meta.srf +/// ... +pub const Store = struct { + cache_dir: []const u8, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator, cache_dir: []const u8) Store { + return .{ + .cache_dir = cache_dir, + .allocator = allocator, + }; + } + + /// Ensure the cache directory for a symbol exists. + pub fn ensureSymbolDir(self: *Store, symbol: []const u8) !void { + const path = try self.symbolPath(symbol, ""); + defer self.allocator.free(path); + std.fs.cwd().makePath(path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => return err, + }; + } + + /// Read raw SRF file contents for a symbol and data type. + /// Returns null if the file does not exist. + pub fn readRaw(self: *Store, symbol: []const u8, data_type: DataType) !?[]const u8 { + const path = try self.symbolPath(symbol, data_type.fileName()); + defer self.allocator.free(path); + + return std.fs.cwd().readFileAlloc(self.allocator, path, 50 * 1024 * 1024) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + } + + /// Write raw SRF data for a symbol and data type. + pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void { + try self.ensureSymbolDir(symbol); + const path = try self.symbolPath(symbol, data_type.fileName()); + defer self.allocator.free(path); + + const file = try std.fs.cwd().createFile(path, .{}); + defer file.close(); + try file.writeAll(data); + } + + /// Check if a cached data file exists and is within its TTL. + pub fn isFresh(self: *Store, symbol: []const u8, data_type: DataType, ttl_seconds: i64) !bool { + if (ttl_seconds < 0) { + // Infinite TTL: just check existence + const path = try self.symbolPath(symbol, data_type.fileName()); + defer self.allocator.free(path); + std.fs.cwd().access(path, .{}) catch return false; + return true; + } + + const path = try self.symbolPath(symbol, data_type.fileName()); + defer self.allocator.free(path); + + const file = std.fs.cwd().openFile(path, .{}) catch return false; + defer file.close(); + + const stat = file.stat() catch return false; + const mtime_s: i64 = @intCast(@divFloor(stat.mtime, std.time.ns_per_s)); + const now_s: i64 = std.time.timestamp(); + return (now_s - mtime_s) < ttl_seconds; + } + + /// Get the modification time (unix seconds) of a cached data file. + /// Returns null if the file does not exist. + pub fn getMtime(self: *Store, symbol: []const u8, data_type: DataType) ?i64 { + const path = self.symbolPath(symbol, data_type.fileName()) catch return null; + defer self.allocator.free(path); + + const file = std.fs.cwd().openFile(path, .{}) catch return null; + defer file.close(); + + const stat = file.stat() catch return null; + return @intCast(@divFloor(stat.mtime, std.time.ns_per_s)); + } + + /// Clear all cached data for a symbol. + pub fn clearSymbol(self: *Store, symbol: []const u8) !void { + const path = try self.symbolPath(symbol, ""); + defer self.allocator.free(path); + std.fs.cwd().deleteTree(path) catch {}; + } + + /// Clear a specific data type for a symbol. + pub fn clearData(self: *Store, symbol: []const u8, data_type: DataType) void { + const path = self.symbolPath(symbol, data_type.fileName()) catch return; + defer self.allocator.free(path); + std.fs.cwd().deleteFile(path) catch {}; + } + + /// Clear all cached data. + pub fn clearAll(self: *Store) !void { + std.fs.cwd().deleteTree(self.cache_dir) catch {}; + } + + // -- Serialization helpers -- + + /// Serialize candles to SRF compact format. + pub fn serializeCandles(allocator: std.mem.Allocator, candles: []const Candle) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + const writer = buf.writer(allocator); + + try writer.writeAll("#!srfv1\n"); + for (candles) |c| { + var date_buf: [10]u8 = undefined; + const date_str = c.date.format(&date_buf); + try writer.print( + "date::{s},open:num:{d},high:num:{d},low:num:{d},close:num:{d},adj_close:num:{d},volume:num:{d}\n", + .{ date_str, c.open, c.high, c.low, c.close, c.adj_close, c.volume }, + ); + } + + return buf.toOwnedSlice(allocator); + } + + /// Deserialize candles from SRF data. + pub fn deserializeCandles(allocator: std.mem.Allocator, data: []const u8) ![]Candle { + var candles: std.ArrayList(Candle) = .empty; + errdefer candles.deinit(allocator); + + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer parsed.deinit(); + + for (parsed.records.items) |record| { + var candle = Candle{ + .date = Date.epoch, + .open = 0, + .high = 0, + .low = 0, + .close = 0, + .adj_close = 0, + .volume = 0, + }; + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "date")) { + if (field.value) |v| { + const str = switch (v) { + .string => |s| s, + else => continue, + }; + candle.date = Date.parse(str) catch continue; + } + } else if (std.mem.eql(u8, field.key, "open")) { + if (field.value) |v| candle.open = numVal(v); + } else if (std.mem.eql(u8, field.key, "high")) { + if (field.value) |v| candle.high = numVal(v); + } else if (std.mem.eql(u8, field.key, "low")) { + if (field.value) |v| candle.low = numVal(v); + } else if (std.mem.eql(u8, field.key, "close")) { + if (field.value) |v| candle.close = numVal(v); + } else if (std.mem.eql(u8, field.key, "adj_close")) { + if (field.value) |v| candle.adj_close = numVal(v); + } else if (std.mem.eql(u8, field.key, "volume")) { + if (field.value) |v| candle.volume = @intFromFloat(numVal(v)); + } + } + try candles.append(allocator, candle); + } + + return candles.toOwnedSlice(allocator); + } + + /// Serialize dividends to SRF compact format. + pub fn serializeDividends(allocator: std.mem.Allocator, dividends: []const Dividend) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + const writer = buf.writer(allocator); + + try writer.writeAll("#!srfv1\n"); + for (dividends) |d| { + var ex_buf: [10]u8 = undefined; + const ex_str = d.ex_date.format(&ex_buf); + try writer.print("ex_date::{s},amount:num:{d}", .{ ex_str, d.amount }); + if (d.pay_date) |pd| { + var pay_buf: [10]u8 = undefined; + try writer.print(",pay_date::{s}", .{pd.format(&pay_buf)}); + } + if (d.frequency) |f| { + try writer.print(",frequency:num:{d}", .{f}); + } + try writer.print(",type::{s}\n", .{@tagName(d.distribution_type)}); + } + + return buf.toOwnedSlice(allocator); + } + + /// Serialize splits to SRF compact format. + pub fn serializeSplits(allocator: std.mem.Allocator, splits: []const Split) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + const writer = buf.writer(allocator); + + try writer.writeAll("#!srfv1\n"); + for (splits) |s| { + var date_buf: [10]u8 = undefined; + const date_str = s.date.format(&date_buf); + try writer.print("date::{s},numerator:num:{d},denominator:num:{d}\n", .{ + date_str, s.numerator, s.denominator, + }); + } + + return buf.toOwnedSlice(allocator); + } + + /// Deserialize dividends from SRF data. + pub fn deserializeDividends(allocator: std.mem.Allocator, data: []const u8) ![]Dividend { + var dividends: std.ArrayList(Dividend) = .empty; + errdefer dividends.deinit(allocator); + + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer parsed.deinit(); + + for (parsed.records.items) |record| { + var div = Dividend{ + .ex_date = Date.epoch, + .amount = 0, + }; + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "ex_date")) { + if (field.value) |v| { + const str = switch (v) { + .string => |s| s, + else => continue, + }; + div.ex_date = Date.parse(str) catch continue; + } + } else if (std.mem.eql(u8, field.key, "amount")) { + if (field.value) |v| div.amount = numVal(v); + } else if (std.mem.eql(u8, field.key, "pay_date")) { + if (field.value) |v| { + const str = switch (v) { + .string => |s| s, + else => continue, + }; + div.pay_date = Date.parse(str) catch null; + } + } else if (std.mem.eql(u8, field.key, "frequency")) { + if (field.value) |v| { + const n = numVal(v); + if (n > 0 and n <= 255) div.frequency = @intFromFloat(n); + } + } else if (std.mem.eql(u8, field.key, "type")) { + if (field.value) |v| { + const str = switch (v) { + .string => |s| s, + else => continue, + }; + div.distribution_type = parseDividendTypeTag(str); + } + } + } + try dividends.append(allocator, div); + } + + return dividends.toOwnedSlice(allocator); + } + + /// Deserialize splits from SRF data. + pub fn deserializeSplits(allocator: std.mem.Allocator, data: []const u8) ![]Split { + var splits: std.ArrayList(Split) = .empty; + errdefer splits.deinit(allocator); + + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer parsed.deinit(); + + for (parsed.records.items) |record| { + var split = Split{ + .date = Date.epoch, + .numerator = 0, + .denominator = 0, + }; + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "date")) { + if (field.value) |v| { + const str = switch (v) { + .string => |s| s, + else => continue, + }; + split.date = Date.parse(str) catch continue; + } + } else if (std.mem.eql(u8, field.key, "numerator")) { + if (field.value) |v| split.numerator = numVal(v); + } else if (std.mem.eql(u8, field.key, "denominator")) { + if (field.value) |v| split.denominator = numVal(v); + } + } + try splits.append(allocator, split); + } + + return splits.toOwnedSlice(allocator); + } + + /// Serialize earnings events to SRF compact format. + pub fn serializeEarnings(allocator: std.mem.Allocator, events: []const EarningsEvent) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + const writer = buf.writer(allocator); + + try writer.writeAll("#!srfv1\n"); + for (events) |e| { + var date_buf: [10]u8 = undefined; + const date_str = e.date.format(&date_buf); + try writer.print("date::{s}", .{date_str}); + if (e.estimate) |est| try writer.print(",estimate:num:{d}", .{est}); + if (e.actual) |act| try writer.print(",actual:num:{d}", .{act}); + if (e.quarter) |q| try writer.print(",quarter:num:{d}", .{q}); + if (e.fiscal_year) |fy| try writer.print(",fiscal_year:num:{d}", .{fy}); + if (e.revenue_actual) |ra| try writer.print(",revenue_actual:num:{d}", .{ra}); + if (e.revenue_estimate) |re| try writer.print(",revenue_estimate:num:{d}", .{re}); + try writer.print(",report_time::{s}\n", .{@tagName(e.report_time)}); + } + + return buf.toOwnedSlice(allocator); + } + + /// Deserialize earnings events from SRF data. + pub fn deserializeEarnings(allocator: std.mem.Allocator, data: []const u8) ![]EarningsEvent { + var events: std.ArrayList(EarningsEvent) = .empty; + errdefer events.deinit(allocator); + + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer parsed.deinit(); + + for (parsed.records.items) |record| { + var ev = EarningsEvent{ + .symbol = "", + .date = Date.epoch, + }; + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "date")) { + if (field.value) |v| { + const str = switch (v) { .string => |s| s, else => continue }; + ev.date = Date.parse(str) catch continue; + } + } else if (std.mem.eql(u8, field.key, "estimate")) { + if (field.value) |v| ev.estimate = numVal(v); + } else if (std.mem.eql(u8, field.key, "actual")) { + if (field.value) |v| ev.actual = numVal(v); + } else if (std.mem.eql(u8, field.key, "quarter")) { + if (field.value) |v| { + const n = numVal(v); + if (n >= 1 and n <= 4) ev.quarter = @intFromFloat(n); + } + } else if (std.mem.eql(u8, field.key, "fiscal_year")) { + if (field.value) |v| { + const n = numVal(v); + if (n > 1900 and n < 2200) ev.fiscal_year = @intFromFloat(n); + } + } else if (std.mem.eql(u8, field.key, "revenue_actual")) { + if (field.value) |v| ev.revenue_actual = numVal(v); + } else if (std.mem.eql(u8, field.key, "revenue_estimate")) { + if (field.value) |v| ev.revenue_estimate = numVal(v); + } else if (std.mem.eql(u8, field.key, "report_time")) { + if (field.value) |v| { + const str = switch (v) { .string => |s| s, else => continue }; + ev.report_time = parseReportTimeTag(str); + } + } + } + // Recompute surprise from actual/estimate + if (ev.actual != null and ev.estimate != null) { + ev.surprise = ev.actual.? - ev.estimate.?; + if (ev.estimate.? != 0) { + ev.surprise_percent = (ev.surprise.? / @abs(ev.estimate.?)) * 100.0; + } + } + try events.append(allocator, ev); + } + + return events.toOwnedSlice(allocator); + } + + /// Serialize ETF profile to SRF compact format. + /// Uses multiple record types: meta fields, then sector:: and holding:: prefixed records. + pub fn serializeEtfProfile(allocator: std.mem.Allocator, profile: EtfProfile) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + const writer = buf.writer(allocator); + + try writer.writeAll("#!srfv1\n"); + + // Meta record + try writer.writeAll("type::meta"); + if (profile.expense_ratio) |er| try writer.print(",expense_ratio:num:{d}", .{er}); + if (profile.net_assets) |na| try writer.print(",net_assets:num:{d}", .{na}); + if (profile.dividend_yield) |dy| try writer.print(",dividend_yield:num:{d}", .{dy}); + if (profile.portfolio_turnover) |pt| try writer.print(",portfolio_turnover:num:{d}", .{pt}); + if (profile.total_holdings) |th| try writer.print(",total_holdings:num:{d}", .{th}); + if (profile.inception_date) |d| { + var db: [10]u8 = undefined; + try writer.print(",inception_date::{s}", .{d.format(&db)}); + } + if (profile.leveraged) try writer.writeAll(",leveraged::yes"); + try writer.writeAll("\n"); + + // Sector records + if (profile.sectors) |sectors| { + for (sectors) |sec| { + try writer.print("type::sector,name::{s},weight:num:{d}\n", .{ sec.sector, sec.weight }); + } + } + + // Holding records + if (profile.holdings) |holdings| { + for (holdings) |h| { + try writer.writeAll("type::holding"); + if (h.symbol) |s| try writer.print(",symbol::{s}", .{s}); + try writer.print(",name::{s},weight:num:{d}\n", .{ h.name, h.weight }); + } + } + + return buf.toOwnedSlice(allocator); + } + + /// Deserialize ETF profile from SRF data. + pub fn deserializeEtfProfile(allocator: std.mem.Allocator, data: []const u8) !EtfProfile { + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer parsed.deinit(); + + var profile = EtfProfile{ .symbol = "" }; + var sectors: std.ArrayList(SectorWeight) = .empty; + errdefer sectors.deinit(allocator); + var holdings: std.ArrayList(Holding) = .empty; + errdefer holdings.deinit(allocator); + + for (parsed.records.items) |record| { + var record_type: []const u8 = ""; + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "type")) { + if (field.value) |v| { + record_type = switch (v) { .string => |s| s, else => "" }; + } + } + } + + if (std.mem.eql(u8, record_type, "meta")) { + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "expense_ratio")) { + if (field.value) |v| profile.expense_ratio = numVal(v); + } else if (std.mem.eql(u8, field.key, "net_assets")) { + if (field.value) |v| profile.net_assets = numVal(v); + } else if (std.mem.eql(u8, field.key, "dividend_yield")) { + if (field.value) |v| profile.dividend_yield = numVal(v); + } else if (std.mem.eql(u8, field.key, "portfolio_turnover")) { + if (field.value) |v| profile.portfolio_turnover = numVal(v); + } else if (std.mem.eql(u8, field.key, "total_holdings")) { + if (field.value) |v| { + const n = numVal(v); + if (n > 0) profile.total_holdings = @intFromFloat(n); + } + } else if (std.mem.eql(u8, field.key, "inception_date")) { + if (field.value) |v| { + const str = switch (v) { .string => |s| s, else => continue }; + profile.inception_date = Date.parse(str) catch null; + } + } else if (std.mem.eql(u8, field.key, "leveraged")) { + if (field.value) |v| { + const str = switch (v) { .string => |s| s, else => continue }; + profile.leveraged = std.mem.eql(u8, str, "yes"); + } + } + } + } else if (std.mem.eql(u8, record_type, "sector")) { + var name: ?[]const u8 = null; + var weight: f64 = 0; + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "name")) { + if (field.value) |v| name = switch (v) { .string => |s| s, else => null }; + } else if (std.mem.eql(u8, field.key, "weight")) { + if (field.value) |v| weight = numVal(v); + } + } + if (name) |n| { + const duped = try allocator.dupe(u8, n); + try sectors.append(allocator, .{ .sector = duped, .weight = weight }); + } + } else if (std.mem.eql(u8, record_type, "holding")) { + var sym: ?[]const u8 = null; + var hname: ?[]const u8 = null; + var weight: f64 = 0; + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "symbol")) { + if (field.value) |v| sym = switch (v) { .string => |s| s, else => null }; + } else if (std.mem.eql(u8, field.key, "name")) { + if (field.value) |v| hname = switch (v) { .string => |s| s, else => null }; + } else if (std.mem.eql(u8, field.key, "weight")) { + if (field.value) |v| weight = numVal(v); + } + } + if (hname) |hn| { + const duped_sym = if (sym) |s| try allocator.dupe(u8, s) else null; + const duped_name = try allocator.dupe(u8, hn); + try holdings.append(allocator, .{ .symbol = duped_sym, .name = duped_name, .weight = weight }); + } + } + } + + if (sectors.items.len > 0) { + profile.sectors = try sectors.toOwnedSlice(allocator); + } else { + sectors.deinit(allocator); + } + if (holdings.items.len > 0) { + profile.holdings = try holdings.toOwnedSlice(allocator); + } else { + holdings.deinit(allocator); + } + + return profile; + } + + /// Serialize options chains to SRF compact format. + pub fn serializeOptions(allocator: std.mem.Allocator, chains: []const OptionsChain) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + const w = buf.writer(allocator); + + try w.writeAll("#!srfv1\n"); + for (chains) |chain| { + var exp_buf: [10]u8 = undefined; + try w.print("type::chain,expiration::{s},symbol::{s}", .{ + chain.expiration.format(&exp_buf), chain.underlying_symbol, + }); + if (chain.underlying_price) |p| try w.print(",price:num:{d}", .{p}); + try w.writeAll("\n"); + + for (chain.calls) |c| { + var eb: [10]u8 = undefined; + try w.print("type::call,expiration::{s},strike:num:{d}", .{ chain.expiration.format(&eb), c.strike }); + if (c.bid) |v| try w.print(",bid:num:{d}", .{v}); + if (c.ask) |v| try w.print(",ask:num:{d}", .{v}); + if (c.last_price) |v| try w.print(",last:num:{d}", .{v}); + if (c.volume) |v| try w.print(",volume:num:{d}", .{v}); + if (c.open_interest) |v| try w.print(",oi:num:{d}", .{v}); + if (c.implied_volatility) |v| try w.print(",iv:num:{d}", .{v}); + if (c.delta) |v| try w.print(",delta:num:{d}", .{v}); + if (c.gamma) |v| try w.print(",gamma:num:{d}", .{v}); + if (c.theta) |v| try w.print(",theta:num:{d}", .{v}); + if (c.vega) |v| try w.print(",vega:num:{d}", .{v}); + try w.writeAll("\n"); + } + + for (chain.puts) |p| { + var eb: [10]u8 = undefined; + try w.print("type::put,expiration::{s},strike:num:{d}", .{ chain.expiration.format(&eb), p.strike }); + if (p.bid) |v| try w.print(",bid:num:{d}", .{v}); + if (p.ask) |v| try w.print(",ask:num:{d}", .{v}); + if (p.last_price) |v| try w.print(",last:num:{d}", .{v}); + if (p.volume) |v| try w.print(",volume:num:{d}", .{v}); + if (p.open_interest) |v| try w.print(",oi:num:{d}", .{v}); + if (p.implied_volatility) |v| try w.print(",iv:num:{d}", .{v}); + if (p.delta) |v| try w.print(",delta:num:{d}", .{v}); + if (p.gamma) |v| try w.print(",gamma:num:{d}", .{v}); + if (p.theta) |v| try w.print(",theta:num:{d}", .{v}); + if (p.vega) |v| try w.print(",vega:num:{d}", .{v}); + try w.writeAll("\n"); + } + } + + return buf.toOwnedSlice(allocator); + } + + /// Deserialize options chains from SRF data. + pub fn deserializeOptions(allocator: std.mem.Allocator, data: []const u8) ![]OptionsChain { + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer parsed.deinit(); + + var chains: std.ArrayList(OptionsChain) = .empty; + errdefer { + for (chains.items) |*ch| { + allocator.free(ch.calls); + allocator.free(ch.puts); + } + chains.deinit(allocator); + } + + // First pass: collect chain headers (expirations) + // Second: collect calls/puts per expiration + var exp_map = std.StringHashMap(usize).init(allocator); + defer exp_map.deinit(); + + // Collect all chain records first + for (parsed.records.items) |record| { + var rec_type: []const u8 = ""; + var expiration: ?Date = null; + var exp_str: []const u8 = ""; + var symbol: []const u8 = ""; + var price: ?f64 = null; + + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "type")) { + if (field.value) |v| rec_type = switch (v) { .string => |s| s, else => "" }; + } else if (std.mem.eql(u8, field.key, "expiration")) { + if (field.value) |v| { + exp_str = switch (v) { .string => |s| s, else => continue }; + expiration = Date.parse(exp_str) catch null; + } + } else if (std.mem.eql(u8, field.key, "symbol")) { + if (field.value) |v| symbol = switch (v) { .string => |s| s, else => "" }; + } else if (std.mem.eql(u8, field.key, "price")) { + if (field.value) |v| price = numVal(v); + } + } + + if (std.mem.eql(u8, rec_type, "chain")) { + if (expiration) |exp| { + const idx = chains.items.len; + try chains.append(allocator, .{ + .underlying_symbol = try allocator.dupe(u8, symbol), + .underlying_price = price, + .expiration = exp, + .calls = &.{}, + .puts = &.{}, + }); + try exp_map.put(exp_str, idx); + } + } + } + + // Second pass: collect contracts + var calls_map = std.AutoHashMap(usize, std.ArrayList(OptionContract)).init(allocator); + defer { + var iter = calls_map.valueIterator(); + while (iter.next()) |v| v.deinit(allocator); + calls_map.deinit(); + } + var puts_map = std.AutoHashMap(usize, std.ArrayList(OptionContract)).init(allocator); + defer { + var iter = puts_map.valueIterator(); + while (iter.next()) |v| v.deinit(allocator); + puts_map.deinit(); + } + + for (parsed.records.items) |record| { + var rec_type: []const u8 = ""; + var exp_str: []const u8 = ""; + var contract = OptionContract{ + .contract_type = .call, + .strike = 0, + .expiration = Date.epoch, + }; + + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "type")) { + if (field.value) |v| rec_type = switch (v) { .string => |s| s, else => "" }; + } else if (std.mem.eql(u8, field.key, "expiration")) { + if (field.value) |v| { + exp_str = switch (v) { .string => |s| s, else => continue }; + contract.expiration = Date.parse(exp_str) catch Date.epoch; + } + } else if (std.mem.eql(u8, field.key, "strike")) { + if (field.value) |v| contract.strike = numVal(v); + } else if (std.mem.eql(u8, field.key, "bid")) { + if (field.value) |v| contract.bid = numVal(v); + } else if (std.mem.eql(u8, field.key, "ask")) { + if (field.value) |v| contract.ask = numVal(v); + } else if (std.mem.eql(u8, field.key, "last")) { + if (field.value) |v| contract.last_price = numVal(v); + } else if (std.mem.eql(u8, field.key, "volume")) { + if (field.value) |v| contract.volume = @intFromFloat(numVal(v)); + } else if (std.mem.eql(u8, field.key, "oi")) { + if (field.value) |v| contract.open_interest = @intFromFloat(numVal(v)); + } else if (std.mem.eql(u8, field.key, "iv")) { + if (field.value) |v| contract.implied_volatility = numVal(v); + } else if (std.mem.eql(u8, field.key, "delta")) { + if (field.value) |v| contract.delta = numVal(v); + } else if (std.mem.eql(u8, field.key, "gamma")) { + if (field.value) |v| contract.gamma = numVal(v); + } else if (std.mem.eql(u8, field.key, "theta")) { + if (field.value) |v| contract.theta = numVal(v); + } else if (std.mem.eql(u8, field.key, "vega")) { + if (field.value) |v| contract.vega = numVal(v); + } + } + + if (std.mem.eql(u8, rec_type, "call")) { + contract.contract_type = .call; + if (exp_map.get(exp_str)) |idx| { + const entry = try calls_map.getOrPut(idx); + if (!entry.found_existing) entry.value_ptr.* = .empty; + try entry.value_ptr.append(allocator, contract); + } + } else if (std.mem.eql(u8, rec_type, "put")) { + contract.contract_type = .put; + if (exp_map.get(exp_str)) |idx| { + const entry = try puts_map.getOrPut(idx); + if (!entry.found_existing) entry.value_ptr.* = .empty; + try entry.value_ptr.append(allocator, contract); + } + } + } + + // Assign calls/puts to chains + for (chains.items, 0..) |*chain, idx| { + if (calls_map.getPtr(idx)) |cl| { + chain.calls = try cl.toOwnedSlice(allocator); + } + if (puts_map.getPtr(idx)) |pl| { + chain.puts = try pl.toOwnedSlice(allocator); + } + } + + return chains.toOwnedSlice(allocator); + } + + fn parseDividendTypeTag(s: []const u8) DividendType { + if (std.mem.eql(u8, s, "regular")) return .regular; + if (std.mem.eql(u8, s, "special")) return .special; + if (std.mem.eql(u8, s, "supplemental")) return .supplemental; + if (std.mem.eql(u8, s, "irregular")) return .irregular; + return .unknown; + } + + fn parseReportTimeTag(s: []const u8) ReportTime { + if (std.mem.eql(u8, s, "bmo")) return .bmo; + if (std.mem.eql(u8, s, "amc")) return .amc; + if (std.mem.eql(u8, s, "dmh")) return .dmh; + return .unknown; + } + + fn symbolPath(self: *Store, symbol: []const u8, file_name: []const u8) ![]const u8 { + if (file_name.len == 0) { + return std.fs.path.join(self.allocator, &.{ self.cache_dir, symbol }); + } + return std.fs.path.join(self.allocator, &.{ self.cache_dir, symbol, file_name }); + } + + fn numVal(v: srf.Value) f64 { + return switch (v) { + .number => |n| n, + else => 0, + }; + } +}; + +const InvalidData = error{InvalidData}; + +/// Serialize a portfolio (list of lots) to SRF format. +pub fn serializePortfolio(allocator: std.mem.Allocator, lots: []const Lot) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + const writer = buf.writer(allocator); + + try writer.writeAll("#!srfv1\n"); + for (lots) |lot| { + var od_buf: [10]u8 = undefined; + try writer.print("symbol::{s},shares:num:{d},open_date::{s},open_price:num:{d}", .{ + lot.symbol, lot.shares, lot.open_date.format(&od_buf), lot.open_price, + }); + if (lot.close_date) |cd| { + var cd_buf: [10]u8 = undefined; + try writer.print(",close_date::{s}", .{cd.format(&cd_buf)}); + } + if (lot.close_price) |cp| { + try writer.print(",close_price:num:{d}", .{cp}); + } + if (lot.note) |n| { + try writer.print(",note::{s}", .{n}); + } + if (lot.account) |a| { + try writer.print(",account::{s}", .{a}); + } + try writer.writeAll("\n"); + } + + return buf.toOwnedSlice(allocator); +} + +/// Deserialize a portfolio from SRF data. Caller owns the returned Portfolio. +pub fn deserializePortfolio(allocator: std.mem.Allocator, data: []const u8) !Portfolio { + var lots: std.ArrayList(Lot) = .empty; + errdefer { + for (lots.items) |lot| { + allocator.free(lot.symbol); + if (lot.note) |n| allocator.free(n); + if (lot.account) |a| allocator.free(a); + } + lots.deinit(allocator); + } + + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidData; + defer parsed.deinit(); + + for (parsed.records.items) |record| { + var lot = Lot{ + .symbol = "", + .shares = 0, + .open_date = Date.epoch, + .open_price = 0, + }; + var sym_raw: ?[]const u8 = null; + var note_raw: ?[]const u8 = null; + var account_raw: ?[]const u8 = null; + + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "symbol")) { + if (field.value) |v| sym_raw = switch (v) { .string => |s| s, else => null }; + } else if (std.mem.eql(u8, field.key, "shares")) { + if (field.value) |v| lot.shares = Store.numVal(v); + } else if (std.mem.eql(u8, field.key, "open_date")) { + if (field.value) |v| { + const str = switch (v) { .string => |s| s, else => continue }; + lot.open_date = Date.parse(str) catch continue; + } + } else if (std.mem.eql(u8, field.key, "open_price")) { + if (field.value) |v| lot.open_price = Store.numVal(v); + } else if (std.mem.eql(u8, field.key, "close_date")) { + if (field.value) |v| { + const str = switch (v) { .string => |s| s, else => continue }; + lot.close_date = Date.parse(str) catch null; + } + } else if (std.mem.eql(u8, field.key, "close_price")) { + if (field.value) |v| lot.close_price = Store.numVal(v); + } else if (std.mem.eql(u8, field.key, "note")) { + if (field.value) |v| note_raw = switch (v) { .string => |s| s, else => null }; + } else if (std.mem.eql(u8, field.key, "account")) { + if (field.value) |v| account_raw = switch (v) { .string => |s| s, else => null }; + } + } + + if (sym_raw) |s| { + lot.symbol = try allocator.dupe(u8, s); + } else continue; + + if (note_raw) |n| { + lot.note = try allocator.dupe(u8, n); + } + + if (account_raw) |a| { + lot.account = try allocator.dupe(u8, a); + } + + try lots.append(allocator, lot); + } + + return .{ + .lots = try lots.toOwnedSlice(allocator), + .allocator = allocator, + }; +} + +test "dividend serialize/deserialize round-trip" { + const allocator = std.testing.allocator; + const divs = [_]Dividend{ + .{ .ex_date = Date.fromYmd(2024, 3, 15), .amount = 0.8325, .pay_date = Date.fromYmd(2024, 3, 28), .frequency = 4, .distribution_type = .regular }, + .{ .ex_date = Date.fromYmd(2024, 6, 14), .amount = 0.9148, .distribution_type = .special }, + }; + + const data = try Store.serializeDividends(allocator, &divs); + defer allocator.free(data); + + const parsed = try Store.deserializeDividends(allocator, data); + defer allocator.free(parsed); + + try std.testing.expectEqual(@as(usize, 2), parsed.len); + + try std.testing.expect(parsed[0].ex_date.eql(Date.fromYmd(2024, 3, 15))); + try std.testing.expectApproxEqAbs(@as(f64, 0.8325), parsed[0].amount, 0.0001); + try std.testing.expect(parsed[0].pay_date != null); + try std.testing.expect(parsed[0].pay_date.?.eql(Date.fromYmd(2024, 3, 28))); + try std.testing.expectEqual(@as(?u8, 4), parsed[0].frequency); + try std.testing.expectEqual(DividendType.regular, parsed[0].distribution_type); + + try std.testing.expect(parsed[1].ex_date.eql(Date.fromYmd(2024, 6, 14))); + try std.testing.expectApproxEqAbs(@as(f64, 0.9148), parsed[1].amount, 0.0001); + try std.testing.expect(parsed[1].pay_date == null); + try std.testing.expectEqual(DividendType.special, parsed[1].distribution_type); +} + +test "split serialize/deserialize round-trip" { + const allocator = std.testing.allocator; + const splits = [_]Split{ + .{ .date = Date.fromYmd(2020, 8, 31), .numerator = 4, .denominator = 1 }, + .{ .date = Date.fromYmd(2014, 6, 9), .numerator = 7, .denominator = 1 }, + }; + + const data = try Store.serializeSplits(allocator, &splits); + defer allocator.free(data); + + const parsed = try Store.deserializeSplits(allocator, data); + defer allocator.free(parsed); + + try std.testing.expectEqual(@as(usize, 2), parsed.len); + + try std.testing.expect(parsed[0].date.eql(Date.fromYmd(2020, 8, 31))); + try std.testing.expectApproxEqAbs(@as(f64, 4), parsed[0].numerator, 0.001); + try std.testing.expectApproxEqAbs(@as(f64, 1), parsed[0].denominator, 0.001); + + try std.testing.expect(parsed[1].date.eql(Date.fromYmd(2014, 6, 9))); + try std.testing.expectApproxEqAbs(@as(f64, 7), parsed[1].numerator, 0.001); +} + +test "portfolio serialize/deserialize round-trip" { + const allocator = std.testing.allocator; + const lots = [_]Lot{ + .{ .symbol = "AMZN", .shares = 10, .open_date = Date.fromYmd(2022, 3, 15), .open_price = 150.25 }, + .{ .symbol = "AMZN", .shares = 5, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 125.00, .close_date = Date.fromYmd(2024, 1, 15), .close_price = 185.50 }, + .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2022, 1, 10), .open_price = 220.00 }, + }; + + const data = try serializePortfolio(allocator, &lots); + defer allocator.free(data); + + var portfolio = try deserializePortfolio(allocator, data); + defer portfolio.deinit(); + + try std.testing.expectEqual(@as(usize, 3), portfolio.lots.len); + + try std.testing.expectEqualStrings("AMZN", portfolio.lots[0].symbol); + try std.testing.expectApproxEqAbs(@as(f64, 10), portfolio.lots[0].shares, 0.01); + try std.testing.expect(portfolio.lots[0].isOpen()); + + try std.testing.expectEqualStrings("AMZN", portfolio.lots[1].symbol); + try std.testing.expectApproxEqAbs(@as(f64, 5), portfolio.lots[1].shares, 0.01); + try std.testing.expect(!portfolio.lots[1].isOpen()); + try std.testing.expect(portfolio.lots[1].close_date.?.eql(Date.fromYmd(2024, 1, 15))); + try std.testing.expectApproxEqAbs(@as(f64, 185.50), portfolio.lots[1].close_price.?, 0.01); + + try std.testing.expectEqualStrings("VTI", portfolio.lots[2].symbol); +} diff --git a/src/cli/main.zig b/src/cli/main.zig new file mode 100644 index 0000000..63aeb6f --- /dev/null +++ b/src/cli/main.zig @@ -0,0 +1,966 @@ +const std = @import("std"); +const zfin = @import("zfin"); +const tui = @import("tui"); + +const usage = + \\Usage: zfin [options] + \\ + \\Commands: + \\ interactive [opts] Launch interactive TUI + \\ perf Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style) + \\ quote Show latest quote + \\ history Show recent price history + \\ divs Show dividend history + \\ splits Show split history + \\ options Show options chain (nearest expiration) + \\ earnings Show earnings history and upcoming + \\ etf Show ETF profile (holdings, sectors, expense ratio) + \\ portfolio Load and analyze a portfolio (.srf file) + \\ cache stats Show cache statistics + \\ cache clear Clear all cached data + \\ + \\Interactive mode options: + \\ -p, --portfolio Portfolio file (.srf) + \\ -w, --watchlist Watchlist file (default: watchlist.srf) + \\ -s, --symbol Initial symbol (default: VTI) + \\ --default-keys Print default keybindings + \\ --default-theme Print default theme + \\ + \\Environment Variables: + \\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices) + \\ POLYGON_API_KEY Polygon.io API key (dividends, splits) + \\ FINNHUB_API_KEY Finnhub API key (earnings) + \\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles) + \\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin) + \\ +; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const args = try std.process.argsAlloc(allocator); + defer std.process.argsFree(allocator, args); + + if (args.len < 2) { + try stdout_print(usage); + return; + } + + var config = zfin.Config.fromEnv(allocator); + defer config.deinit(); + const command = args[1]; + + if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) { + try stdout_print(usage); + return; + } + + // Interactive TUI -- delegates to the TUI module (owns its own DataService) + if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) { + try tui.run(allocator, config, args); + return; + } + + var svc = zfin.DataService.init(allocator, config); + defer svc.deinit(); + + if (std.mem.eql(u8, command, "perf")) { + if (args.len < 3) return try stderr_print("Error: 'perf' requires a symbol argument\n"); + try cmdPerf(allocator, &svc, args[2]); + } else if (std.mem.eql(u8, command, "quote")) { + if (args.len < 3) return try stderr_print("Error: 'quote' requires a symbol argument\n"); + try cmdQuote(allocator, config, args[2]); + } else if (std.mem.eql(u8, command, "history")) { + if (args.len < 3) return try stderr_print("Error: 'history' requires a symbol argument\n"); + try cmdHistory(allocator, &svc, args[2]); + } else if (std.mem.eql(u8, command, "divs")) { + if (args.len < 3) return try stderr_print("Error: 'divs' requires a symbol argument\n"); + try cmdDivs(allocator, &svc, config, args[2]); + } else if (std.mem.eql(u8, command, "splits")) { + if (args.len < 3) return try stderr_print("Error: 'splits' requires a symbol argument\n"); + try cmdSplits(allocator, &svc, args[2]); + } else if (std.mem.eql(u8, command, "options")) { + if (args.len < 3) return try stderr_print("Error: 'options' requires a symbol argument\n"); + try cmdOptions(allocator, &svc, args[2]); + } else if (std.mem.eql(u8, command, "earnings")) { + if (args.len < 3) return try stderr_print("Error: 'earnings' requires a symbol argument\n"); + try cmdEarnings(allocator, &svc, args[2]); + } else if (std.mem.eql(u8, command, "etf")) { + if (args.len < 3) return try stderr_print("Error: 'etf' requires a symbol argument\n"); + try cmdEtf(allocator, &svc, args[2]); + } else if (std.mem.eql(u8, command, "portfolio")) { + if (args.len < 3) return try stderr_print("Error: 'portfolio' requires a file path argument\n"); + try cmdPortfolio(allocator, config, args[2]); + } else if (std.mem.eql(u8, command, "cache")) { + if (args.len < 3) return try stderr_print("Error: 'cache' requires a subcommand (stats, clear)\n"); + try cmdCache(allocator, config, args[2]); + } else { + try stderr_print("Unknown command. Run 'zfin help' for usage.\n"); + } +} + +fn cmdPerf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { + const result = svc.getTrailingReturns(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try stderr_print("Error: TWELVEDATA_API_KEY not set. Get a free key at https://twelvedata.com\n"); + return; + }, + else => { + try stderr_print("Error fetching data.\n"); + return; + }, + }; + defer allocator.free(result.candles); + defer if (result.dividends) |d| allocator.free(d); + + if (result.source == .cached) try stderr_print("(using cached data)\n"); + + const c = result.candles; + const end_date = c[c.len - 1].date; + const today = todayDate(); + const month_end = today.lastDayOfPriorMonth(); + + var buf: [8192]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.print("\nTrailing Returns for {s}\n", .{symbol}); + try out.print("========================================\n", .{}); + try out.print("Data points: {d} (", .{c.len}); + { + var db: [10]u8 = undefined; + try out.print("{s}", .{c[0].date.format(&db)}); + } + try out.print(" to ", .{}); + { + var db: [10]u8 = undefined; + try out.print("{s}", .{end_date.format(&db)}); + } + try out.print(")\nLatest close: ${d:.2}\n", .{c[c.len - 1].close}); + + const has_divs = result.asof_total != null; + + // -- As-of-date returns (matches Morningstar "Trailing Returns" page) -- + { + var db: [10]u8 = undefined; + try out.print("\nAs-of {s}:\n", .{end_date.format(&db)}); + } + try printReturnsTable(out, result.asof_price, if (has_divs) result.asof_total else null); + + // -- Month-end returns (matches Morningstar "Performance" page) -- + { + var db: [10]u8 = undefined; + try out.print("\nMonth-end ({s}):\n", .{month_end.format(&db)}); + } + try printReturnsTable(out, result.me_price, if (has_divs) result.me_total else null); + + if (!has_divs) { + try out.print("\nSet POLYGON_API_KEY for total returns with dividend reinvestment.\n", .{}); + } + try out.print("\n", .{}); + try out.flush(); +} + +fn printReturnsTable( + out: anytype, + price: zfin.performance.TrailingReturns, + total: ?zfin.performance.TrailingReturns, +) !void { + const has_total = total != null; + + if (has_total) { + try out.print("{s:>22} {s:>14} {s:>14}\n", .{ "", "Price Only", "Total Return" }); + try out.print("{s:->22} {s:->14} {s:->14}\n", .{ "", "", "" }); + } else { + try out.print("{s:>22} {s:>14}\n", .{ "", "Price Only" }); + try out.print("{s:->22} {s:->14}\n", .{ "", "" }); + } + + const periods = [_]struct { label: []const u8, years: u16 }{ + .{ .label = "1-Year Return:", .years = 1 }, + .{ .label = "3-Year Return:", .years = 3 }, + .{ .label = "5-Year Return:", .years = 5 }, + .{ .label = "10-Year Return:", .years = 10 }, + }; + + const price_arr = [_]?zfin.performance.PerformanceResult{ + price.one_year, price.three_year, price.five_year, price.ten_year, + }; + + const total_arr: [4]?zfin.performance.PerformanceResult = if (total) |t| + .{ t.one_year, t.three_year, t.five_year, t.ten_year } + else + .{ null, null, null, null }; + + for (periods, 0..) |period, i| { + try out.print(" {s:<20}", .{period.label}); + + if (price_arr[i]) |r| { + var rb: [32]u8 = undefined; + const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return; + try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); + } else { + try out.print(" {s:>13}", .{"N/A"}); + } + + if (has_total) { + if (total_arr[i]) |r| { + var rb: [32]u8 = undefined; + const val = if (period.years > 1) r.annualized_return orelse r.total_return else r.total_return; + try out.print(" {s:>13}", .{zfin.performance.formatReturn(&rb, val)}); + } else { + try out.print(" {s:>13}", .{"N/A"}); + } + } + + if (period.years > 1) { + try out.print(" ann.", .{}); + } + try out.print("\n", .{}); + } +} + +fn cmdQuote(allocator: std.mem.Allocator, config: zfin.Config, symbol: []const u8) !void { + // Quote is a real-time endpoint, not cached -- use TwelveData directly + const td_key = config.twelvedata_key orelse { + try stderr_print("Error: TWELVEDATA_API_KEY not set.\n"); + return; + }; + + var td = zfin.TwelveData.init(allocator, td_key); + defer td.deinit(); + + var qr = td.fetchQuote(allocator, symbol) catch |err| { + try stderr_print("API error: "); + try stderr_print(@errorName(err)); + try stderr_print("\n"); + return; + }; + defer qr.deinit(); + + var q = qr.parse(allocator) catch |err| { + try stderr_print("Parse error: "); + try stderr_print(@errorName(err)); + try stderr_print("\n"); + return; + }; + defer q.deinit(); + + var buf: [4096]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.print("\n{s} -- {s}\n", .{ q.symbol(), q.name() }); + try out.print("========================================\n", .{}); + try out.print(" Exchange: {s}\n", .{q.exchange()}); + try out.print(" Date: {s}\n", .{q.datetime()}); + try out.print(" Close: ${d:.2}\n", .{q.close()}); + try out.print(" Open: ${d:.2}\n", .{q.open()}); + try out.print(" High: ${d:.2}\n", .{q.high()}); + try out.print(" Low: ${d:.2}\n", .{q.low()}); + try out.print(" Volume: {d}\n", .{q.volume()}); + try out.print(" Prev Close: ${d:.2}\n", .{q.previous_close()}); + try out.print(" Change: ${d:.2} ({d:.2}%)\n", .{ q.change(), q.percent_change() }); + try out.print(" 52-Week Low: ${d:.2}\n", .{q.fifty_two_week_low()}); + try out.print(" 52-Week High: ${d:.2}\n", .{q.fifty_two_week_high()}); + try out.print(" Avg Volume: {d}\n\n", .{q.average_volume()}); + try out.flush(); +} + +fn cmdHistory(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { + // History uses getCandles but filters to last 30 days + const result = svc.getCandles(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try stderr_print("Error: TWELVEDATA_API_KEY not set.\n"); + return; + }, + else => { + try stderr_print("Error fetching data.\n"); + return; + }, + }; + defer allocator.free(result.data); + + if (result.source == .cached) try stderr_print("(using cached data)\n"); + + const all = result.data; + if (all.len == 0) return try stderr_print("No data available.\n"); + + // Filter to last 30 days + const today = todayDate(); + const one_month_ago = today.addDays(-30); + const c = filterCandlesFrom(all, one_month_ago); + if (c.len == 0) return try stderr_print("No data available.\n"); + + var buf: [8192]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.print("\nPrice History for {s} (last 30 days)\n", .{symbol}); + try out.print("========================================\n", .{}); + try out.print("{s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{ + "Date", "Open", "High", "Low", "Close", "Volume", + }); + try out.print("{s:->12} {s:->10} {s:->10} {s:->10} {s:->10} {s:->12}\n", .{ + "", "", "", "", "", "", + }); + + for (c) |candle| { + var db: [10]u8 = undefined; + try out.print("{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {d:>12}\n", .{ + candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, candle.volume, + }); + } + try out.print("\n{d} trading days\n\n", .{c.len}); + try out.flush(); +} + +/// Return a slice view of candles on or after the given date (no allocation). +fn filterCandlesFrom(candles: []const zfin.Candle, from: zfin.Date) []const zfin.Candle { + // Binary search for first candle >= from + var lo: usize = 0; + var hi: usize = candles.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (candles[mid].date.lessThan(from)) { + lo = mid + 1; + } else { + hi = mid; + } + } + if (lo >= candles.len) return candles[0..0]; + return candles[lo..]; +} + +fn cmdDivs(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8) !void { + const result = svc.getDividends(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); + return; + }, + else => { + try stderr_print("Error fetching dividend data.\n"); + return; + }, + }; + defer allocator.free(result.data); + + if (result.source == .cached) try stderr_print("(using cached dividend data)\n"); + + const d = result.data; + + // Fetch current price for yield calculation + var current_price: ?f64 = null; + if (config.twelvedata_key) |td_key| { + var td = zfin.TwelveData.init(allocator, td_key); + defer td.deinit(); + if (td.fetchQuote(allocator, symbol)) |qr_val| { + var qr = qr_val; + defer qr.deinit(); + if (qr.parse(allocator)) |q_val| { + var q = q_val; + defer q.deinit(); + current_price = q.close(); + } else |_| {} + } else |_| {} + } + + var buf: [8192]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.print("\nDividend History for {s}\n", .{symbol}); + try out.print("========================================\n", .{}); + + if (d.len == 0) { + try out.print(" No dividends found.\n\n", .{}); + try out.flush(); + return; + } + + try out.print("{s:>12} {s:>10} {s:>12} {s:>6} {s:>10}\n", .{ + "Ex-Date", "Amount", "Pay Date", "Freq", "Type", + }); + try out.print("{s:->12} {s:->10} {s:->12} {s:->6} {s:->10}\n", .{ + "", "", "", "", "", + }); + + const today = todayDate(); + const one_year_ago = today.subtractYears(1); + var total: f64 = 0; + var ttm: f64 = 0; + + for (d) |div| { + var ex_buf: [10]u8 = undefined; + try out.print("{s:>12} {d:>10.4}", .{ div.ex_date.format(&ex_buf), div.amount }); + if (div.pay_date) |pd| { + var pay_buf: [10]u8 = undefined; + try out.print(" {s:>12}", .{pd.format(&pay_buf)}); + } else { + try out.print(" {s:>12}", .{"--"}); + } + if (div.frequency) |f| { + try out.print(" {d:>6}", .{f}); + } else { + try out.print(" {s:>6}", .{"--"}); + } + try out.print(" {s:>10}\n", .{@tagName(div.distribution_type)}); + total += div.amount; + if (!div.ex_date.lessThan(one_year_ago)) ttm += div.amount; + } + + try out.print("\n{d} dividends, total: ${d:.4}\n", .{ d.len, total }); + try out.print("TTM dividends: ${d:.4}", .{ttm}); + if (current_price) |price| { + if (price > 0) { + const yield = (ttm / price) * 100.0; + try out.print(" (yield: {d:.2}% at ${d:.2})", .{ yield, price }); + } + } + try out.print("\n\n", .{}); + try out.flush(); +} + +fn cmdSplits(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { + const result = svc.getSplits(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try stderr_print("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); + return; + }, + else => { + try stderr_print("Error fetching split data.\n"); + return; + }, + }; + defer allocator.free(result.data); + + if (result.source == .cached) try stderr_print("(using cached split data)\n"); + + const sp = result.data; + + var buf: [4096]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.print("\nSplit History for {s}\n", .{symbol}); + try out.print("========================================\n", .{}); + + if (sp.len == 0) { + try out.print(" No splits found.\n\n", .{}); + try out.flush(); + return; + } + + try out.print("{s:>12} {s:>10}\n", .{ "Date", "Ratio" }); + try out.print("{s:->12} {s:->10}\n", .{ "", "" }); + + for (sp) |s| { + var db: [10]u8 = undefined; + try out.print("{s:>12} {d:.0}:{d:.0}\n", .{ s.date.format(&db), s.numerator, s.denominator }); + } + try out.print("\n{d} split(s)\n\n", .{sp.len}); + try out.flush(); +} + +fn cmdOptions(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { + const result = svc.getOptions(symbol) catch |err| switch (err) { + zfin.DataError.FetchFailed => { + try stderr_print("Error fetching options data from CBOE.\n"); + return; + }, + else => { + try stderr_print("Error loading options data.\n"); + return; + }, + }; + const ch = result.data; + defer { + // All chains share the same underlying_symbol pointer; free it once. + if (ch.len > 0) allocator.free(ch[0].underlying_symbol); + for (ch) |chain| { + allocator.free(chain.calls); + allocator.free(chain.puts); + } + allocator.free(ch); + } + + if (result.source == .cached) try stderr_print("(using cached options data)\n"); + + if (ch.len == 0) { + try stderr_print("No options data found.\n"); + return; + } + + var buf: [16384]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.print("\nOptions Chain for {s}\n", .{symbol}); + try out.print("========================================\n", .{}); + if (ch[0].underlying_price) |price| { + try out.print("Underlying: ${d:.2}\n", .{price}); + } + try out.print("{d} expiration(s) available\n", .{ch.len}); + + // List expirations + try out.print("\nExpirations:\n", .{}); + for (ch) |chain| { + var db: [10]u8 = undefined; + try out.print(" {s} ({d} calls, {d} puts)\n", .{ + chain.expiration.format(&db), + chain.calls.len, + chain.puts.len, + }); + } + + // Show nearest expiration chain in detail + const nearest = ch[0]; + { + var db: [10]u8 = undefined; + try out.print("\nNearest Expiration: {s}\n", .{nearest.expiration.format(&db)}); + } + try out.print("{s:->64}\n", .{""}); + + // Calls + try out.print("\n CALLS\n", .{}); + try out.print(" {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8}\n", .{ + "Strike", "Last", "Bid", "Ask", "Volume", "OI", + }); + try out.print(" {s:->10} {s:->10} {s:->10} {s:->10} {s:->10} {s:->8}\n", .{ + "", "", "", "", "", "", + }); + for (nearest.calls) |c| { + try out.print(" {d:>10.2}", .{c.strike}); + if (c.last_price) |p| try out.print(" {d:>10.2}", .{p}) else try out.print(" {s:>10}", .{"--"}); + if (c.bid) |b| try out.print(" {d:>10.2}", .{b}) else try out.print(" {s:>10}", .{"--"}); + if (c.ask) |a| try out.print(" {d:>10.2}", .{a}) else try out.print(" {s:>10}", .{"--"}); + if (c.volume) |v| try out.print(" {d:>10}", .{v}) else try out.print(" {s:>10}", .{"--"}); + if (c.open_interest) |oi| try out.print(" {d:>8}", .{oi}) else try out.print(" {s:>8}", .{"--"}); + try out.print("\n", .{}); + } + + // Puts + try out.print("\n PUTS\n", .{}); + try out.print(" {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8}\n", .{ + "Strike", "Last", "Bid", "Ask", "Volume", "OI", + }); + try out.print(" {s:->10} {s:->10} {s:->10} {s:->10} {s:->10} {s:->8}\n", .{ + "", "", "", "", "", "", + }); + for (nearest.puts) |p| { + try out.print(" {d:>10.2}", .{p.strike}); + if (p.last_price) |lp| try out.print(" {d:>10.2}", .{lp}) else try out.print(" {s:>10}", .{"--"}); + if (p.bid) |b| try out.print(" {d:>10.2}", .{b}) else try out.print(" {s:>10}", .{"--"}); + if (p.ask) |a| try out.print(" {d:>10.2}", .{a}) else try out.print(" {s:>10}", .{"--"}); + if (p.volume) |v| try out.print(" {d:>10}", .{v}) else try out.print(" {s:>10}", .{"--"}); + if (p.open_interest) |oi| try out.print(" {d:>8}", .{oi}) else try out.print(" {s:>8}", .{"--"}); + try out.print("\n", .{}); + } + + try out.print("\n", .{}); + try out.flush(); +} + +fn cmdEarnings(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { + const result = svc.getEarnings(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try stderr_print("Error: FINNHUB_API_KEY not set. Get a free key at https://finnhub.io\n"); + return; + }, + else => { + try stderr_print("Error fetching earnings data.\n"); + return; + }, + }; + defer allocator.free(result.data); + + if (result.source == .cached) try stderr_print("(using cached earnings data)\n"); + + const ev = result.data; + + var buf: [8192]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.print("\nEarnings History for {s}\n", .{symbol}); + try out.print("========================================\n", .{}); + + if (ev.len == 0) { + try out.print(" No earnings data found.\n\n", .{}); + try out.flush(); + return; + } + + try out.print("{s:>12} {s:>4} {s:>10} {s:>10} {s:>10} {s:>16} {s:>5}\n", .{ + "Date", "Q", "Estimate", "Actual", "Surprise", "Revenue", "When", + }); + try out.print("{s:->12} {s:->4} {s:->10} {s:->10} {s:->10} {s:->16} {s:->5}\n", .{ + "", "", "", "", "", "", "", + }); + + for (ev) |e| { + var db: [10]u8 = undefined; + try out.print("{s:>12}", .{e.date.format(&db)}); + if (e.quarter) |q| try out.print(" Q{d}", .{q}) else try out.print(" {s:>4}", .{"--"}); + if (e.estimate) |est| try out.print(" {d:>10.4}", .{est}) else try out.print(" {s:>10}", .{"--"}); + if (e.actual) |act| try out.print(" {d:>10.4}", .{act}) else try out.print(" {s:>10}", .{"--"}); + if (e.surpriseAmount()) |s| { + if (s >= 0) + try out.print(" +{d:.4}", .{s}) + else + try out.print(" {d:.4}", .{s}); + } else { + try out.print(" {s:>10}", .{"--"}); + } + if (e.revenue_actual) |rev| { + try out.print(" {s:>14}", .{formatLargeNum(rev)}); + } else if (e.revenue_estimate) |rev| { + try out.print(" ~{s:>14}", .{formatLargeNum(rev)}); + } else { + try out.print(" {s:>16}", .{"--"}); + } + try out.print(" {s:>5}\n", .{@tagName(e.report_time)}); + } + + try out.print("\n{d} earnings event(s)\n\n", .{ev.len}); + try out.flush(); +} + +fn cmdEtf(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8) !void { + const result = svc.getEtfProfile(symbol) catch |err| switch (err) { + zfin.DataError.NoApiKey => { + try stderr_print("Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); + return; + }, + else => { + try stderr_print("Error fetching ETF profile.\n"); + return; + }, + }; + + const profile = result.data; + defer { + if (profile.holdings) |h| { + for (h) |holding| { + if (holding.symbol) |s| allocator.free(s); + allocator.free(holding.name); + } + allocator.free(h); + } + if (profile.sectors) |s| { + for (s) |sec| allocator.free(sec.sector); + allocator.free(s); + } + } + + if (result.source == .cached) try stderr_print("(using cached ETF profile)\n"); + + try printEtfProfile(profile, symbol); +} + +fn printEtfProfile(profile: zfin.EtfProfile, symbol: []const u8) !void { + var buf: [16384]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.print("\nETF Profile: {s}\n", .{symbol}); + try out.print("========================================\n", .{}); + + if (profile.expense_ratio) |er| { + try out.print(" Expense Ratio: {d:.2}%\n", .{er * 100.0}); + } + if (profile.net_assets) |na| { + try out.print(" Net Assets: ${s}\n", .{formatLargeNum(na)}); + } + if (profile.dividend_yield) |dy| { + try out.print(" Dividend Yield: {d:.2}%\n", .{dy * 100.0}); + } + if (profile.portfolio_turnover) |pt| { + try out.print(" Portfolio Turnover: {d:.1}%\n", .{pt * 100.0}); + } + if (profile.inception_date) |d| { + var db: [10]u8 = undefined; + try out.print(" Inception Date: {s}\n", .{d.format(&db)}); + } + if (profile.leveraged) { + try out.print(" Leveraged: YES\n", .{}); + } + if (profile.total_holdings) |th| { + try out.print(" Total Holdings: {d}\n", .{th}); + } + + // Sectors + if (profile.sectors) |sectors| { + if (sectors.len > 0) { + try out.print("\n Sector Allocation:\n", .{}); + for (sectors) |sec| { + try out.print(" {d:>5.1}% {s}\n", .{ sec.weight * 100.0, sec.sector }); + } + } + } + + // Top holdings + if (profile.holdings) |holdings| { + if (holdings.len > 0) { + try out.print("\n Top Holdings:\n", .{}); + try out.print(" {s:>6} {s:>7} {s}\n", .{ "Symbol", "Weight", "Name" }); + try out.print(" {s:->6} {s:->7} {s:->30}\n", .{ "", "", "" }); + for (holdings) |h| { + if (h.symbol) |s| { + try out.print(" {s:>6} {d:>6.2}% {s}\n", .{ s, h.weight * 100.0, h.name }); + } else { + try out.print(" {s:>6} {d:>6.2}% {s}\n", .{ "--", h.weight * 100.0, h.name }); + } + } + } + } + + try out.print("\n", .{}); + try out.flush(); +} + +fn cmdPortfolio(allocator: std.mem.Allocator, config: zfin.Config, file_path: []const u8) !void { + // Load portfolio from SRF file + const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| { + try stderr_print("Error reading portfolio file: "); + try stderr_print(@errorName(err)); + try stderr_print("\n"); + return; + }; + defer allocator.free(data); + + var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { + try stderr_print("Error parsing portfolio file.\n"); + return; + }; + defer portfolio.deinit(); + + if (portfolio.lots.len == 0) { + try stderr_print("Portfolio is empty.\n"); + return; + } + + // Get positions + const positions = try portfolio.positions(allocator); + defer allocator.free(positions); + + // Get unique symbols and fetch current prices + const syms = try portfolio.symbols(allocator); + defer allocator.free(syms); + + var prices = std.StringHashMap(f64).init(allocator); + defer prices.deinit(); + + if (config.twelvedata_key) |td_key| { + var td = zfin.TwelveData.init(allocator, td_key); + defer td.deinit(); + + for (syms) |sym| { + try stderr_print("Fetching quote: "); + try stderr_print(sym); + try stderr_print("...\n"); + if (td.fetchQuote(allocator, sym)) |qr_val| { + var qr = qr_val; + defer qr.deinit(); + if (qr.parse(allocator)) |q_val| { + var q = q_val; + defer q.deinit(); + const price = q.close(); + if (price > 0) try prices.put(sym, price); + } else |_| {} + } else |_| {} + } + } else { + try stderr_print("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n"); + } + + // Compute summary + var summary = zfin.risk.portfolioSummary(allocator, positions, prices) catch { + try stderr_print("Error computing portfolio summary.\n"); + return; + }; + defer summary.deinit(allocator); + + var buf: [16384]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + // Header + try out.print("\nPortfolio Summary ({s})\n", .{file_path}); + try out.print("========================================\n", .{}); + + // Lot counts + var open_lots: u32 = 0; + var closed_lots: u32 = 0; + for (portfolio.lots) |lot| { + if (lot.isOpen()) open_lots += 1 else closed_lots += 1; + } + try out.print(" Lots: {d} open, {d} closed\n", .{ open_lots, closed_lots }); + try out.print(" Positions: {d} symbols\n\n", .{positions.len}); + + // Positions table + try out.print("{s:>6} {s:>8} {s:>10} {s:>10} {s:>12} {s:>10} {s:>8}\n", .{ + "Symbol", "Shares", "Avg Cost", "Price", "Mkt Value", "P&L", "Weight", + }); + try out.print("{s:->6} {s:->8} {s:->10} {s:->10} {s:->12} {s:->10} {s:->8}\n", .{ + "", "", "", "", "", "", "", + }); + + for (summary.allocations) |a| { + try out.print("{s:>6} {d:>8.1} {d:>10.2} {d:>10.2} {d:>12.2} ", .{ + a.symbol, a.shares, a.avg_cost, a.current_price, a.market_value, + }); + if (a.unrealized_pnl >= 0) { + try out.print("+{d:>9.2}", .{a.unrealized_pnl}); + } else { + try out.print("{d:>10.2}", .{a.unrealized_pnl}); + } + try out.print(" {d:>6.1}%\n", .{a.weight * 100.0}); + } + + try out.print("{s:->6} {s:->8} {s:->10} {s:->10} {s:->12} {s:->10} {s:->8}\n", .{ + "", "", "", "", "", "", "", + }); + try out.print("{s:>6} {s:>8} {s:>10} {s:>10} {d:>12.2} ", .{ + "", "", "", "TOTAL", summary.total_value, + }); + if (summary.unrealized_pnl >= 0) { + try out.print("+{d:>9.2}", .{summary.unrealized_pnl}); + } else { + try out.print("{d:>10.2}", .{summary.unrealized_pnl}); + } + try out.print(" {s:>7}\n", .{"100.0%"}); + + try out.print("\n Cost Basis: ${d:.2}\n", .{summary.total_cost}); + try out.print(" Market Value: ${d:.2}\n", .{summary.total_value}); + try out.print(" Unrealized P&L: ", .{}); + if (summary.unrealized_pnl >= 0) { + try out.print("+${d:.2} ({d:.2}%)\n", .{ summary.unrealized_pnl, summary.unrealized_return * 100.0 }); + } else { + try out.print("-${d:.2} ({d:.2}%)\n", .{ -summary.unrealized_pnl, summary.unrealized_return * 100.0 }); + } + if (summary.realized_pnl != 0) { + try out.print(" Realized P&L: ", .{}); + if (summary.realized_pnl >= 0) { + try out.print("+${d:.2}\n", .{summary.realized_pnl}); + } else { + try out.print("-${d:.2}\n", .{-summary.realized_pnl}); + } + } + + // Risk metrics for each position if we have candles cached + var store = zfin.cache.Store.init(allocator, config.cache_dir); + var any_risk = false; + + for (summary.allocations) |a| { + const cached = store.readRaw(a.symbol, .candles_daily) catch null; + if (cached) |cdata| { + defer allocator.free(cdata); + if (zfin.cache.Store.deserializeCandles(allocator, cdata)) |candles| { + defer allocator.free(candles); + if (zfin.risk.computeRisk(candles)) |metrics| { + if (!any_risk) { + try out.print("\n Risk Metrics (from cached price data):\n", .{}); + 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", .{ + "", "", "", "", + }); + any_risk = true; + } + try out.print(" {s:>6} {d:>9.1}% {d:>8.2} {d:>9.1}%", .{ + a.symbol, metrics.volatility * 100.0, metrics.sharpe, metrics.max_drawdown * 100.0, + }); + if (metrics.drawdown_trough) |dt| { + var db: [10]u8 = undefined; + try out.print(" (trough {s})", .{dt.format(&db)}); + } + try out.print("\n", .{}); + } + } else |_| {} + } + } + + try out.print("\n", .{}); + try out.flush(); +} + +fn formatLargeNum(val: f64) [15]u8 { + var result: [15]u8 = .{' '} ** 15; + if (val >= 1_000_000_000_000) { + _ = std.fmt.bufPrint(&result, "{d:.1}T", .{val / 1_000_000_000_000}) catch {}; + } else if (val >= 1_000_000_000) { + _ = std.fmt.bufPrint(&result, "{d:.1}B", .{val / 1_000_000_000}) catch {}; + } else if (val >= 1_000_000) { + _ = std.fmt.bufPrint(&result, "{d:.1}M", .{val / 1_000_000}) catch {}; + } else { + _ = std.fmt.bufPrint(&result, "{d:.0}", .{val}) catch {}; + } + return result; +} + +fn cmdCache(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8) !void { + if (std.mem.eql(u8, subcommand, "stats")) { + var buf: [4096]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + try out.print("Cache directory: {s}\n", .{config.cache_dir}); + std.fs.cwd().access(config.cache_dir, .{}) catch { + try out.print(" (empty -- no cached data)\n", .{}); + try out.flush(); + return; + }; + // List symbol directories + var dir = std.fs.cwd().openDir(config.cache_dir, .{ .iterate = true }) catch { + try out.print(" (empty -- no cached data)\n", .{}); + try out.flush(); + return; + }; + defer dir.close(); + var count: usize = 0; + var iter = dir.iterate(); + while (iter.next() catch null) |entry| { + if (entry.kind == .directory) { + try out.print(" {s}/\n", .{entry.name}); + count += 1; + } + } + if (count == 0) { + try out.print(" (empty -- no cached data)\n", .{}); + } else { + try out.print("\n {d} symbol(s) cached\n", .{count}); + } + try out.flush(); + } else if (std.mem.eql(u8, subcommand, "clear")) { + var store = zfin.cache.Store.init(allocator, config.cache_dir); + try store.clearAll(); + try stdout_print("Cache cleared.\n"); + } else { + try stderr_print("Unknown cache subcommand. Use 'stats' or 'clear'.\n"); + } +} + +fn todayDate() zfin.Date { + const ts = std.time.timestamp(); + const days: i32 = @intCast(@divFloor(ts, 86400)); + return .{ .days = days }; +} + +fn stdout_print(msg: []const u8) !void { + var buf: [4096]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + try out.writeAll(msg); + try out.flush(); +} + +fn stderr_print(msg: []const u8) !void { + var buf: [1024]u8 = undefined; + var writer = std.fs.File.stderr().writer(&buf); + const out = &writer.interface; + try out.writeAll(msg); + try out.flush(); +} diff --git a/src/config.zig b/src/config.zig new file mode 100644 index 0000000..599269c --- /dev/null +++ b/src/config.zig @@ -0,0 +1,93 @@ +const std = @import("std"); + +pub const Config = struct { + twelvedata_key: ?[]const u8 = null, + polygon_key: ?[]const u8 = null, + finnhub_key: ?[]const u8 = null, + alphavantage_key: ?[]const u8 = null, + cache_dir: []const u8, + allocator: ?std.mem.Allocator = null, + env_buf: ?[]const u8 = null, + + pub fn fromEnv(allocator: std.mem.Allocator) Config { + var self = Config{ + .cache_dir = undefined, + .allocator = allocator, + }; + + // Try loading .env file from the project directory or home directory + self.env_buf = loadEnvFile(allocator); + + self.twelvedata_key = self.resolve("TWELVEDATA_API_KEY"); + self.polygon_key = self.resolve("POLYGON_API_KEY"); + self.finnhub_key = self.resolve("FINNHUB_API_KEY"); + self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY"); + + const env_cache = self.resolve("ZFIN_CACHE_DIR"); + self.cache_dir = env_cache orelse blk: { + const home = std.posix.getenv("HOME") orelse "/tmp"; + break :blk std.fs.path.join(allocator, &.{ home, ".cache", "zfin" }) catch @panic("OOM"); + }; + + return self; + } + + pub fn deinit(self: *Config) void { + if (self.allocator) |a| { + // Check if cache_dir was allocated (not from env/envfile) BEFORE freeing env_buf + const cache_dir_from_env = self.resolve("ZFIN_CACHE_DIR") != null; + if (self.env_buf) |buf| a.free(buf); + if (!cache_dir_from_env) { + a.free(self.cache_dir); + } + } + } + + pub fn hasAnyKey(self: Config) bool { + return self.twelvedata_key != null or + self.polygon_key != null or + self.finnhub_key != null or + self.alphavantage_key != null; + } + + /// Look up a key: environment variable first, then .env file fallback. + fn resolve(self: Config, key: []const u8) ?[]const u8 { + if (std.posix.getenv(key)) |v| return v; + return envFileGet(self.env_buf, key); + } +}; + +/// Parse a KEY=VALUE line from .env content. Returns value for the given key. +fn envFileGet(buf: ?[]const u8, key: []const u8) ?[]const u8 { + const data = buf orelse return null; + var iter = std.mem.splitScalar(u8, data, '\n'); + while (iter.next()) |line| { + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + if (std.mem.indexOfScalar(u8, trimmed, '=')) |eq| { + const k = std.mem.trim(u8, trimmed[0..eq], &std.ascii.whitespace); + if (std.mem.eql(u8, k, key)) { + return std.mem.trim(u8, trimmed[eq + 1 ..], &std.ascii.whitespace); + } + } + } + return null; +} + +/// Try to load .env from the executable's directory, then cwd. +fn loadEnvFile(allocator: std.mem.Allocator) ?[]const u8 { + // Try relative to the executable + const exe_dir = std.fs.selfExeDirPathAlloc(allocator) catch null; + defer if (exe_dir) |d| allocator.free(d); + + if (exe_dir) |dir| { + const path = std.fs.path.join(allocator, &.{ dir, "..", ".env" }) catch null; + defer if (path) |p| allocator.free(p); + if (path) |p| { + if (std.fs.cwd().readFileAlloc(allocator, p, 4096)) |data| return data else |_| {} + } + } + + // Try cwd + return std.fs.cwd().readFileAlloc(allocator, ".env", 4096) catch null; +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..b722d85 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,27 @@ +const std = @import("std"); +const zfin = @import("zfin"); + +pub fn main() !void { + // Prints to stderr, ignoring potential errors. + std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); + try zfin.bufferedPrint(); +} + +test "simple test" { + const gpa = std.testing.allocator; + var list: std.ArrayList(i32) = .empty; + defer list.deinit(gpa); // Try commenting this out and see if zig detects the memory leak! + try list.append(gpa, 42); + try std.testing.expectEqual(@as(i32, 42), list.pop()); +} + +test "fuzz example" { + const Context = struct { + fn testOne(context: @This(), input: []const u8) anyerror!void { + _ = context; + // Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case! + try std.testing.expect(!std.mem.eql(u8, "canyoufindme", input)); + } + }; + try std.testing.fuzz(Context{}, Context.testOne, .{}); +} diff --git a/src/models/candle.zig b/src/models/candle.zig new file mode 100644 index 0000000..e7a2193 --- /dev/null +++ b/src/models/candle.zig @@ -0,0 +1,14 @@ +const Date = @import("date.zig").Date; + +/// A single OHLCV bar, normalized from any provider. +pub const Candle = struct { + date: Date, + open: f64, + high: f64, + low: f64, + close: f64, + /// Close price adjusted for splits and dividends (for total return calculations). + /// If the provider does not supply this, it equals `close`. + adj_close: f64, + volume: u64, +}; diff --git a/src/models/date.zig b/src/models/date.zig new file mode 100644 index 0000000..3d3007a --- /dev/null +++ b/src/models/date.zig @@ -0,0 +1,199 @@ +/// Date represented as days since epoch (compact, sortable). +/// Use helper functions for formatting and parsing. +pub const Date = struct { + /// Days since 1970-01-01 + days: i32, + + pub const epoch = Date{ .days = 0 }; + + pub fn year(self: Date) i16 { + return epochDaysToYmd(self.days).year; + } + + pub fn month(self: Date) u8 { + return epochDaysToYmd(self.days).month; + } + + pub fn day(self: Date) u8 { + return epochDaysToYmd(self.days).day; + } + + pub fn fromYmd(y: i16, m: u8, d: u8) Date { + return .{ .days = ymdToEpochDays(y, m, d) }; + } + + /// Parse "YYYY-MM-DD" format + pub fn parse(str: []const u8) !Date { + if (str.len != 10 or str[4] != '-' or str[7] != '-') return error.InvalidDateFormat; + const y = std.fmt.parseInt(i16, str[0..4], 10) catch return error.InvalidDateFormat; + const m = std.fmt.parseInt(u8, str[5..7], 10) catch return error.InvalidDateFormat; + const d = std.fmt.parseInt(u8, str[8..10], 10) catch return error.InvalidDateFormat; + return fromYmd(y, m, d); + } + + /// Format as "YYYY-MM-DD" + pub fn format(self: Date, buf: *[10]u8) []const u8 { + const ymd = epochDaysToYmd(self.days); + const y: u16 = @intCast(ymd.year); + buf[0] = '0' + @as(u8, @intCast(y / 1000)); + buf[1] = '0' + @as(u8, @intCast((y / 100) % 10)); + buf[2] = '0' + @as(u8, @intCast((y / 10) % 10)); + buf[3] = '0' + @as(u8, @intCast(y % 10)); + buf[4] = '-'; + buf[5] = '0' + @as(u8, @intCast(ymd.month / 10)); + buf[6] = '0' + @as(u8, @intCast(ymd.month % 10)); + buf[7] = '-'; + buf[8] = '0' + @as(u8, @intCast(ymd.day / 10)); + buf[9] = '0' + @as(u8, @intCast(ymd.day % 10)); + return buf[0..10]; + } + + /// Day of week: 0=Monday, 1=Tuesday, ..., 4=Friday, 5=Saturday, 6=Sunday. + pub fn dayOfWeek(self: Date) u8 { + // 1970-01-01 was a Thursday (day 3 in 0=Mon scheme) + const d = @mod(self.days + 3, @as(i32, 7)); + return @intCast(if (d < 0) d + 7 else d); + } + + pub fn eql(a: Date, b: Date) bool { + return a.days == b.days; + } + + pub fn lessThan(a: Date, b: Date) bool { + return a.days < b.days; + } + + pub fn addDays(self: Date, n: i32) Date { + return .{ .days = self.days + n }; + } + + /// Subtract N calendar years. Clamps Feb 29 -> Feb 28 if target is not a leap year. + pub fn subtractYears(self: Date, n: u16) Date { + const ymd = epochDaysToYmd(self.days); + const new_year: i16 = ymd.year - @as(i16, @intCast(n)); + const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day; + return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) }; + } + + /// Return the last day of the previous month. + /// E.g., if self is 2026-02-24, returns 2026-01-31. + pub fn lastDayOfPriorMonth(self: Date) Date { + const ymd = epochDaysToYmd(self.days); + if (ymd.month == 1) { + return fromYmd(ymd.year - 1, 12, 31); + } else { + return fromYmd(ymd.year, ymd.month - 1, daysInMonth(ymd.year, ymd.month - 1)); + } + } + + fn daysInMonth(y: i16, m: u8) u8 { + const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; + if (m == 2 and isLeapYear(y)) return 29; + return table[m - 1]; + } + + /// Returns approximate number of years between two dates + pub fn yearsBetween(from: Date, to: Date) f64 { + return @as(f64, @floatFromInt(to.days - from.days)) / 365.25; + } + + fn isLeapYear(y: i16) bool { + const yu: u16 = @bitCast(y); + return (yu % 4 == 0 and yu % 100 != 0) or (yu % 400 == 0); + } +}; + +const Ymd = struct { year: i16, month: u8, day: u8 }; + +fn epochDaysToYmd(days: i32) Ymd { + // Algorithm from http://howardhinnant.github.io/date_algorithms.html + // Using i64 throughout to avoid overflow on unsigned intermediate values. + const z: i64 = @as(i64, days) + 719468; + const era: i64 = @divFloor(if (z >= 0) z else z - 146096, 146097); + const doe_i: i64 = z - era * 146097; // [0, 146096] + const doe: u64 = @intCast(doe_i); + const yoe_val: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399] + const y: i64 = @as(i64, @intCast(yoe_val)) + era * 400; + const doy: u64 = doe - (365 * yoe_val + yoe_val / 4 - yoe_val / 100); + const mp: u64 = (5 * doy + 2) / 153; + const d: u8 = @intCast(doy - (153 * mp + 2) / 5 + 1); + const m_raw: u64 = if (mp < 10) mp + 3 else mp - 9; + const m: u8 = @intCast(m_raw); + const y_adj: i16 = @intCast(if (m <= 2) y + 1 else y); + return .{ .year = y_adj, .month = m, .day = d }; +} + +fn ymdToEpochDays(y: i16, m: u8, d: u8) i32 { + const y_adj: i64 = @as(i64, y) - @as(i64, if (m <= 2) @as(i64, 1) else @as(i64, 0)); + const era: i64 = @divFloor(if (y_adj >= 0) y_adj else y_adj - 399, 400); + const yoe: u64 = @intCast(y_adj - era * 400); + const m_adj: u64 = if (m > 2) @as(u64, m) - 3 else @as(u64, m) + 9; + const doy: u64 = (153 * m_adj + 2) / 5 + @as(u64, d) - 1; + const doe: u64 = yoe * 365 + yoe / 4 -| yoe / 100 + doy; + return @intCast(era * 146097 + @as(i64, @intCast(doe)) - 719468); +} + +const std = @import("std"); + +test "date roundtrip" { + const d = Date.fromYmd(2024, 6, 15); + try std.testing.expectEqual(@as(i16, 2024), d.year()); + try std.testing.expectEqual(@as(u8, 6), d.month()); + try std.testing.expectEqual(@as(u8, 15), d.day()); +} + +test "date parse" { + const d = try Date.parse("2024-06-15"); + try std.testing.expectEqual(@as(i16, 2024), d.year()); + try std.testing.expectEqual(@as(u8, 6), d.month()); + try std.testing.expectEqual(@as(u8, 15), d.day()); +} + +test "date format" { + const d = Date.fromYmd(2024, 1, 5); + var buf: [10]u8 = undefined; + const s = d.format(&buf); + try std.testing.expectEqualStrings("2024-01-05", s); +} + +test "subtractYears" { + const d = Date.fromYmd(2026, 2, 24); + const d1 = d.subtractYears(1); + try std.testing.expectEqual(@as(i16, 2025), d1.year()); + try std.testing.expectEqual(@as(u8, 2), d1.month()); + try std.testing.expectEqual(@as(u8, 24), d1.day()); + + const d3 = d.subtractYears(3); + try std.testing.expectEqual(@as(i16, 2023), d3.year()); + + // Leap year edge case: Feb 29 2024 - 1 year = Feb 28 2023 + const leap = Date.fromYmd(2024, 2, 29); + const non_leap = leap.subtractYears(1); + try std.testing.expectEqual(@as(i16, 2023), non_leap.year()); + try std.testing.expectEqual(@as(u8, 2), non_leap.month()); + try std.testing.expectEqual(@as(u8, 28), non_leap.day()); +} + +test "lastDayOfPriorMonth" { + // Feb 24 -> Jan 31 + const d1 = Date.fromYmd(2026, 2, 24).lastDayOfPriorMonth(); + try std.testing.expectEqual(@as(i16, 2026), d1.year()); + try std.testing.expectEqual(@as(u8, 1), d1.month()); + try std.testing.expectEqual(@as(u8, 31), d1.day()); + + // Jan 15 -> Dec 31 of prior year + const d2 = Date.fromYmd(2026, 1, 15).lastDayOfPriorMonth(); + try std.testing.expectEqual(@as(i16, 2025), d2.year()); + try std.testing.expectEqual(@as(u8, 12), d2.month()); + try std.testing.expectEqual(@as(u8, 31), d2.day()); + + // Mar 1 leap year -> Feb 29 + const d3 = Date.fromYmd(2024, 3, 1).lastDayOfPriorMonth(); + try std.testing.expectEqual(@as(u8, 2), d3.month()); + try std.testing.expectEqual(@as(u8, 29), d3.day()); + + // Mar 1 non-leap -> Feb 28 + const d4 = Date.fromYmd(2025, 3, 1).lastDayOfPriorMonth(); + try std.testing.expectEqual(@as(u8, 2), d4.month()); + try std.testing.expectEqual(@as(u8, 28), d4.day()); +} diff --git a/src/models/dividend.zig b/src/models/dividend.zig new file mode 100644 index 0000000..631e1ac --- /dev/null +++ b/src/models/dividend.zig @@ -0,0 +1,27 @@ +const Date = @import("date.zig").Date; + +pub const DividendType = enum { + regular, + special, + supplemental, + irregular, + unknown, +}; + +/// A single dividend payment record. +pub const Dividend = struct { + /// Date the stock begins trading without the dividend + ex_date: Date, + /// Date the dividend is paid (may be null if unknown) + pay_date: ?Date = null, + /// Date of record for eligibility + record_date: ?Date = null, + /// Cash amount per share + amount: f64, + /// How many times per year this dividend is expected + frequency: ?u8 = null, + /// Classification of the dividend + distribution_type: DividendType = .unknown, + /// Currency code (e.g., "USD") + currency: ?[]const u8 = null, +}; diff --git a/src/models/earnings.zig b/src/models/earnings.zig new file mode 100644 index 0000000..4e35a7d --- /dev/null +++ b/src/models/earnings.zig @@ -0,0 +1,51 @@ +const Date = @import("date.zig").Date; + +pub const ReportTime = enum { + bmo, // before market open + amc, // after market close + dmh, // during market hours + unknown, +}; + +/// An earnings event (historical or upcoming). +pub const EarningsEvent = struct { + symbol: []const u8, + date: Date, + /// Estimated EPS (analyst consensus) + estimate: ?f64 = null, + /// Actual reported EPS (null if upcoming) + actual: ?f64 = null, + /// Surprise amount (actual - estimate) + surprise: ?f64 = null, + /// Surprise percentage + surprise_percent: ?f64 = null, + /// Fiscal quarter (1-4) + quarter: ?u8 = null, + /// Fiscal year + fiscal_year: ?i16 = null, + /// Revenue actual + revenue_actual: ?f64 = null, + /// Revenue estimate + revenue_estimate: ?f64 = null, + /// When earnings are reported relative to market hours + report_time: ReportTime = .unknown, + + pub fn isFuture(self: EarningsEvent) bool { + return self.actual == null; + } + + pub fn surpriseAmount(self: EarningsEvent) ?f64 { + if (self.surprise) |s| return s; + const act = self.actual orelse return null; + const est = self.estimate orelse return null; + return act - est; + } + + pub fn surprisePct(self: EarningsEvent) ?f64 { + if (self.surprise_percent) |s| return s; + const act = self.actual orelse return null; + const est = self.estimate orelse return null; + if (est == 0) return null; + return ((act - est) / @abs(est)) * 100.0; + } +}; diff --git a/src/models/etf_profile.zig b/src/models/etf_profile.zig new file mode 100644 index 0000000..74223c3 --- /dev/null +++ b/src/models/etf_profile.zig @@ -0,0 +1,43 @@ +const Date = @import("date.zig").Date; + +/// Top holding in an ETF. +pub const Holding = struct { + symbol: ?[]const u8 = null, + name: []const u8, + weight: f64, +}; + +/// Sector allocation in an ETF. +pub const SectorWeight = struct { + sector: []const u8, + weight: f64, +}; + +/// ETF profile and metadata. +pub const EtfProfile = struct { + symbol: []const u8, + name: ?[]const u8 = null, + asset_class: ?[]const u8 = null, + /// Expense ratio as a decimal (e.g., 0.0003 for 0.03%) + expense_ratio: ?f64 = null, + /// Net assets in USD + net_assets: ?f64 = null, + /// Morningstar-style category (e.g., "Large Blend") + category: ?[]const u8 = null, + /// Investment focus description + description: ?[]const u8 = null, + /// Top holdings + holdings: ?[]const Holding = null, + /// Number of total holdings in the fund + total_holdings: ?u32 = null, + /// Sector allocations + sectors: ?[]const SectorWeight = null, + /// Dividend yield as decimal (e.g., 0.0111 for 1.11%) + dividend_yield: ?f64 = null, + /// Portfolio turnover as decimal + portfolio_turnover: ?f64 = null, + /// Fund inception date + inception_date: ?Date = null, + /// Whether the fund is leveraged + leveraged: bool = false, +}; diff --git a/src/models/option.zig b/src/models/option.zig new file mode 100644 index 0000000..75a29c8 --- /dev/null +++ b/src/models/option.zig @@ -0,0 +1,35 @@ +const Date = @import("date.zig").Date; + +pub const ContractType = enum { + call, + put, +}; + +/// A single options contract in a chain. +pub const OptionContract = struct { + /// Full OCC symbol (e.g., "O:AAPL211022C000150000") + contract_symbol: ?[]const u8 = null, + contract_type: ContractType, + strike: f64, + expiration: Date, + bid: ?f64 = null, + ask: ?f64 = null, + last_price: ?f64 = null, + volume: ?u64 = null, + open_interest: ?u64 = null, + implied_volatility: ?f64 = null, + // Greeks + delta: ?f64 = null, + gamma: ?f64 = null, + theta: ?f64 = null, + vega: ?f64 = null, +}; + +/// Full options chain for an underlying asset at a given expiration. +pub const OptionsChain = struct { + underlying_symbol: []const u8, + underlying_price: ?f64 = null, + expiration: Date, + calls: []const OptionContract, + puts: []const OptionContract, +}; diff --git a/src/models/portfolio.zig b/src/models/portfolio.zig new file mode 100644 index 0000000..e42d8e7 --- /dev/null +++ b/src/models/portfolio.zig @@ -0,0 +1,231 @@ +const std = @import("std"); +const Date = @import("date.zig").Date; + +/// A single lot in a portfolio -- one purchase/sale event. +/// Open lots have no close_date/close_price. +/// Closed lots have both. +pub const Lot = struct { + symbol: []const u8, + shares: f64, + open_date: Date, + open_price: f64, + close_date: ?Date = null, + close_price: ?f64 = null, + /// Optional note/tag for the lot + note: ?[]const u8 = null, + /// Optional account identifier (e.g. "Roth IRA", "Brokerage") + account: ?[]const u8 = null, + + pub fn isOpen(self: Lot) bool { + return self.close_date == null; + } + + pub fn costBasis(self: Lot) f64 { + return self.shares * self.open_price; + } + + pub fn marketValue(self: Lot, current_price: f64) f64 { + return self.shares * current_price; + } + + pub fn realizedPnl(self: Lot) ?f64 { + const cp = self.close_price orelse return null; + return self.shares * (cp - self.open_price); + } + + pub fn unrealizedPnl(self: Lot, current_price: f64) f64 { + return self.shares * (current_price - self.open_price); + } + + pub fn returnPct(self: Lot, current_price: f64) f64 { + if (self.open_price == 0) return 0; + const price = if (self.close_price) |cp| cp else current_price; + return (price / self.open_price) - 1.0; + } +}; + +/// Aggregated position for a single symbol across multiple lots. +pub const Position = struct { + symbol: []const u8, + /// Total open shares + shares: f64, + /// Weighted average cost basis per share (open lots only) + avg_cost: f64, + /// Total cost basis of open lots + total_cost: f64, + /// Number of open lots + open_lots: u32, + /// Number of closed lots + closed_lots: u32, + /// Total realized P&L from closed lots + realized_pnl: f64, +}; + +/// A portfolio is a collection of lots. +pub const Portfolio = struct { + lots: []Lot, + allocator: std.mem.Allocator, + + pub fn deinit(self: *Portfolio) void { + for (self.lots) |lot| { + self.allocator.free(lot.symbol); + if (lot.note) |n| self.allocator.free(n); + if (lot.account) |a| self.allocator.free(a); + } + self.allocator.free(self.lots); + } + + /// Get all unique symbols in the portfolio. + pub fn symbols(self: Portfolio, allocator: std.mem.Allocator) ![][]const u8 { + var seen = std.StringHashMap(void).init(allocator); + defer seen.deinit(); + + for (self.lots) |lot| { + try seen.put(lot.symbol, {}); + } + + var result = std.ArrayList([]const u8).empty; + errdefer result.deinit(allocator); + + var iter = seen.keyIterator(); + while (iter.next()) |key| { + try result.append(allocator, key.*); + } + return result.toOwnedSlice(allocator); + } + + /// Get all lots for a given symbol. + pub fn lotsForSymbol(self: Portfolio, allocator: std.mem.Allocator, symbol: []const u8) ![]Lot { + var result = std.ArrayList(Lot).empty; + errdefer result.deinit(allocator); + + for (self.lots) |lot| { + if (std.mem.eql(u8, lot.symbol, symbol)) { + try result.append(allocator, lot); + } + } + return result.toOwnedSlice(allocator); + } + + /// Aggregate lots into positions by symbol. + pub fn positions(self: Portfolio, allocator: std.mem.Allocator) ![]Position { + var map = std.StringHashMap(Position).init(allocator); + defer map.deinit(); + + for (self.lots) |lot| { + const entry = try map.getOrPut(lot.symbol); + if (!entry.found_existing) { + entry.value_ptr.* = .{ + .symbol = lot.symbol, + .shares = 0, + .avg_cost = 0, + .total_cost = 0, + .open_lots = 0, + .closed_lots = 0, + .realized_pnl = 0, + }; + } + if (lot.isOpen()) { + entry.value_ptr.shares += lot.shares; + entry.value_ptr.total_cost += lot.costBasis(); + entry.value_ptr.open_lots += 1; + } else { + entry.value_ptr.closed_lots += 1; + entry.value_ptr.realized_pnl += lot.realizedPnl() orelse 0; + } + } + + // Compute avg_cost + var iter = map.valueIterator(); + while (iter.next()) |pos| { + if (pos.shares > 0) { + pos.avg_cost = pos.total_cost / pos.shares; + } + } + + var result = std.ArrayList(Position).empty; + errdefer result.deinit(allocator); + + var viter = map.valueIterator(); + while (viter.next()) |pos| { + try result.append(allocator, pos.*); + } + return result.toOwnedSlice(allocator); + } + + /// Total cost basis of all open lots. + pub fn totalCostBasis(self: Portfolio) f64 { + var total: f64 = 0; + for (self.lots) |lot| { + if (lot.isOpen()) total += lot.costBasis(); + } + return total; + } + + /// Total realized P&L from all closed lots. + pub fn totalRealizedPnl(self: Portfolio) f64 { + var total: f64 = 0; + for (self.lots) |lot| { + if (lot.realizedPnl()) |pnl| total += pnl; + } + return total; + } +}; + +test "lot basics" { + const lot = Lot{ + .symbol = "AAPL", + .shares = 10, + .open_date = Date.fromYmd(2024, 1, 15), + .open_price = 150.0, + }; + try std.testing.expect(lot.isOpen()); + try std.testing.expectApproxEqAbs(@as(f64, 1500.0), lot.costBasis(), 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 2000.0), lot.marketValue(200.0), 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.unrealizedPnl(200.0), 0.01); + try std.testing.expect(lot.realizedPnl() == null); +} + +test "closed lot" { + const lot = Lot{ + .symbol = "AAPL", + .shares = 10, + .open_date = Date.fromYmd(2024, 1, 15), + .open_price = 150.0, + .close_date = Date.fromYmd(2024, 6, 15), + .close_price = 200.0, + }; + try std.testing.expect(!lot.isOpen()); + try std.testing.expectApproxEqAbs(@as(f64, 500.0), lot.realizedPnl().?, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 0.3333), lot.returnPct(0), 0.001); +} + +test "portfolio positions" { + const allocator = std.testing.allocator; + + var lots = [_]Lot{ + .{ .symbol = "AAPL", .shares = 10, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0 }, + .{ .symbol = "AAPL", .shares = 5, .open_date = Date.fromYmd(2024, 3, 1), .open_price = 160.0 }, + .{ .symbol = "VTI", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 220.0 }, + .{ .symbol = "AAPL", .shares = 3, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 130.0, .close_date = Date.fromYmd(2024, 2, 1), .close_price = 155.0 }, + }; + + var portfolio = Portfolio{ .lots = &lots, .allocator = allocator }; + // Don't call deinit since these are stack-allocated test strings + + const pos = try portfolio.positions(allocator); + defer allocator.free(pos); + + try std.testing.expectEqual(@as(usize, 2), pos.len); + + // Find AAPL position + var aapl: ?Position = null; + for (pos) |p| { + if (std.mem.eql(u8, p.symbol, "AAPL")) aapl = p; + } + try std.testing.expect(aapl != null); + try std.testing.expectApproxEqAbs(@as(f64, 15.0), aapl.?.shares, 0.01); + try std.testing.expectEqual(@as(u32, 2), aapl.?.open_lots); + try std.testing.expectEqual(@as(u32, 1), aapl.?.closed_lots); + try std.testing.expectApproxEqAbs(@as(f64, 75.0), aapl.?.realized_pnl, 0.01); // 3 * (155-130) +} diff --git a/src/models/quote.zig b/src/models/quote.zig new file mode 100644 index 0000000..8917261 --- /dev/null +++ b/src/models/quote.zig @@ -0,0 +1,18 @@ +/// Real-time (or near-real-time) quote snapshot for a symbol. +pub const Quote = struct { + symbol: []const u8, + name: []const u8, + exchange: []const u8, + datetime: []const u8, + close: f64, + open: f64, + high: f64, + low: f64, + volume: u64, + previous_close: f64, + change: f64, + percent_change: f64, + average_volume: u64, + fifty_two_week_low: f64, + fifty_two_week_high: f64, +}; diff --git a/src/models/split.zig b/src/models/split.zig new file mode 100644 index 0000000..bbcf7a3 --- /dev/null +++ b/src/models/split.zig @@ -0,0 +1,15 @@ +const Date = @import("date.zig").Date; + +/// A stock split event. +pub const Split = struct { + date: Date, + /// Number of shares after the split (e.g., 4 in a 4:1 split) + numerator: f64, + /// Number of shares before the split (e.g., 1 in a 4:1 split) + denominator: f64, + + /// Returns the split ratio (e.g., 4.0 for a 4:1 split) + pub fn ratio(self: Split) f64 { + return self.numerator / self.denominator; + } +}; diff --git a/src/models/ticker_info.zig b/src/models/ticker_info.zig new file mode 100644 index 0000000..65b31f6 --- /dev/null +++ b/src/models/ticker_info.zig @@ -0,0 +1,19 @@ +pub const SecurityType = enum { + stock, + etf, + mutual_fund, + index, + crypto, + forex, + unknown, +}; + +/// Basic information about a ticker symbol. +pub const TickerInfo = struct { + symbol: []const u8, + name: ?[]const u8 = null, + exchange: ?[]const u8 = null, + security_type: SecurityType = .unknown, + currency: ?[]const u8 = null, + country: ?[]const u8 = null, +}; diff --git a/src/net/http.zig b/src/net/http.zig new file mode 100644 index 0000000..f45e5d5 --- /dev/null +++ b/src/net/http.zig @@ -0,0 +1,158 @@ +const std = @import("std"); + +pub const HttpError = error{ + RequestFailed, + RateLimited, + Unauthorized, + NotFound, + ServerError, + InvalidResponse, + OutOfMemory, +}; + +pub const Response = struct { + status: std.http.Status, + body: []const u8, + allocator: std.mem.Allocator, + + pub fn deinit(self: *Response) void { + self.allocator.free(self.body); + } +}; + +/// Thin HTTP client wrapper with retry and error classification. +pub const Client = struct { + allocator: std.mem.Allocator, + http_client: std.http.Client, + max_retries: u8 = 3, + base_backoff_ms: u64 = 500, + + pub fn init(allocator: std.mem.Allocator) Client { + return .{ + .allocator = allocator, + .http_client = std.http.Client{ .allocator = allocator }, + }; + } + + pub fn deinit(self: *Client) void { + self.http_client.deinit(); + } + + /// Perform a GET request with automatic retries on transient errors. + pub fn get(self: *Client, url: []const u8) HttpError!Response { + var attempt: u8 = 0; + while (true) : (attempt += 1) { + if (self.doGet(url)) |response| { + return classifyResponse(response); + } else |_| { + if (attempt >= self.max_retries) return HttpError.RequestFailed; + const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt); + std.Thread.sleep(backoff * std.time.ns_per_ms); + } + } + } + + fn doGet(self: *Client, url: []const u8) HttpError!Response { + var aw: std.Io.Writer.Allocating = .init(self.allocator); + + const result = self.http_client.fetch(.{ + .location = .{ .url = url }, + .response_writer = &aw.writer, + }) catch |err| { + aw.deinit(); + // TLS 1.2-only hosts (e.g., finnhub.io) fail with Zig's TLS 1.3-only client. + // Fall back to system curl for these cases. + if (err == error.TlsInitializationFailed) { + return curlGet(self.allocator, url); + } + return HttpError.RequestFailed; + }; + + const body = aw.toOwnedSlice() catch { + aw.deinit(); + return HttpError.OutOfMemory; + }; + + return .{ + .status = result.status, + .body = body, + .allocator = self.allocator, + }; + } + + fn classifyResponse(response: Response) HttpError!Response { + return switch (response.status) { + .ok => response, + .too_many_requests => HttpError.RateLimited, + .unauthorized, .forbidden => HttpError.Unauthorized, + .not_found => HttpError.NotFound, + .internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError, + else => HttpError.InvalidResponse, + }; + } +}; + +/// Fallback HTTP GET using system curl for TLS 1.2 hosts. +fn curlGet(allocator: std.mem.Allocator, url: []const u8) HttpError!Response { + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "curl", "-sS", "-f", "-L", "--max-time", "30", url }, + .max_output_bytes = 10 * 1024 * 1024, + }) catch return HttpError.RequestFailed; + + allocator.free(result.stderr); + + const success = switch (result.term) { + .Exited => |code| code == 0, + else => false, + }; + + if (!success) { + allocator.free(result.stdout); + return HttpError.RequestFailed; + } + + return .{ + .status = .ok, + .body = result.stdout, + .allocator = allocator, + }; +} + +/// Build a URL with query parameters. +pub fn buildUrl( + allocator: std.mem.Allocator, + base: []const u8, + params: []const [2][]const u8, +) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + + try buf.appendSlice(allocator, base); + for (params, 0..) |param, i| { + try buf.append(allocator, if (i == 0) '?' else '&'); + try buf.appendSlice(allocator, param[0]); + try buf.append(allocator, '='); + for (param[1]) |c| { + switch (c) { + ' ' => try buf.appendSlice(allocator, "%20"), + '&' => try buf.appendSlice(allocator, "%26"), + '=' => try buf.appendSlice(allocator, "%3D"), + '+' => try buf.appendSlice(allocator, "%2B"), + else => try buf.append(allocator, c), + } + } + } + + return buf.toOwnedSlice(allocator); +} + +test "buildUrl" { + const allocator = std.testing.allocator; + const url = try buildUrl(allocator, "https://api.example.com/v1/data", &.{ + .{ "symbol", "AAPL" }, + .{ "apikey", "test123" }, + }); + defer allocator.free(url); + try std.testing.expectEqualStrings("https://api.example.com/v1/data?symbol=AAPL&apikey=test123", url); +} diff --git a/src/net/rate_limiter.zig b/src/net/rate_limiter.zig new file mode 100644 index 0000000..9934f9f --- /dev/null +++ b/src/net/rate_limiter.zig @@ -0,0 +1,87 @@ +const std = @import("std"); + +/// Token-bucket rate limiter. Enforces a maximum number of requests per time window. +pub const RateLimiter = struct { + /// Maximum tokens (requests) in the bucket + max_tokens: u32, + /// Current available tokens + tokens: f64, + /// Tokens added per nanosecond + refill_rate_per_ns: f64, + /// Last time tokens were refilled + last_refill: i128, + + /// Create a rate limiter. + /// `max_per_window` is the max requests allowed in `window_ns` nanoseconds. + pub fn init(max_per_window: u32, window_ns: u64) RateLimiter { + return .{ + .max_tokens = max_per_window, + .tokens = @floatFromInt(max_per_window), + .refill_rate_per_ns = @as(f64, @floatFromInt(max_per_window)) / @as(f64, @floatFromInt(window_ns)), + .last_refill = std.time.nanoTimestamp(), + }; + } + + /// Convenience: N requests per minute + pub fn perMinute(n: u32) RateLimiter { + return init(n, 60 * std.time.ns_per_s); + } + + /// Convenience: N requests per day + pub fn perDay(n: u32) RateLimiter { + return init(n, 24 * 3600 * std.time.ns_per_s); + } + + /// Try to acquire a token. Returns true if granted, false if rate-limited. + /// Caller should sleep and retry if false. + pub fn tryAcquire(self: *RateLimiter) bool { + self.refill(); + if (self.tokens >= 1.0) { + self.tokens -= 1.0; + return true; + } + return false; + } + + /// Acquire a token, blocking (sleeping) until one is available. + pub fn acquire(self: *RateLimiter) void { + while (!self.tryAcquire()) { + // Sleep for the time needed to generate 1 token + const wait_ns: u64 = @intFromFloat(1.0 / self.refill_rate_per_ns); + std.Thread.sleep(wait_ns); + } + } + + /// Returns estimated wait time in nanoseconds until a token is available. + /// Returns 0 if a token is available now. + pub fn estimateWaitNs(self: *RateLimiter) u64 { + self.refill(); + if (self.tokens >= 1.0) return 0; + const deficit = 1.0 - self.tokens; + return @intFromFloat(deficit / self.refill_rate_per_ns); + } + + fn refill(self: *RateLimiter) void { + const now = std.time.nanoTimestamp(); + const elapsed = now - self.last_refill; + if (elapsed <= 0) return; + + const new_tokens = @as(f64, @floatFromInt(elapsed)) * self.refill_rate_per_ns; + self.tokens = @min(self.tokens + new_tokens, @as(f64, @floatFromInt(self.max_tokens))); + self.last_refill = now; + } +}; + +test "rate limiter basic" { + var rl = RateLimiter.perMinute(60); + // Should have full bucket initially + try std.testing.expect(rl.tryAcquire()); +} + +test "rate limiter exhaustion" { + var rl = RateLimiter.init(2, std.time.ns_per_s); + try std.testing.expect(rl.tryAcquire()); + try std.testing.expect(rl.tryAcquire()); + // Bucket should be empty now + try std.testing.expect(!rl.tryAcquire()); +} diff --git a/src/providers/alphavantage.zig b/src/providers/alphavantage.zig new file mode 100644 index 0000000..cac9e79 --- /dev/null +++ b/src/providers/alphavantage.zig @@ -0,0 +1,216 @@ +//! Alpha Vantage API provider -- used for ETF profiles (free endpoint). +//! API docs: https://www.alphavantage.co/documentation/ +//! +//! Free tier: 25 requests/day. Only used for data other providers don't have. +//! +//! ETF Profile endpoint: GET /query?function=ETF_PROFILE&symbol=X&apikey=KEY +//! Returns net assets, expense ratio, sector weights, top holdings, etc. + +const std = @import("std"); +const http = @import("../net/http.zig"); +const RateLimiter = @import("../net/rate_limiter.zig").RateLimiter; +const Date = @import("../models/date.zig").Date; +const EtfProfile = @import("../models/etf_profile.zig").EtfProfile; +const Holding = @import("../models/etf_profile.zig").Holding; +const SectorWeight = @import("../models/etf_profile.zig").SectorWeight; +const provider = @import("provider.zig"); + +const base_url = "https://www.alphavantage.co/query"; + +pub const AlphaVantage = struct { + api_key: []const u8, + client: http.Client, + rate_limiter: RateLimiter, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator, api_key: []const u8) AlphaVantage { + return .{ + .api_key = api_key, + .client = http.Client.init(allocator), + .rate_limiter = RateLimiter.perDay(25), + .allocator = allocator, + }; + } + + pub fn deinit(self: *AlphaVantage) void { + self.client.deinit(); + } + + /// Fetch ETF profile data: expense ratio, holdings, sectors, etc. + pub fn fetchEtfProfile( + self: *AlphaVantage, + allocator: std.mem.Allocator, + symbol: []const u8, + ) provider.ProviderError!EtfProfile { + self.rate_limiter.acquire(); + + const url = http.buildUrl(allocator, base_url, &.{ + .{ "function", "ETF_PROFILE" }, + .{ "symbol", symbol }, + .{ "apikey", self.api_key }, + }) catch return provider.ProviderError.OutOfMemory; + defer allocator.free(url); + + var response = self.client.get(url) catch |err| return mapHttpError(err); + defer response.deinit(); + + return parseEtfProfileResponse(allocator, response.body, symbol); + } + + pub fn asProvider(self: *AlphaVantage) provider.Provider { + return .{ + .ptr = @ptrCast(self), + .vtable = &vtable, + }; + } + + const vtable = provider.Provider.VTable{ + .fetchEtfProfile = @ptrCast(&fetchEtfProfileVtable), + .name = .alphavantage, + }; + + fn fetchEtfProfileVtable( + ptr: *AlphaVantage, + allocator: std.mem.Allocator, + symbol: []const u8, + ) provider.ProviderError!EtfProfile { + return ptr.fetchEtfProfile(allocator, symbol); + } +}; + +// -- JSON parsing -- + +fn parseEtfProfileResponse( + allocator: std.mem.Allocator, + body: []const u8, + symbol: []const u8, +) provider.ProviderError!EtfProfile { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + defer parsed.deinit(); + + const root = parsed.value.object; + + // Alpha Vantage returns {"Error Message": "..."} or {"Note": "..."} on error/rate limit + if (root.get("Error Message")) |_| return provider.ProviderError.RequestFailed; + if (root.get("Note")) |_| return provider.ProviderError.RateLimited; + if (root.get("Information")) |_| return provider.ProviderError.RateLimited; + + var profile = EtfProfile{ + .symbol = symbol, + }; + + if (root.get("net_assets")) |v| { + profile.net_assets = parseStrFloat(v); + } + if (root.get("net_expense_ratio")) |v| { + profile.expense_ratio = parseStrFloat(v); + } + if (root.get("portfolio_turnover")) |v| { + profile.portfolio_turnover = parseStrFloat(v); + } + if (root.get("dividend_yield")) |v| { + profile.dividend_yield = parseStrFloat(v); + } + if (root.get("inception_date")) |v| { + if (jsonStr(v)) |s| { + profile.inception_date = Date.parse(s) catch null; + } + } + if (root.get("leveraged")) |v| { + if (jsonStr(v)) |s| { + profile.leveraged = std.mem.eql(u8, s, "YES"); + } + } + + // Parse sectors + if (root.get("sectors")) |sectors_val| { + if (sectors_val == .array) { + var sectors: std.ArrayList(SectorWeight) = .empty; + errdefer sectors.deinit(allocator); + + for (sectors_val.array.items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + const name = jsonStr(obj.get("sector")) orelse continue; + const weight = parseStrFloat(obj.get("weight") orelse continue) orelse continue; + + const duped_name = allocator.dupe(u8, name) catch return provider.ProviderError.OutOfMemory; + sectors.append(allocator, .{ + .sector = duped_name, + .weight = weight, + }) catch return provider.ProviderError.OutOfMemory; + } + profile.sectors = sectors.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; + } + } + + // Parse top holdings (limit to top 20 to keep output manageable) + if (root.get("holdings")) |holdings_val| { + if (holdings_val == .array) { + const max_holdings: usize = 20; + var holdings: std.ArrayList(Holding) = .empty; + errdefer holdings.deinit(allocator); + + const total: u32 = @intCast(holdings_val.array.items.len); + profile.total_holdings = total; + + const limit = @min(holdings_val.array.items.len, max_holdings); + for (holdings_val.array.items[0..limit]) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + const desc = jsonStr(obj.get("description")) orelse continue; + const weight = parseStrFloat(obj.get("weight") orelse continue) orelse continue; + + const duped_sym = if (jsonStr(obj.get("symbol"))) |s| + (allocator.dupe(u8, s) catch return provider.ProviderError.OutOfMemory) + else + null; + const duped_name = allocator.dupe(u8, desc) catch return provider.ProviderError.OutOfMemory; + + holdings.append(allocator, .{ + .symbol = duped_sym, + .name = duped_name, + .weight = weight, + }) catch return provider.ProviderError.OutOfMemory; + } + profile.holdings = holdings.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; + } + } + + return profile; +} + +// -- Helpers -- + +fn parseStrFloat(val: ?std.json.Value) ?f64 { + const v = val orelse return null; + return switch (v) { + .string => |s| std.fmt.parseFloat(f64, s) catch null, + .float => |f| f, + .integer => |i| @as(f64, @floatFromInt(i)), + .null => null, + else => null, + }; +} + +fn jsonStr(val: ?std.json.Value) ?[]const u8 { + const v = val orelse return null; + return switch (v) { + .string => |s| s, + else => null, + }; +} + +fn mapHttpError(err: http.HttpError) provider.ProviderError { + return switch (err) { + error.RateLimited => provider.ProviderError.RateLimited, + error.Unauthorized => provider.ProviderError.Unauthorized, + error.NotFound => provider.ProviderError.NotFound, + else => provider.ProviderError.RequestFailed, + }; +} diff --git a/src/providers/cboe.zig b/src/providers/cboe.zig new file mode 100644 index 0000000..55208ac --- /dev/null +++ b/src/providers/cboe.zig @@ -0,0 +1,310 @@ +//! CBOE delayed quotes provider -- options chains from the exchange itself. +//! No API key required. Data is 15-minute delayed during market hours. +//! +//! Endpoint: GET https://cdn.cboe.com/api/global/delayed_quotes/options/{SYMBOL}.json +//! Returns all expirations with full chains including greeks, bid/ask, volume, OI. + +const std = @import("std"); +const http = @import("../net/http.zig"); +const RateLimiter = @import("../net/rate_limiter.zig").RateLimiter; +const Date = @import("../models/date.zig").Date; +const OptionContract = @import("../models/option.zig").OptionContract; +const OptionsChain = @import("../models/option.zig").OptionsChain; +const ContractType = @import("../models/option.zig").ContractType; +const provider = @import("provider.zig"); + +const base_url = "https://cdn.cboe.com/api/global/delayed_quotes/options"; + +pub const Cboe = struct { + client: http.Client, + rate_limiter: RateLimiter, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) Cboe { + return .{ + .client = http.Client.init(allocator), + .rate_limiter = RateLimiter.perMinute(30), + .allocator = allocator, + }; + } + + pub fn deinit(self: *Cboe) void { + self.client.deinit(); + } + + /// Fetch the full options chain for a symbol (all expirations). + /// Returns chains grouped by expiration date, sorted nearest-first. + pub fn fetchOptionsChain( + self: *Cboe, + allocator: std.mem.Allocator, + symbol: []const u8, + ) provider.ProviderError![]OptionsChain { + self.rate_limiter.acquire(); + + // Build URL: {base_url}/{SYMBOL}.json + const url = buildCboeUrl(allocator, symbol) catch return provider.ProviderError.OutOfMemory; + defer allocator.free(url); + + var response = self.client.get(url) catch |err| return mapHttpError(err); + defer response.deinit(); + + return parseResponse(allocator, response.body, symbol); + } +}; + +fn buildCboeUrl(allocator: std.mem.Allocator, symbol: []const u8) ![]const u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + + try buf.appendSlice(allocator, base_url); + try buf.append(allocator, '/'); + try buf.appendSlice(allocator, symbol); + try buf.appendSlice(allocator, ".json"); + + return buf.toOwnedSlice(allocator); +} + +/// Parse a CBOE options response into grouped OptionsChain slices. +fn parseResponse( + allocator: std.mem.Allocator, + body: []const u8, + symbol: []const u8, +) provider.ProviderError![]OptionsChain { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + defer parsed.deinit(); + + const root = switch (parsed.value) { + .object => |o| o, + else => return provider.ProviderError.ParseError, + }; + + const data_obj = switch (root.get("data") orelse return provider.ProviderError.ParseError) { + .object => |o| o, + else => return provider.ProviderError.ParseError, + }; + + const underlying_price: ?f64 = if (data_obj.get("current_price")) |v| optFloat(v) else null; + + const options_arr = switch (data_obj.get("options") orelse return provider.ProviderError.ParseError) { + .array => |a| a.items, + else => return provider.ProviderError.ParseError, + }; + + // Parse all contracts and group by expiration. + // Use an ArrayList of (expiration, calls_list, puts_list) tuples. + var exp_map = ExpMap{}; + + for (options_arr) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + + const occ_sym = switch (obj.get("option") orelse continue) { + .string => |s| s, + else => continue, + }; + + const occ = parseOccSymbol(occ_sym, symbol.len) orelse continue; + + const contract = OptionContract{ + .contract_type = occ.contract_type, + .strike = occ.strike, + .expiration = occ.expiration, + .bid = optFloat(obj.get("bid")), + .ask = optFloat(obj.get("ask")), + .last_price = optFloat(obj.get("last_trade_price")), + .volume = optUint(obj.get("volume")), + .open_interest = optUint(obj.get("open_interest")), + .implied_volatility = optFloat(obj.get("iv")), + .delta = optFloat(obj.get("delta")), + .gamma = optFloat(obj.get("gamma")), + .theta = optFloat(obj.get("theta")), + .vega = optFloat(obj.get("vega")), + }; + + // Find or create the expiration bucket + const bucket = exp_map.getOrPut(allocator, occ.expiration) catch + return provider.ProviderError.OutOfMemory; + + switch (occ.contract_type) { + .call => bucket.calls.append(allocator, contract) catch return provider.ProviderError.OutOfMemory, + .put => bucket.puts.append(allocator, contract) catch return provider.ProviderError.OutOfMemory, + } + } + + // Convert to sorted OptionsChain slice + const owned_symbol = allocator.dupe(u8, symbol) catch return provider.ProviderError.OutOfMemory; + errdefer allocator.free(owned_symbol); + + return exp_map.toOwnedChains(allocator, owned_symbol, underlying_price); +} + +// ── OCC symbol parsing ────────────────────────────────────────────── + +const OccInfo = struct { + expiration: Date, + contract_type: ContractType, + strike: f64, +}; + +/// Parse OCC option symbol: AAPL260225C00205000 +/// Format: {underlying}{YYMMDD}{C|P}{strike * 1000, zero-padded to 8 digits} +fn parseOccSymbol(sym: []const u8, underlying_len: usize) ?OccInfo { + // After underlying: 6 digits date + 1 char type + 8 digits strike = 15 chars + if (sym.len < underlying_len + 15) return null; + + const rest = sym[underlying_len..]; + + // Parse date: YYMMDD + const yy = std.fmt.parseInt(i16, rest[0..2], 10) catch return null; + const mm = std.fmt.parseInt(u8, rest[2..4], 10) catch return null; + const dd = std.fmt.parseInt(u8, rest[4..6], 10) catch return null; + const expiration = Date.fromYmd(2000 + yy, mm, dd); + + // Parse type + const contract_type: ContractType = switch (rest[6]) { + 'C' => .call, + 'P' => .put, + else => return null, + }; + + // Parse strike: 8 digits, divide by 1000 + const strike_raw = std.fmt.parseInt(u64, rest[7..15], 10) catch return null; + const strike: f64 = @as(f64, @floatFromInt(strike_raw)) / 1000.0; + + return .{ + .expiration = expiration, + .contract_type = contract_type, + .strike = strike, + }; +} + +// ── Expiration grouping ───────────────────────────────────────────── + +/// Maps expiration dates to call/put ArrayLists. Uses a simple sorted array +/// since the number of expirations is small (typically 10-30). +const ExpMap = struct { + entries: std.ArrayList(Entry) = .empty, + + const Entry = struct { + expiration: Date, + calls: std.ArrayList(OptionContract) = .empty, + puts: std.ArrayList(OptionContract) = .empty, + }; + + const Bucket = struct { + calls: *std.ArrayList(OptionContract), + puts: *std.ArrayList(OptionContract), + }; + + fn getOrPut(self: *ExpMap, allocator: std.mem.Allocator, exp: Date) !Bucket { + for (self.entries.items) |*entry| { + if (entry.expiration.eql(exp)) { + return .{ .calls = &entry.calls, .puts = &entry.puts }; + } + } + try self.entries.append(allocator, .{ .expiration = exp }); + const last = &self.entries.items[self.entries.items.len - 1]; + return .{ .calls = &last.calls, .puts = &last.puts }; + } + + /// Convert to owned []OptionsChain, sorted by expiration ascending. + /// Frees internal structures; caller owns the returned chains. + fn toOwnedChains( + self: *ExpMap, + allocator: std.mem.Allocator, + owned_symbol: []const u8, + underlying_price: ?f64, + ) provider.ProviderError![]OptionsChain { + // Sort entries by expiration + std.mem.sort(Entry, self.entries.items, {}, struct { + fn lessThan(_: void, a: Entry, b: Entry) bool { + return a.expiration.lessThan(b.expiration); + } + }.lessThan); + + var chains = allocator.alloc(OptionsChain, self.entries.items.len) catch + return provider.ProviderError.OutOfMemory; + errdefer allocator.free(chains); + + for (self.entries.items, 0..) |*entry, i| { + const calls = entry.calls.toOwnedSlice(allocator) catch + return provider.ProviderError.OutOfMemory; + const puts = entry.puts.toOwnedSlice(allocator) catch { + allocator.free(calls); + return provider.ProviderError.OutOfMemory; + }; + + chains[i] = .{ + .underlying_symbol = owned_symbol, + .underlying_price = underlying_price, + .expiration = entry.expiration, + .calls = calls, + .puts = puts, + }; + } + + self.entries.deinit(allocator); + + return chains; + } +}; + +// ── JSON helpers ──────────────────────────────────────────────────── + +fn optFloat(val: ?std.json.Value) ?f64 { + const v = val orelse return null; + return switch (v) { + .float => |f| f, + .integer => |i| @floatFromInt(i), + .null => null, + else => null, + }; +} + +fn optUint(val: ?std.json.Value) ?u64 { + const v = val orelse return null; + return switch (v) { + .integer => |i| if (i >= 0) @intCast(i) else null, + .float => |f| if (f >= 0 and f == @floor(f)) @intFromFloat(f) else null, + .null => null, + else => null, + }; +} + +fn mapHttpError(err: http.HttpError) provider.ProviderError { + return switch (err) { + error.RateLimited => provider.ProviderError.RateLimited, + error.Unauthorized => provider.ProviderError.Unauthorized, + error.NotFound => provider.ProviderError.NotFound, + else => provider.ProviderError.RequestFailed, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────── + +test "parseOccSymbol -- call" { + const info = parseOccSymbol("AAPL260225C00205000", 4).?; + try std.testing.expect(info.contract_type == .call); + try std.testing.expect(info.expiration.eql(Date.fromYmd(2026, 2, 25))); + try std.testing.expectApproxEqAbs(@as(f64, 205.0), info.strike, 0.001); +} + +test "parseOccSymbol -- put" { + const info = parseOccSymbol("AMZN260306P00180000", 4).?; + try std.testing.expect(info.contract_type == .put); + try std.testing.expect(info.expiration.eql(Date.fromYmd(2026, 3, 6))); + try std.testing.expectApproxEqAbs(@as(f64, 180.0), info.strike, 0.001); +} + +test "parseOccSymbol -- fractional strike" { + const info = parseOccSymbol("SPY260227C00555500", 3).?; + try std.testing.expectApproxEqAbs(@as(f64, 555.5), info.strike, 0.001); +} + +test "parseOccSymbol -- invalid" { + try std.testing.expect(parseOccSymbol("X", 1) == null); + try std.testing.expect(parseOccSymbol("AAPL26022", 4) == null); +} diff --git a/src/providers/finnhub.zig b/src/providers/finnhub.zig new file mode 100644 index 0000000..ff3baed --- /dev/null +++ b/src/providers/finnhub.zig @@ -0,0 +1,464 @@ +//! Finnhub API provider -- primary source for options chains and earnings. +//! API docs: https://finnhub.io/docs/api +//! +//! Free tier: 60 requests/min, all US market data. +//! +//! Options endpoint: GET /api/v1/stock/option-chain?symbol=X +//! Returns all expirations with full CALL/PUT chains including greeks. +//! +//! Earnings endpoint: GET /api/v1/calendar/earnings?symbol=X&from=YYYY-MM-DD&to=YYYY-MM-DD +//! Returns historical and upcoming earnings with EPS, revenue, estimates. + +const std = @import("std"); +const http = @import("../net/http.zig"); +const RateLimiter = @import("../net/rate_limiter.zig").RateLimiter; +const Date = @import("../models/date.zig").Date; +const OptionContract = @import("../models/option.zig").OptionContract; +const OptionsChain = @import("../models/option.zig").OptionsChain; +const ContractType = @import("../models/option.zig").ContractType; +const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; +const ReportTime = @import("../models/earnings.zig").ReportTime; +const provider = @import("provider.zig"); + +const base_url = "https://finnhub.io/api/v1"; + +pub const Finnhub = struct { + api_key: []const u8, + client: http.Client, + rate_limiter: RateLimiter, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator, api_key: []const u8) Finnhub { + return .{ + .api_key = api_key, + .client = http.Client.init(allocator), + .rate_limiter = RateLimiter.perMinute(60), + .allocator = allocator, + }; + } + + pub fn deinit(self: *Finnhub) void { + self.client.deinit(); + } + + /// Fetch the full options chain for a symbol (all expirations). + /// Returns chains grouped by expiration date, sorted nearest-first. + pub fn fetchOptionsChain( + self: *Finnhub, + allocator: std.mem.Allocator, + symbol: []const u8, + ) provider.ProviderError![]OptionsChain { + self.rate_limiter.acquire(); + + const url = http.buildUrl(allocator, base_url ++ "/stock/option-chain", &.{ + .{ "symbol", symbol }, + .{ "token", self.api_key }, + }) catch return provider.ProviderError.OutOfMemory; + defer allocator.free(url); + + var response = self.client.get(url) catch |err| return mapHttpError(err); + defer response.deinit(); + + return parseOptionsResponse(allocator, response.body, symbol); + } + + /// Fetch options for a specific expiration date only. + pub fn fetchOptionsForExpiration( + self: *Finnhub, + allocator: std.mem.Allocator, + symbol: []const u8, + expiration: Date, + ) provider.ProviderError!?OptionsChain { + const chains = try self.fetchOptionsChain(allocator, symbol); + defer { + for (chains) |chain| { + allocator.free(chain.calls); + allocator.free(chain.puts); + } + allocator.free(chains); + } + + for (chains) |chain| { + if (chain.expiration.eql(expiration)) { + // Copy the matching chain so caller owns the memory + const calls = allocator.dupe(OptionContract, chain.calls) catch + return provider.ProviderError.OutOfMemory; + errdefer allocator.free(calls); + const puts = allocator.dupe(OptionContract, chain.puts) catch + return provider.ProviderError.OutOfMemory; + return OptionsChain{ + .underlying_symbol = chain.underlying_symbol, + .underlying_price = chain.underlying_price, + .expiration = chain.expiration, + .calls = calls, + .puts = puts, + }; + } + } + return null; + } + + /// Fetch earnings calendar for a symbol. + /// Returns earnings events sorted newest-first (upcoming first, then historical). + pub fn fetchEarnings( + self: *Finnhub, + allocator: std.mem.Allocator, + symbol: []const u8, + from: ?Date, + to: ?Date, + ) provider.ProviderError![]EarningsEvent { + self.rate_limiter.acquire(); + + var params: [4][2][]const u8 = undefined; + var n: usize = 0; + + params[n] = .{ "symbol", symbol }; + n += 1; + params[n] = .{ "token", self.api_key }; + n += 1; + + var from_buf: [10]u8 = undefined; + var to_buf: [10]u8 = undefined; + + if (from) |f| { + params[n] = .{ "from", f.format(&from_buf) }; + n += 1; + } + if (to) |t| { + params[n] = .{ "to", t.format(&to_buf) }; + n += 1; + } + + const url = http.buildUrl(allocator, base_url ++ "/calendar/earnings", params[0..n]) catch + return provider.ProviderError.OutOfMemory; + defer allocator.free(url); + + var response = self.client.get(url) catch |err| return mapHttpError(err); + defer response.deinit(); + + return parseEarningsResponse(allocator, response.body, symbol); + } + + pub fn asProvider(self: *Finnhub) provider.Provider { + return .{ + .ptr = @ptrCast(self), + .vtable = &vtable, + }; + } + + const vtable = provider.Provider.VTable{ + .fetchOptions = @ptrCast(&fetchOptionsVtable), + .fetchEarnings = @ptrCast(&fetchEarningsVtable), + .name = .finnhub, + }; + + fn fetchOptionsVtable( + ptr: *Finnhub, + allocator: std.mem.Allocator, + symbol: []const u8, + expiration: ?Date, + ) provider.ProviderError![]OptionContract { + if (expiration) |exp| { + const chain = try ptr.fetchOptionsForExpiration(allocator, symbol, exp); + if (chain) |c| { + // Merge calls and puts into a single slice + const total = c.calls.len + c.puts.len; + const merged = allocator.alloc(OptionContract, total) catch + return provider.ProviderError.OutOfMemory; + @memcpy(merged[0..c.calls.len], c.calls); + @memcpy(merged[c.calls.len..], c.puts); + allocator.free(c.calls); + allocator.free(c.puts); + return merged; + } + return allocator.alloc(OptionContract, 0) catch return provider.ProviderError.OutOfMemory; + } + // No expiration given: return contracts from nearest expiration + const chains = try ptr.fetchOptionsChain(allocator, symbol); + defer { + for (chains[1..]) |chain| { + allocator.free(chain.calls); + allocator.free(chain.puts); + } + allocator.free(chains); + } + if (chains.len == 0) return allocator.alloc(OptionContract, 0) catch return provider.ProviderError.OutOfMemory; + const first = chains[0]; + const total = first.calls.len + first.puts.len; + const merged = allocator.alloc(OptionContract, total) catch + return provider.ProviderError.OutOfMemory; + @memcpy(merged[0..first.calls.len], first.calls); + @memcpy(merged[first.calls.len..], first.puts); + allocator.free(first.calls); + allocator.free(first.puts); + return merged; + } + + fn fetchEarningsVtable( + ptr: *Finnhub, + allocator: std.mem.Allocator, + symbol: []const u8, + ) provider.ProviderError![]EarningsEvent { + return ptr.fetchEarnings(allocator, symbol, null, null); + } +}; + +// -- JSON parsing -- + +fn parseOptionsResponse( + allocator: std.mem.Allocator, + body: []const u8, + symbol: []const u8, +) provider.ProviderError![]OptionsChain { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + defer parsed.deinit(); + + const root = parsed.value.object; + + // Check for error response + if (root.get("error")) |_| return provider.ProviderError.RequestFailed; + + const underlying_price: f64 = if (root.get("lastTradePrice")) |v| parseJsonFloat(v) else 0; + + const data_arr = root.get("data") orelse { + const empty = allocator.alloc(OptionsChain, 0) catch return provider.ProviderError.OutOfMemory; + return empty; + }; + const items = switch (data_arr) { + .array => |a| a.items, + else => { + const empty = allocator.alloc(OptionsChain, 0) catch return provider.ProviderError.OutOfMemory; + return empty; + }, + }; + + var chains: std.ArrayList(OptionsChain) = .empty; + errdefer { + for (chains.items) |chain| { + allocator.free(chain.calls); + allocator.free(chain.puts); + } + chains.deinit(allocator); + } + + for (items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + + const exp_str = jsonStr(obj.get("expirationDate")) orelse continue; + const expiration = Date.parse(exp_str) catch continue; + + const options_obj = (obj.get("options") orelse continue); + const options = switch (options_obj) { + .object => |o| o, + else => continue, + }; + + const calls = parseContracts(allocator, options.get("CALL"), .call, expiration) catch + return provider.ProviderError.OutOfMemory; + errdefer allocator.free(calls); + const puts = parseContracts(allocator, options.get("PUT"), .put, expiration) catch + return provider.ProviderError.OutOfMemory; + + chains.append(allocator, .{ + .underlying_symbol = symbol, + .underlying_price = underlying_price, + .expiration = expiration, + .calls = calls, + .puts = puts, + }) catch return provider.ProviderError.OutOfMemory; + } + + return chains.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; +} + +fn parseContracts( + allocator: std.mem.Allocator, + val: ?std.json.Value, + contract_type: ContractType, + expiration: Date, +) ![]OptionContract { + const arr = val orelse { + return try allocator.alloc(OptionContract, 0); + }; + const items = switch (arr) { + .array => |a| a.items, + else => return try allocator.alloc(OptionContract, 0), + }; + + var contracts: std.ArrayList(OptionContract) = .empty; + errdefer contracts.deinit(allocator); + + for (items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + + const strike = parseJsonFloat(obj.get("strike") orelse continue); + if (strike <= 0) continue; + + contracts.append(allocator, .{ + .contract_type = contract_type, + .strike = strike, + .expiration = expiration, + .bid = optFloat(obj.get("bid")), + .ask = optFloat(obj.get("ask")), + .last_price = optFloat(obj.get("lastPrice")), + .volume = optUint(obj.get("volume")), + .open_interest = optUint(obj.get("openInterest")), + .implied_volatility = optFloat(obj.get("impliedVolatility")), + .delta = optFloat(obj.get("delta")), + .gamma = optFloat(obj.get("gamma")), + .theta = optFloat(obj.get("theta")), + .vega = optFloat(obj.get("vega")), + }) catch return error.OutOfMemory; + } + + return contracts.toOwnedSlice(allocator); +} + +fn parseEarningsResponse( + allocator: std.mem.Allocator, + body: []const u8, + symbol: []const u8, +) provider.ProviderError![]EarningsEvent { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + defer parsed.deinit(); + + const root = parsed.value.object; + + if (root.get("error")) |_| return provider.ProviderError.RequestFailed; + + const cal = root.get("earningsCalendar") orelse { + const empty = allocator.alloc(EarningsEvent, 0) catch return provider.ProviderError.OutOfMemory; + return empty; + }; + const items = switch (cal) { + .array => |a| a.items, + else => { + const empty = allocator.alloc(EarningsEvent, 0) catch return provider.ProviderError.OutOfMemory; + return empty; + }, + }; + + var events: std.ArrayList(EarningsEvent) = .empty; + errdefer events.deinit(allocator); + + for (items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + + const date_str = jsonStr(obj.get("date")) orelse continue; + const date = Date.parse(date_str) catch continue; + + const actual = optFloat(obj.get("epsActual")); + const estimate = optFloat(obj.get("epsEstimate")); + const surprise: ?f64 = if (actual != null and estimate != null) + actual.? - estimate.? + else + null; + const surprise_pct: ?f64 = if (surprise != null and estimate != null and estimate.? != 0) + (surprise.? / @abs(estimate.?)) * 100.0 + else + null; + + events.append(allocator, .{ + .symbol = symbol, + .date = date, + .estimate = estimate, + .actual = actual, + .surprise = surprise, + .surprise_percent = surprise_pct, + .quarter = parseQuarter(obj.get("quarter")), + .fiscal_year = parseFiscalYear(obj.get("year")), + .revenue_actual = optFloat(obj.get("revenueActual")), + .revenue_estimate = optFloat(obj.get("revenueEstimate")), + .report_time = parseReportTime(obj.get("hour")), + }) catch return provider.ProviderError.OutOfMemory; + } + + return events.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; +} + +// -- Helpers -- + +fn parseJsonFloat(val: std.json.Value) f64 { + return switch (val) { + .float => |f| f, + .integer => |i| @floatFromInt(i), + .string => |s| std.fmt.parseFloat(f64, s) catch 0, + else => 0, + }; +} + +fn optFloat(val: ?std.json.Value) ?f64 { + const v = val orelse return null; + return switch (v) { + .float => |f| f, + .integer => |i| @floatFromInt(i), + .null => null, + else => null, + }; +} + +fn optUint(val: ?std.json.Value) ?u64 { + const v = val orelse return null; + return switch (v) { + .integer => |i| if (i >= 0) @intCast(i) else null, + .float => |f| if (f >= 0) @intFromFloat(f) else null, + .null => null, + else => null, + }; +} + +fn jsonStr(val: ?std.json.Value) ?[]const u8 { + const v = val orelse return null; + return switch (v) { + .string => |s| s, + else => null, + }; +} + +fn parseQuarter(val: ?std.json.Value) ?u8 { + const v = val orelse return null; + const i = switch (v) { + .integer => |n| n, + .float => |f| @as(i64, @intFromFloat(f)), + else => return null, + }; + return if (i >= 1 and i <= 4) @intCast(i) else null; +} + +fn parseFiscalYear(val: ?std.json.Value) ?i16 { + const v = val orelse return null; + const i = switch (v) { + .integer => |n| n, + .float => |f| @as(i64, @intFromFloat(f)), + else => return null, + }; + return if (i > 1900 and i < 2200) @intCast(i) else null; +} + +fn parseReportTime(val: ?std.json.Value) ReportTime { + const s = jsonStr(val) orelse return .unknown; + if (std.mem.eql(u8, s, "bmo")) return .bmo; + if (std.mem.eql(u8, s, "amc")) return .amc; + if (std.mem.eql(u8, s, "dmh")) return .dmh; + return .unknown; +} + +fn mapHttpError(err: http.HttpError) provider.ProviderError { + return switch (err) { + error.RateLimited => provider.ProviderError.RateLimited, + error.Unauthorized => provider.ProviderError.Unauthorized, + error.NotFound => provider.ProviderError.NotFound, + else => provider.ProviderError.RequestFailed, + }; +} diff --git a/src/providers/polygon.zig b/src/providers/polygon.zig new file mode 100644 index 0000000..b83c571 --- /dev/null +++ b/src/providers/polygon.zig @@ -0,0 +1,439 @@ +//! Polygon.io API provider -- primary source for dividend/split reference data +//! and secondary source for daily OHLCV bars. +//! API docs: https://polygon.io/docs +//! +//! Free tier: 5 requests/min, unlimited daily, 2yr historical bars. +//! Dividends and splits are available for all history. + +const std = @import("std"); +const http = @import("../net/http.zig"); +const RateLimiter = @import("../net/rate_limiter.zig").RateLimiter; +const Date = @import("../models/date.zig").Date; +const Candle = @import("../models/candle.zig").Candle; +const Dividend = @import("../models/dividend.zig").Dividend; +const DividendType = @import("../models/dividend.zig").DividendType; +const Split = @import("../models/split.zig").Split; +const provider = @import("provider.zig"); + +const base_url = "https://api.polygon.io"; + +pub const Polygon = struct { + api_key: []const u8, + client: http.Client, + rate_limiter: RateLimiter, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator, api_key: []const u8) Polygon { + return .{ + .api_key = api_key, + .client = http.Client.init(allocator), + .rate_limiter = RateLimiter.perMinute(5), + .allocator = allocator, + }; + } + + pub fn deinit(self: *Polygon) void { + self.client.deinit(); + } + + /// Fetch dividend history for a ticker. Results sorted oldest-first by ex_date. + /// Polygon endpoint: GET /v3/reference/dividends?ticker=X&ex_dividend_date.gte=YYYY-MM-DD&... + pub fn fetchDividends( + self: *Polygon, + allocator: std.mem.Allocator, + symbol: []const u8, + from: ?Date, + to: ?Date, + ) provider.ProviderError![]Dividend { + var all_dividends: std.ArrayList(Dividend) = .empty; + errdefer all_dividends.deinit(allocator); + + var next_url: ?[]const u8 = null; + defer if (next_url) |u| allocator.free(u); + + // First request + { + self.rate_limiter.acquire(); + + var params: [5][2][]const u8 = undefined; + var n: usize = 0; + + params[n] = .{ "ticker", symbol }; + n += 1; + params[n] = .{ "limit", "1000" }; + n += 1; + params[n] = .{ "sort", "ex_dividend_date" }; + n += 1; + + var from_buf: [10]u8 = undefined; + var to_buf: [10]u8 = undefined; + + if (from) |f| { + params[n] = .{ "ex_dividend_date.gte", f.format(&from_buf) }; + n += 1; + } + if (to) |t| { + params[n] = .{ "ex_dividend_date.lte", t.format(&to_buf) }; + n += 1; + } + + const url = http.buildUrl(allocator, base_url ++ "/v3/reference/dividends", params[0..n]) catch + return provider.ProviderError.OutOfMemory; + defer allocator.free(url); + + const authed = appendApiKey(allocator, url, self.api_key) catch + return provider.ProviderError.OutOfMemory; + defer allocator.free(authed); + + var response = self.client.get(authed) catch |err| return mapHttpError(err); + defer response.deinit(); + + next_url = try parseDividendsPage(allocator, response.body, &all_dividends); + } + + // Paginate + while (next_url) |cursor_url| { + self.rate_limiter.acquire(); + + const authed = appendApiKey(allocator, cursor_url, self.api_key) catch + return provider.ProviderError.OutOfMemory; + defer allocator.free(authed); + + var response = self.client.get(authed) catch |err| return mapHttpError(err); + defer response.deinit(); + + allocator.free(cursor_url); + next_url = try parseDividendsPage(allocator, response.body, &all_dividends); + } + + return all_dividends.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; + } + + /// Fetch split history for a ticker. Results sorted oldest-first. + /// Polygon endpoint: GET /v3/reference/splits?ticker=X&... + pub fn fetchSplits( + self: *Polygon, + allocator: std.mem.Allocator, + symbol: []const u8, + ) provider.ProviderError![]Split { + self.rate_limiter.acquire(); + + const url = http.buildUrl(allocator, base_url ++ "/v3/reference/splits", &.{ + .{ "ticker", symbol }, + .{ "limit", "1000" }, + .{ "sort", "execution_date" }, + }) catch return provider.ProviderError.OutOfMemory; + defer allocator.free(url); + + const authed = appendApiKey(allocator, url, self.api_key) catch + return provider.ProviderError.OutOfMemory; + defer allocator.free(authed); + + var response = self.client.get(authed) catch |err| return mapHttpError(err); + defer response.deinit(); + + return parseSplitsResponse(allocator, response.body); + } + + /// Fetch daily OHLCV bars. Polygon free tier: 2 years max history. + /// Polygon endpoint: GET /v2/aggs/ticker/{ticker}/range/1/day/{from}/{to} + pub fn fetchCandles( + self: *Polygon, + allocator: std.mem.Allocator, + symbol: []const u8, + from: Date, + to: Date, + ) provider.ProviderError![]Candle { + self.rate_limiter.acquire(); + + var from_buf: [10]u8 = undefined; + var to_buf: [10]u8 = undefined; + const from_str = from.format(&from_buf); + const to_str = to.format(&to_buf); + + // Build URL manually since the path contains the date range + const path = std.fmt.allocPrint( + allocator, + "{s}/v2/aggs/ticker/{s}/range/1/day/{s}/{s}?adjusted=true&sort=asc&limit=5000", + .{ base_url, symbol, from_str, to_str }, + ) catch return provider.ProviderError.OutOfMemory; + defer allocator.free(path); + + const authed = appendApiKey(allocator, path, self.api_key) catch + return provider.ProviderError.OutOfMemory; + defer allocator.free(authed); + + var response = self.client.get(authed) catch |err| return mapHttpError(err); + defer response.deinit(); + + return parseCandlesResponse(allocator, response.body); + } + + pub fn asProvider(self: *Polygon) provider.Provider { + return .{ + .ptr = @ptrCast(self), + .vtable = &vtable, + }; + } + + const vtable = provider.Provider.VTable{ + .fetchDividends = @ptrCast(&fetchDividendsVtable), + .fetchSplits = @ptrCast(&fetchSplitsVtable), + .fetchCandles = @ptrCast(&fetchCandlesVtable), + .name = .polygon, + }; + + fn fetchDividendsVtable(ptr: *Polygon, allocator: std.mem.Allocator, symbol: []const u8, from: ?Date, to: ?Date) provider.ProviderError![]Dividend { + return ptr.fetchDividends(allocator, symbol, from, to); + } + fn fetchSplitsVtable(ptr: *Polygon, allocator: std.mem.Allocator, symbol: []const u8) provider.ProviderError![]Split { + return ptr.fetchSplits(allocator, symbol); + } + fn fetchCandlesVtable(ptr: *Polygon, allocator: std.mem.Allocator, symbol: []const u8, from: Date, to: Date) provider.ProviderError![]Candle { + return ptr.fetchCandles(allocator, symbol, from, to); + } +}; + +// -- JSON parsing -- + +fn parseDividendsPage( + allocator: std.mem.Allocator, + body: []const u8, + out: *std.ArrayList(Dividend), +) provider.ProviderError!?[]const u8 { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + defer parsed.deinit(); + + const root = parsed.value.object; + + // Check status + if (root.get("status")) |s| { + if (s == .string and std.mem.eql(u8, s.string, "ERROR")) + return provider.ProviderError.RequestFailed; + } + + const results = root.get("results") orelse return null; + const items = switch (results) { + .array => |a| a.items, + else => return null, + }; + + for (items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + + const ex_date = blk: { + const v = obj.get("ex_dividend_date") orelse continue; + const s = switch (v) { + .string => |str| str, + else => continue, + }; + break :blk Date.parse(s) catch continue; + }; + + const amount = parseJsonFloat(obj.get("cash_amount")); + if (amount <= 0) continue; + + out.append(allocator, .{ + .ex_date = ex_date, + .amount = amount, + .pay_date = parseDateField(obj, "pay_date"), + .record_date = parseDateField(obj, "record_date"), + .frequency = parseFrequency(obj), + .distribution_type = parseDividendType(obj), + .currency = jsonStr(obj.get("currency")), + }) catch return provider.ProviderError.OutOfMemory; + } + + // Check for next_url (pagination cursor) + if (root.get("next_url")) |nu| { + const url_str = switch (nu) { + .string => |s| s, + else => return null, + }; + const duped = allocator.dupe(u8, url_str) catch return provider.ProviderError.OutOfMemory; + return duped; + } + + return null; +} + +fn parseSplitsResponse(allocator: std.mem.Allocator, body: []const u8) provider.ProviderError![]Split { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + defer parsed.deinit(); + + const root = parsed.value.object; + + if (root.get("status")) |s| { + if (s == .string and std.mem.eql(u8, s.string, "ERROR")) + return provider.ProviderError.RequestFailed; + } + + const results = root.get("results") orelse { + const empty = allocator.alloc(Split, 0) catch return provider.ProviderError.OutOfMemory; + return empty; + }; + const items = switch (results) { + .array => |a| a.items, + else => { + const empty = allocator.alloc(Split, 0) catch return provider.ProviderError.OutOfMemory; + return empty; + }, + }; + + var splits: std.ArrayList(Split) = .empty; + errdefer splits.deinit(allocator); + + for (items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + + const date = blk: { + const v = obj.get("execution_date") orelse continue; + const s = switch (v) { + .string => |str| str, + else => continue, + }; + break :blk Date.parse(s) catch continue; + }; + + splits.append(allocator, .{ + .date = date, + .numerator = parseJsonFloat(obj.get("split_to")), + .denominator = parseJsonFloat(obj.get("split_from")), + }) catch return provider.ProviderError.OutOfMemory; + } + + return splits.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; +} + +fn parseCandlesResponse(allocator: std.mem.Allocator, body: []const u8) provider.ProviderError![]Candle { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + defer parsed.deinit(); + + const root = parsed.value.object; + + if (root.get("status")) |s| { + if (s == .string and std.mem.eql(u8, s.string, "ERROR")) + return provider.ProviderError.RequestFailed; + } + + const results = root.get("results") orelse { + const empty = allocator.alloc(Candle, 0) catch return provider.ProviderError.OutOfMemory; + return empty; + }; + const items = switch (results) { + .array => |a| a.items, + else => { + const empty = allocator.alloc(Candle, 0) catch return provider.ProviderError.OutOfMemory; + return empty; + }, + }; + + var candles: std.ArrayList(Candle) = .empty; + errdefer candles.deinit(allocator); + + for (items) |item| { + const obj = switch (item) { + .object => |o| o, + else => continue, + }; + + // Polygon returns timestamp in milliseconds + const ts_ms = blk: { + const v = obj.get("t") orelse continue; + break :blk switch (v) { + .integer => |i| i, + .float => |f| @as(i64, @intFromFloat(f)), + else => continue, + }; + }; + const days: i32 = @intCast(@divFloor(ts_ms, 86400 * 1000)); + const date = Date{ .days = days }; + + const close = parseJsonFloat(obj.get("c")); + + candles.append(allocator, .{ + .date = date, + .open = parseJsonFloat(obj.get("o")), + .high = parseJsonFloat(obj.get("h")), + .low = parseJsonFloat(obj.get("l")), + .close = close, + .adj_close = close, // Polygon adjusted=true gives adjusted values + .volume = @intFromFloat(parseJsonFloat(obj.get("v"))), + }) catch return provider.ProviderError.OutOfMemory; + } + + return candles.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; +} + +// -- Helpers -- + +fn appendApiKey(allocator: std.mem.Allocator, url: []const u8, api_key: []const u8) ![]const u8 { + const sep: u8 = if (std.mem.indexOfScalar(u8, url, '?') != null) '&' else '?'; + return std.fmt.allocPrint(allocator, "{s}{c}apiKey={s}", .{ url, sep, api_key }); +} + +fn parseJsonFloat(val: ?std.json.Value) f64 { + const v = val orelse return 0; + return switch (v) { + .float => |f| f, + .integer => |i| @floatFromInt(i), + .string => |s| std.fmt.parseFloat(f64, s) catch 0, + else => 0, + }; +} + +fn jsonStr(val: ?std.json.Value) ?[]const u8 { + const v = val orelse return null; + return switch (v) { + .string => |s| s, + else => null, + }; +} + +fn parseDateField(obj: std.json.ObjectMap, key: []const u8) ?Date { + const v = obj.get(key) orelse return null; + const s = switch (v) { + .string => |str| str, + else => return null, + }; + return Date.parse(s) catch null; +} + +fn parseFrequency(obj: std.json.ObjectMap) ?u8 { + const v = obj.get("frequency") orelse return null; + return switch (v) { + .integer => |i| if (i > 0 and i <= 255) @intCast(i) else null, + .float => |f| if (f > 0 and f <= 255) @intFromFloat(f) else null, + else => null, + }; +} + +fn parseDividendType(obj: std.json.ObjectMap) DividendType { + const v = obj.get("dividend_type") orelse return .unknown; + const s = switch (v) { + .string => |str| str, + else => return .unknown, + }; + if (std.mem.eql(u8, s, "CD")) return .regular; + if (std.mem.eql(u8, s, "SC")) return .special; + if (std.mem.eql(u8, s, "LT") or std.mem.eql(u8, s, "ST")) return .regular; + return .unknown; +} + +fn mapHttpError(err: http.HttpError) provider.ProviderError { + return switch (err) { + error.RateLimited => provider.ProviderError.RateLimited, + error.Unauthorized => provider.ProviderError.Unauthorized, + error.NotFound => provider.ProviderError.NotFound, + else => provider.ProviderError.RequestFailed, + }; +} diff --git a/src/providers/provider.zig b/src/providers/provider.zig new file mode 100644 index 0000000..0e595a8 --- /dev/null +++ b/src/providers/provider.zig @@ -0,0 +1,147 @@ +const std = @import("std"); +const Date = @import("../models/date.zig").Date; +const Candle = @import("../models/candle.zig").Candle; +const Dividend = @import("../models/dividend.zig").Dividend; +const Split = @import("../models/split.zig").Split; +const OptionContract = @import("../models/option.zig").OptionContract; +const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; +const EtfProfile = @import("../models/etf_profile.zig").EtfProfile; + +pub const ProviderError = error{ + ApiKeyMissing, + RequestFailed, + RateLimited, + ParseError, + NotSupported, + OutOfMemory, + Unauthorized, + NotFound, + ServerError, + InvalidResponse, + ConnectionRefused, +}; + +/// Identifies which upstream data source a result came from. +pub const ProviderName = enum { + twelvedata, + polygon, + finnhub, + cboe, + alphavantage, +}; + +/// Common interface for all data providers. +/// Each provider implements the capabilities it supports and returns +/// `error.NotSupported` for those it doesn't. +pub const Provider = struct { + ptr: *anyopaque, + vtable: *const VTable, + + pub const VTable = struct { + fetchCandles: ?*const fn ( + ptr: *anyopaque, + allocator: std.mem.Allocator, + symbol: []const u8, + from: Date, + to: Date, + ) ProviderError![]Candle = null, + + fetchDividends: ?*const fn ( + ptr: *anyopaque, + allocator: std.mem.Allocator, + symbol: []const u8, + from: ?Date, + to: ?Date, + ) ProviderError![]Dividend = null, + + fetchSplits: ?*const fn ( + ptr: *anyopaque, + allocator: std.mem.Allocator, + symbol: []const u8, + ) ProviderError![]Split = null, + + fetchOptions: ?*const fn ( + ptr: *anyopaque, + allocator: std.mem.Allocator, + symbol: []const u8, + expiration: ?Date, + ) ProviderError![]OptionContract = null, + + fetchEarnings: ?*const fn ( + ptr: *anyopaque, + allocator: std.mem.Allocator, + symbol: []const u8, + ) ProviderError![]EarningsEvent = null, + + fetchEtfProfile: ?*const fn ( + ptr: *anyopaque, + allocator: std.mem.Allocator, + symbol: []const u8, + ) ProviderError!EtfProfile = null, + + name: ProviderName, + }; + + pub fn fetchCandles( + self: Provider, + allocator: std.mem.Allocator, + symbol: []const u8, + from: Date, + to: Date, + ) ProviderError![]Candle { + const func = self.vtable.fetchCandles orelse return ProviderError.NotSupported; + return func(self.ptr, allocator, symbol, from, to); + } + + pub fn fetchDividends( + self: Provider, + allocator: std.mem.Allocator, + symbol: []const u8, + from: ?Date, + to: ?Date, + ) ProviderError![]Dividend { + const func = self.vtable.fetchDividends orelse return ProviderError.NotSupported; + return func(self.ptr, allocator, symbol, from, to); + } + + pub fn fetchSplits( + self: Provider, + allocator: std.mem.Allocator, + symbol: []const u8, + ) ProviderError![]Split { + const func = self.vtable.fetchSplits orelse return ProviderError.NotSupported; + return func(self.ptr, allocator, symbol); + } + + pub fn fetchOptions( + self: Provider, + allocator: std.mem.Allocator, + symbol: []const u8, + expiration: ?Date, + ) ProviderError![]OptionContract { + const func = self.vtable.fetchOptions orelse return ProviderError.NotSupported; + return func(self.ptr, allocator, symbol, expiration); + } + + pub fn fetchEarnings( + self: Provider, + allocator: std.mem.Allocator, + symbol: []const u8, + ) ProviderError![]EarningsEvent { + const func = self.vtable.fetchEarnings orelse return ProviderError.NotSupported; + return func(self.ptr, allocator, symbol); + } + + pub fn fetchEtfProfile( + self: Provider, + allocator: std.mem.Allocator, + symbol: []const u8, + ) ProviderError!EtfProfile { + const func = self.vtable.fetchEtfProfile orelse return ProviderError.NotSupported; + return func(self.ptr, allocator, symbol); + } + + pub fn providerName(self: Provider) ProviderName { + return self.vtable.name; + } +}; diff --git a/src/providers/twelvedata.zig b/src/providers/twelvedata.zig new file mode 100644 index 0000000..a3a34bb --- /dev/null +++ b/src/providers/twelvedata.zig @@ -0,0 +1,285 @@ +//! Twelve Data API provider -- primary source for historical price data. +//! API docs: https://twelvedata.com/docs +//! +//! Free tier: 800 requests/day, 8 credits/min, all US market data. +//! +//! Note: Twelve Data returns split-adjusted prices but NOT dividend-adjusted. +//! The `adj_close` field is set equal to `close` here. For true total-return +//! calculations, use Polygon dividend data with the manual reinvestment method. + +const std = @import("std"); +const http = @import("../net/http.zig"); +const RateLimiter = @import("../net/rate_limiter.zig").RateLimiter; +const Date = @import("../models/date.zig").Date; +const Candle = @import("../models/candle.zig").Candle; +const provider = @import("provider.zig"); + +const base_url = "https://api.twelvedata.com"; + +pub const TwelveData = struct { + api_key: []const u8, + client: http.Client, + rate_limiter: RateLimiter, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator, api_key: []const u8) TwelveData { + return .{ + .api_key = api_key, + .client = http.Client.init(allocator), + .rate_limiter = RateLimiter.perMinute(8), + .allocator = allocator, + }; + } + + pub fn deinit(self: *TwelveData) void { + self.client.deinit(); + } + + /// Fetch daily candles for a symbol between two dates. + /// Returns candles sorted oldest-first. + pub fn fetchCandles( + self: *TwelveData, + allocator: std.mem.Allocator, + symbol: []const u8, + from: Date, + to: Date, + ) provider.ProviderError![]Candle { + self.rate_limiter.acquire(); + + var from_buf: [10]u8 = undefined; + var to_buf: [10]u8 = undefined; + const from_str = from.format(&from_buf); + const to_str = to.format(&to_buf); + + const url = http.buildUrl(allocator, base_url ++ "/time_series", &.{ + .{ "symbol", symbol }, + .{ "interval", "1day" }, + .{ "start_date", from_str }, + .{ "end_date", to_str }, + .{ "outputsize", "5000" }, + .{ "apikey", self.api_key }, + }) catch return provider.ProviderError.OutOfMemory; + defer allocator.free(url); + + var response = self.client.get(url) catch |err| return switch (err) { + error.RateLimited => provider.ProviderError.RateLimited, + error.Unauthorized => provider.ProviderError.Unauthorized, + error.NotFound => provider.ProviderError.NotFound, + else => provider.ProviderError.RequestFailed, + }; + defer response.deinit(); + + return parseTimeSeriesResponse(allocator, response.body); + } + + pub const QuoteResponse = struct { + body: []const u8, + allocator: std.mem.Allocator, + + pub fn deinit(self: *QuoteResponse) void { + self.allocator.free(self.body); + } + + /// Parse and print quote data. Caller should use this within the + /// lifetime of the QuoteResponse. + pub fn parse(self: QuoteResponse, allocator: std.mem.Allocator) provider.ProviderError!ParsedQuote { + return parseQuoteBody(allocator, self.body); + } + }; + + pub const ParsedQuote = struct { + parsed: std.json.Parsed(std.json.Value), + + pub fn deinit(self: *ParsedQuote) void { + self.parsed.deinit(); + } + + fn root(self: ParsedQuote) std.json.ObjectMap { + return self.parsed.value.object; + } + + pub fn symbol(self: ParsedQuote) []const u8 { return jsonStr(self.root().get("symbol")); } + pub fn name(self: ParsedQuote) []const u8 { return jsonStr(self.root().get("name")); } + pub fn exchange(self: ParsedQuote) []const u8 { return jsonStr(self.root().get("exchange")); } + pub fn datetime(self: ParsedQuote) []const u8 { return jsonStr(self.root().get("datetime")); } + pub fn close(self: ParsedQuote) f64 { return parseJsonFloat(self.root().get("close")); } + pub fn open(self: ParsedQuote) f64 { return parseJsonFloat(self.root().get("open")); } + pub fn high(self: ParsedQuote) f64 { return parseJsonFloat(self.root().get("high")); } + pub fn low(self: ParsedQuote) f64 { return parseJsonFloat(self.root().get("low")); } + pub fn volume(self: ParsedQuote) u64 { return @intFromFloat(parseJsonFloat(self.root().get("volume"))); } + pub fn previous_close(self: ParsedQuote) f64 { return parseJsonFloat(self.root().get("previous_close")); } + pub fn change(self: ParsedQuote) f64 { return parseJsonFloat(self.root().get("change")); } + pub fn percent_change(self: ParsedQuote) f64 { return parseJsonFloat(self.root().get("percent_change")); } + pub fn average_volume(self: ParsedQuote) u64 { return @intFromFloat(parseJsonFloat(self.root().get("average_volume"))); } + + pub fn fifty_two_week_low(self: ParsedQuote) f64 { + const ftw = self.root().get("fifty_two_week") orelse return 0; + return switch (ftw) { + .object => |o| parseJsonFloat(o.get("low")), + else => 0, + }; + } + pub fn fifty_two_week_high(self: ParsedQuote) f64 { + const ftw = self.root().get("fifty_two_week") orelse return 0; + return switch (ftw) { + .object => |o| parseJsonFloat(o.get("high")), + else => 0, + }; + } + }; + + pub fn fetchQuote( + self: *TwelveData, + allocator: std.mem.Allocator, + symbol: []const u8, + ) provider.ProviderError!QuoteResponse { + self.rate_limiter.acquire(); + + const url = http.buildUrl(allocator, base_url ++ "/quote", &.{ + .{ "symbol", symbol }, + .{ "apikey", self.api_key }, + }) catch return provider.ProviderError.OutOfMemory; + defer allocator.free(url); + + var response = self.client.get(url) catch |err| return switch (err) { + error.RateLimited => provider.ProviderError.RateLimited, + error.Unauthorized => provider.ProviderError.Unauthorized, + error.NotFound => provider.ProviderError.NotFound, + else => provider.ProviderError.RequestFailed, + }; + + // Transfer ownership of body to QuoteResponse + const body = response.body; + response.body = &.{}; + + return .{ + .body = body, + .allocator = allocator, + }; + } + + pub fn asProvider(self: *TwelveData) provider.Provider { + return .{ + .ptr = @ptrCast(self), + .vtable = &vtable, + }; + } + + const vtable = provider.Provider.VTable{ + .fetchCandles = @ptrCast(&fetchCandlesVtable), + .name = .twelvedata, + }; + + fn fetchCandlesVtable( + ptr: *TwelveData, + allocator: std.mem.Allocator, + symbol: []const u8, + from: Date, + to: Date, + ) provider.ProviderError![]Candle { + return ptr.fetchCandles(allocator, symbol, from, to); + } +}; + +// -- JSON parsing -- + +fn parseTimeSeriesResponse(allocator: std.mem.Allocator, body: []const u8) provider.ProviderError![]Candle { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + defer parsed.deinit(); + + const root = parsed.value; + + // Check for API error + if (root.object.get("status")) |status| { + if (status == .string) { + if (std.mem.eql(u8, status.string, "error")) { + // Check error code + if (root.object.get("code")) |code| { + if (code == .integer and code.integer == 429) return provider.ProviderError.RateLimited; + if (code == .integer and code.integer == 401) return provider.ProviderError.Unauthorized; + } + return provider.ProviderError.RequestFailed; + } + } + } + + const values_json = root.object.get("values") orelse return provider.ProviderError.ParseError; + const values = switch (values_json) { + .array => |a| a.items, + else => return provider.ProviderError.ParseError, + }; + + // Twelve Data returns newest first. We'll parse into a list and reverse. + var candles: std.ArrayList(Candle) = .empty; + errdefer candles.deinit(allocator); + + for (values) |val| { + const obj = switch (val) { + .object => |o| o, + else => continue, + }; + + const date = blk: { + const dt = obj.get("datetime") orelse continue; + const dt_str = switch (dt) { + .string => |s| s, + else => continue, + }; + // datetime can be "YYYY-MM-DD" or "YYYY-MM-DD HH:MM:SS" + const date_part = if (dt_str.len >= 10) dt_str[0..10] else continue; + break :blk Date.parse(date_part) catch continue; + }; + + candles.append(allocator, .{ + .date = date, + .open = parseJsonFloat(obj.get("open")), + .high = parseJsonFloat(obj.get("high")), + .low = parseJsonFloat(obj.get("low")), + .close = parseJsonFloat(obj.get("close")), + // Twelve Data close is split-adjusted only, not dividend-adjusted + .adj_close = parseJsonFloat(obj.get("close")), + .volume = @intFromFloat(parseJsonFloat(obj.get("volume"))), + }) catch return provider.ProviderError.OutOfMemory; + } + + // Reverse to get oldest-first ordering + const slice = candles.toOwnedSlice(allocator) catch return provider.ProviderError.OutOfMemory; + std.mem.reverse(Candle, slice); + return slice; +} + +fn parseQuoteBody(allocator: std.mem.Allocator, body: []const u8) provider.ProviderError!TwelveData.ParsedQuote { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch + return provider.ProviderError.ParseError; + + // Check for error + if (parsed.value.object.get("status")) |status| { + if (status == .string and std.mem.eql(u8, status.string, "error")) { + var p = parsed; + p.deinit(); + return provider.ProviderError.RequestFailed; + } + } + + return .{ .parsed = parsed }; +} + +/// Parse a JSON value that may be a string containing a number, or a number directly. +fn parseJsonFloat(val: ?std.json.Value) f64 { + const v = val orelse return 0; + return switch (v) { + .string => |s| std.fmt.parseFloat(f64, s) catch 0, + .float => |f| f, + .integer => |i| @floatFromInt(i), + else => 0, + }; +} + +fn jsonStr(val: ?std.json.Value) []const u8 { + const v = val orelse return ""; + return switch (v) { + .string => |s| s, + else => "", + }; +} diff --git a/src/root.zig b/src/root.zig new file mode 100644 index 0000000..5118447 --- /dev/null +++ b/src/root.zig @@ -0,0 +1,60 @@ +//! zfin -- Zig Financial Data Library +//! +//! Fetches, caches, and analyzes US equity/ETF financial data from +//! multiple free-tier API providers (Twelve Data, Polygon, Finnhub, +//! Alpha Vantage). Includes Morningstar-style performance calculations. + +// -- Data models -- +pub const Date = @import("models/date.zig").Date; +pub const Candle = @import("models/candle.zig").Candle; +pub const Dividend = @import("models/dividend.zig").Dividend; +pub const DividendType = @import("models/dividend.zig").DividendType; +pub const Split = @import("models/split.zig").Split; +pub const OptionContract = @import("models/option.zig").OptionContract; +pub const OptionsChain = @import("models/option.zig").OptionsChain; +pub const ContractType = @import("models/option.zig").ContractType; +pub const EarningsEvent = @import("models/earnings.zig").EarningsEvent; +pub const ReportTime = @import("models/earnings.zig").ReportTime; +pub const EtfProfile = @import("models/etf_profile.zig").EtfProfile; +pub const Holding = @import("models/etf_profile.zig").Holding; +pub const SectorWeight = @import("models/etf_profile.zig").SectorWeight; +pub const TickerInfo = @import("models/ticker_info.zig").TickerInfo; +pub const SecurityType = @import("models/ticker_info.zig").SecurityType; +pub const Lot = @import("models/portfolio.zig").Lot; +pub const Position = @import("models/portfolio.zig").Position; +pub const Portfolio = @import("models/portfolio.zig").Portfolio; +pub const Quote = @import("models/quote.zig").Quote; + +// -- Infrastructure -- +pub const Config = @import("config.zig").Config; +pub const RateLimiter = @import("net/rate_limiter.zig").RateLimiter; +pub const http = @import("net/http.zig"); + +// -- Cache -- +pub const cache = @import("cache/store.zig"); + +// -- Analytics -- +pub const performance = @import("analytics/performance.zig"); +pub const risk = @import("analytics/risk.zig"); + +// -- Service layer -- +pub const DataService = @import("service.zig").DataService; +pub const DataError = @import("service.zig").DataError; +pub const DataSource = @import("service.zig").Source; + +// -- Providers -- +pub const Provider = @import("providers/provider.zig").Provider; +pub const TwelveData = @import("providers/twelvedata.zig").TwelveData; +pub const Polygon = @import("providers/polygon.zig").Polygon; +pub const Finnhub = @import("providers/finnhub.zig").Finnhub; +pub const Cboe = @import("providers/cboe.zig").Cboe; +pub const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage; + +// -- Re-export SRF for portfolio file loading -- +pub const srf = @import("srf"); + +// -- Tests -- +test { + const std = @import("std"); + std.testing.refAllDecls(@This()); +} diff --git a/src/service.zig b/src/service.zig new file mode 100644 index 0000000..9b2177c --- /dev/null +++ b/src/service.zig @@ -0,0 +1,429 @@ +//! DataService -- unified data access layer for zfin. +//! +//! Encapsulates the "check cache -> fresh? return -> else fetch from provider -> cache -> return" +//! pattern that was previously duplicated between CLI and TUI. Both frontends should use this +//! as their sole data source. +//! +//! Provider selection is internal: each data type routes to the appropriate provider +//! based on available API keys. Callers never need to know which provider was used. + +const std = @import("std"); +const Date = @import("models/date.zig").Date; +const Candle = @import("models/candle.zig").Candle; +const Dividend = @import("models/dividend.zig").Dividend; +const Split = @import("models/split.zig").Split; +const OptionsChain = @import("models/option.zig").OptionsChain; +const EarningsEvent = @import("models/earnings.zig").EarningsEvent; +const Quote = @import("models/quote.zig").Quote; +const EtfProfile = @import("models/etf_profile.zig").EtfProfile; +const Config = @import("config.zig").Config; +const cache = @import("cache/store.zig"); +const TwelveData = @import("providers/twelvedata.zig").TwelveData; +const Polygon = @import("providers/polygon.zig").Polygon; +const Finnhub = @import("providers/finnhub.zig").Finnhub; +const Cboe = @import("providers/cboe.zig").Cboe; +const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage; +const performance = @import("analytics/performance.zig"); + +pub const DataError = error{ + NoApiKey, + FetchFailed, + CacheError, + ParseError, + OutOfMemory, +}; + +/// Indicates whether the returned data came from cache or was freshly fetched. +pub const Source = enum { + cached, + fetched, +}; + +pub const DataService = struct { + allocator: std.mem.Allocator, + config: Config, + + // Lazily initialized providers (null until first use) + td: ?TwelveData = null, + pg: ?Polygon = null, + fh: ?Finnhub = null, + cboe: ?Cboe = null, + av: ?AlphaVantage = null, + + pub fn init(allocator: std.mem.Allocator, config: Config) DataService { + return .{ + .allocator = allocator, + .config = config, + }; + } + + pub fn deinit(self: *DataService) void { + if (self.td) |*td| td.deinit(); + if (self.pg) |*pg| pg.deinit(); + if (self.fh) |*fh| fh.deinit(); + if (self.cboe) |*c| c.deinit(); + if (self.av) |*av| av.deinit(); + } + + // ── Provider accessors ─────────────────────────────────────── + + fn getTwelveData(self: *DataService) DataError!*TwelveData { + if (self.td) |*td| return td; + const key = self.config.twelvedata_key orelse return DataError.NoApiKey; + self.td = TwelveData.init(self.allocator, key); + return &self.td.?; + } + + fn getPolygon(self: *DataService) DataError!*Polygon { + if (self.pg) |*pg| return pg; + const key = self.config.polygon_key orelse return DataError.NoApiKey; + self.pg = Polygon.init(self.allocator, key); + return &self.pg.?; + } + + fn getFinnhub(self: *DataService) DataError!*Finnhub { + if (self.fh) |*fh| return fh; + const key = self.config.finnhub_key orelse return DataError.NoApiKey; + self.fh = Finnhub.init(self.allocator, key); + return &self.fh.?; + } + + fn getCboe(self: *DataService) *Cboe { + if (self.cboe) |*c| return c; + self.cboe = Cboe.init(self.allocator); + return &self.cboe.?; + } + + fn getAlphaVantage(self: *DataService) DataError!*AlphaVantage { + if (self.av) |*av| return av; + const key = self.config.alphavantage_key orelse return DataError.NoApiKey; + self.av = AlphaVantage.init(self.allocator, key); + return &self.av.?; + } + + // ── Cache helper ───────────────────────────────────────────── + + fn store(self: *DataService) cache.Store { + return cache.Store.init(self.allocator, self.config.cache_dir); + } + + /// Invalidate cached data for a symbol so the next get* call forces a fresh fetch. + pub fn invalidate(self: *DataService, symbol: []const u8, data_type: cache.DataType) void { + var s = self.store(); + s.clearData(symbol, data_type); + } + + // ── Public data methods ────────────────────────────────────── + + /// Fetch daily candles for a symbol (10+ years for trailing returns). + /// Checks cache first; fetches from TwelveData if stale/missing. + pub fn getCandles(self: *DataService, symbol: []const u8) DataError!struct { data: []Candle, source: Source, timestamp: i64 } { + var s = self.store(); + + // Try cache + const cached_raw = s.readRaw(symbol, .candles_daily) catch return DataError.CacheError; + if (cached_raw) |data| { + defer self.allocator.free(data); + const fresh = s.isFresh(symbol, .candles_daily, cache.Ttl.candles_latest) catch false; + if (fresh) { + const candles = cache.Store.deserializeCandles(self.allocator, data) catch null; + if (candles) |c| return .{ .data = c, .source = .cached, .timestamp = s.getMtime(symbol, .candles_daily) orelse std.time.timestamp() }; + } + } + + // Fetch from provider + var td = try self.getTwelveData(); + const today = todayDate(); + const from = today.addDays(-365 * 10 - 60); + + const fetched = td.fetchCandles(self.allocator, symbol, from, today) catch + return DataError.FetchFailed; + + // Cache the result + if (fetched.len > 0) { + if (cache.Store.serializeCandles(self.allocator, fetched)) |srf_data| { + defer self.allocator.free(srf_data); + s.writeRaw(symbol, .candles_daily, srf_data) catch {}; + } else |_| {} + } + + return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; + } + + /// Fetch dividend history for a symbol. + /// Checks cache first; fetches from Polygon if stale/missing. + pub fn getDividends(self: *DataService, symbol: []const u8) DataError!struct { data: []Dividend, source: Source, timestamp: i64 } { + var s = self.store(); + + const cached_raw = s.readRaw(symbol, .dividends) catch return DataError.CacheError; + if (cached_raw) |data| { + defer self.allocator.free(data); + const fresh = s.isFresh(symbol, .dividends, cache.Ttl.dividends) catch false; + if (fresh) { + const divs = cache.Store.deserializeDividends(self.allocator, data) catch null; + if (divs) |d| return .{ .data = d, .source = .cached, .timestamp = s.getMtime(symbol, .dividends) orelse std.time.timestamp() }; + } + } + + var pg = try self.getPolygon(); + const fetched = pg.fetchDividends(self.allocator, symbol, null, null) catch + return DataError.FetchFailed; + + if (fetched.len > 0) { + if (cache.Store.serializeDividends(self.allocator, fetched)) |srf_data| { + defer self.allocator.free(srf_data); + s.writeRaw(symbol, .dividends, srf_data) catch {}; + } else |_| {} + } + + return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; + } + + /// Fetch split history for a symbol. + /// Checks cache first; fetches from Polygon if stale/missing. + pub fn getSplits(self: *DataService, symbol: []const u8) DataError!struct { data: []Split, source: Source, timestamp: i64 } { + var s = self.store(); + + const cached_raw = s.readRaw(symbol, .splits) catch return DataError.CacheError; + if (cached_raw) |data| { + defer self.allocator.free(data); + const fresh = s.isFresh(symbol, .splits, cache.Ttl.splits) catch false; + if (fresh) { + const splits = cache.Store.deserializeSplits(self.allocator, data) catch null; + if (splits) |sp| return .{ .data = sp, .source = .cached, .timestamp = s.getMtime(symbol, .splits) orelse std.time.timestamp() }; + } + } + + var pg = try self.getPolygon(); + const fetched = pg.fetchSplits(self.allocator, symbol) catch + return DataError.FetchFailed; + + if (cache.Store.serializeSplits(self.allocator, fetched)) |srf_data| { + defer self.allocator.free(srf_data); + s.writeRaw(symbol, .splits, srf_data) catch {}; + } else |_| {} + + return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; + } + + /// Fetch options chain for a symbol (all expirations). + /// Checks cache first; fetches from CBOE if stale/missing (no API key needed). + pub fn getOptions(self: *DataService, symbol: []const u8) DataError!struct { data: []OptionsChain, source: Source, timestamp: i64 } { + var s = self.store(); + + const cached_raw = s.readRaw(symbol, .options) catch return DataError.CacheError; + if (cached_raw) |data| { + defer self.allocator.free(data); + const fresh = s.isFresh(symbol, .options, cache.Ttl.options) catch false; + if (fresh) { + const chains = cache.Store.deserializeOptions(self.allocator, data) catch null; + if (chains) |c| return .{ .data = c, .source = .cached, .timestamp = s.getMtime(symbol, .options) orelse std.time.timestamp() }; + } + } + + var cboe = self.getCboe(); + const fetched = cboe.fetchOptionsChain(self.allocator, symbol) catch + return DataError.FetchFailed; + + if (fetched.len > 0) { + if (cache.Store.serializeOptions(self.allocator, fetched)) |srf_data| { + defer self.allocator.free(srf_data); + s.writeRaw(symbol, .options, srf_data) catch {}; + } else |_| {} + } + + return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; + } + + /// Fetch earnings history for a symbol (5 years back, 1 year forward). + /// Checks cache first; fetches from Finnhub if stale/missing. + pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!struct { data: []EarningsEvent, source: Source, timestamp: i64 } { + var s = self.store(); + + const cached_raw = s.readRaw(symbol, .earnings) catch return DataError.CacheError; + if (cached_raw) |data| { + defer self.allocator.free(data); + const fresh = s.isFresh(symbol, .earnings, cache.Ttl.earnings) catch false; + if (fresh) { + const events = cache.Store.deserializeEarnings(self.allocator, data) catch null; + if (events) |e| return .{ .data = e, .source = .cached, .timestamp = s.getMtime(symbol, .earnings) orelse std.time.timestamp() }; + } + } + + var fh = try self.getFinnhub(); + const today = todayDate(); + const from = today.subtractYears(5); + const to = today.addDays(365); + + const fetched = fh.fetchEarnings(self.allocator, symbol, from, to) catch + return DataError.FetchFailed; + + if (fetched.len > 0) { + if (cache.Store.serializeEarnings(self.allocator, fetched)) |srf_data| { + defer self.allocator.free(srf_data); + s.writeRaw(symbol, .earnings, srf_data) catch {}; + } else |_| {} + } + + return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; + } + + /// Fetch ETF profile for a symbol. + /// Checks cache first; fetches from Alpha Vantage if stale/missing. + pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!struct { data: EtfProfile, source: Source, timestamp: i64 } { + var s = self.store(); + + const cached_raw = s.readRaw(symbol, .etf_profile) catch return DataError.CacheError; + if (cached_raw) |data| { + defer self.allocator.free(data); + const fresh = s.isFresh(symbol, .etf_profile, cache.Ttl.etf_profile) catch false; + if (fresh) { + const profile = cache.Store.deserializeEtfProfile(self.allocator, data) catch null; + if (profile) |p| return .{ .data = p, .source = .cached, .timestamp = s.getMtime(symbol, .etf_profile) orelse std.time.timestamp() }; + } + } + + var av = try self.getAlphaVantage(); + const fetched = av.fetchEtfProfile(self.allocator, symbol) catch + return DataError.FetchFailed; + + if (cache.Store.serializeEtfProfile(self.allocator, fetched)) |srf_data| { + defer self.allocator.free(srf_data); + s.writeRaw(symbol, .etf_profile, srf_data) catch {}; + } else |_| {} + + return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; + } + + /// Fetch a real-time (or 15-min delayed) quote for a symbol. + /// No cache -- always fetches fresh from TwelveData. + pub fn getQuote(self: *DataService, symbol: []const u8) DataError!Quote { + var td = try self.getTwelveData(); + var resp = td.fetchQuote(self.allocator, symbol) catch + return DataError.FetchFailed; + defer resp.deinit(); + + var parsed = resp.parse(self.allocator) catch + return DataError.ParseError; + defer parsed.deinit(); + + return .{ + .symbol = symbol, + .name = symbol, // name is in parsed JSON but lifetime is tricky; use symbol + .exchange = "", + .datetime = "", + .close = parsed.close(), + .open = parsed.open(), + .high = parsed.high(), + .low = parsed.low(), + .volume = parsed.volume(), + .previous_close = parsed.previous_close(), + .change = parsed.change(), + .percent_change = parsed.percent_change(), + .average_volume = parsed.average_volume(), + .fifty_two_week_low = parsed.fifty_two_week_low(), + .fifty_two_week_high = parsed.fifty_two_week_high(), + }; + } + + /// Compute trailing returns for a symbol (fetches candles + dividends). + /// Returns both as-of-date and month-end trailing returns. + /// As-of-date: end = latest close. Matches Morningstar "Trailing Returns" page. + /// Month-end: end = last business day of prior month. Matches Morningstar "Performance" page. + pub fn getTrailingReturns(self: *DataService, symbol: []const u8) DataError!struct { + asof_price: performance.TrailingReturns, + asof_total: ?performance.TrailingReturns, + me_price: performance.TrailingReturns, + me_total: ?performance.TrailingReturns, + candles: []Candle, + dividends: ?[]Dividend, + source: Source, + timestamp: i64, + } { + const candle_result = try self.getCandles(symbol); + const c = candle_result.data; + if (c.len == 0) return DataError.FetchFailed; + + const today = todayDate(); + + // As-of-date (end = last candle) + const asof_price = performance.trailingReturns(c); + // Month-end (end = last business day of prior month) + const me_price = performance.trailingReturnsMonthEnd(c, today); + + // Try to get dividends (non-fatal if unavailable) + var divs: ?[]Dividend = null; + var asof_total: ?performance.TrailingReturns = null; + var me_total: ?performance.TrailingReturns = null; + + if (self.getDividends(symbol)) |div_result| { + divs = div_result.data; + asof_total = performance.trailingReturnsWithDividends(c, div_result.data); + me_total = performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); + } else |_| {} + + return .{ + .asof_price = asof_price, + .asof_total = asof_total, + .me_price = me_price, + .me_total = me_total, + .candles = c, + .dividends = divs, + .source = candle_result.source, + .timestamp = candle_result.timestamp, + }; + } + + /// Read candles from cache only (no network fetch). Used by TUI for display. + /// Returns null if no cached data exists. + pub fn getCachedCandles(self: *DataService, symbol: []const u8) ?[]Candle { + var s = self.store(); + const data = s.readRaw(symbol, .candles_daily) catch return null; + if (data) |d| { + defer self.allocator.free(d); + return cache.Store.deserializeCandles(self.allocator, d) catch null; + } + return null; + } + + /// Read dividends from cache only (no network fetch). + pub fn getCachedDividends(self: *DataService, symbol: []const u8) ?[]Dividend { + var s = self.store(); + const data = s.readRaw(symbol, .dividends) catch return null; + if (data) |d| { + defer self.allocator.free(d); + return cache.Store.deserializeDividends(self.allocator, d) catch null; + } + return null; + } + + /// Read earnings from cache only (no network fetch). + pub fn getCachedEarnings(self: *DataService, symbol: []const u8) ?[]EarningsEvent { + var s = self.store(); + const data = s.readRaw(symbol, .earnings) catch return null; + if (data) |d| { + defer self.allocator.free(d); + return cache.Store.deserializeEarnings(self.allocator, d) catch null; + } + return null; + } + + /// Read options from cache only (no network fetch). + pub fn getCachedOptions(self: *DataService, symbol: []const u8) ?[]OptionsChain { + var s = self.store(); + const data = s.readRaw(symbol, .options) catch return null; + if (data) |d| { + defer self.allocator.free(d); + return cache.Store.deserializeOptions(self.allocator, d) catch null; + } + return null; + } + + // ── Utility ────────────────────────────────────────────────── + + fn todayDate() Date { + const ts = std.time.timestamp(); + const days: i32 = @intCast(@divFloor(ts, 86400)); + return .{ .days = days }; + } +}; diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig new file mode 100644 index 0000000..56989ae --- /dev/null +++ b/src/tui/keybinds.zig @@ -0,0 +1,405 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const srf = @import("srf"); + +pub const Action = enum { + quit, + refresh, + prev_tab, + next_tab, + tab_1, + tab_2, + tab_3, + tab_4, + tab_5, + scroll_down, + scroll_up, + scroll_top, + scroll_bottom, + page_down, + page_up, + select_next, + select_prev, + expand_collapse, + select_symbol, + symbol_input, + help, + edit, + collapse_all_calls, + collapse_all_puts, + options_filter_1, + options_filter_2, + options_filter_3, + options_filter_4, + options_filter_5, + options_filter_6, + options_filter_7, + options_filter_8, + options_filter_9, +}; + +pub const KeyCombo = struct { + codepoint: u21, + mods: vaxis.Key.Modifiers = .{}, +}; + +pub const Binding = struct { + action: Action, + key: KeyCombo, +}; + +pub const KeyMap = struct { + bindings: []const Binding, + arena: ?*std.heap.ArenaAllocator = null, + + pub fn deinit(self: *KeyMap) void { + if (self.arena) |a| { + const backing = a.child_allocator; + a.deinit(); + backing.destroy(a); + } + } + + pub fn matchAction(self: KeyMap, key: vaxis.Key) ?Action { + for (self.bindings) |b| { + if (key.matches(b.key.codepoint, b.key.mods)) return b.action; + } + return null; + } +}; + +// ── Defaults ───────────────────────────────────────────────── + +const default_bindings = [_]Binding{ + .{ .action = .quit, .key = .{ .codepoint = 'q' } }, + .{ .action = .quit, .key = .{ .codepoint = 'c', .mods = .{ .ctrl = true } } }, + .{ .action = .refresh, .key = .{ .codepoint = 'r' } }, + .{ .action = .refresh, .key = .{ .codepoint = vaxis.Key.f5 } }, + .{ .action = .prev_tab, .key = .{ .codepoint = 'h' } }, + .{ .action = .prev_tab, .key = .{ .codepoint = vaxis.Key.left } }, + .{ .action = .next_tab, .key = .{ .codepoint = 'l' } }, + .{ .action = .next_tab, .key = .{ .codepoint = vaxis.Key.right } }, + .{ .action = .next_tab, .key = .{ .codepoint = vaxis.Key.tab } }, + .{ .action = .tab_1, .key = .{ .codepoint = '1' } }, + .{ .action = .tab_2, .key = .{ .codepoint = '2' } }, + .{ .action = .tab_3, .key = .{ .codepoint = '3' } }, + .{ .action = .tab_4, .key = .{ .codepoint = '4' } }, + .{ .action = .tab_5, .key = .{ .codepoint = '5' } }, + .{ .action = .scroll_down, .key = .{ .codepoint = 'd', .mods = .{ .ctrl = true } } }, + .{ .action = .scroll_up, .key = .{ .codepoint = 'u', .mods = .{ .ctrl = true } } }, + .{ .action = .scroll_top, .key = .{ .codepoint = 'g' } }, + .{ .action = .scroll_bottom, .key = .{ .codepoint = 'G' } }, + .{ .action = .page_down, .key = .{ .codepoint = vaxis.Key.page_down } }, + .{ .action = .page_up, .key = .{ .codepoint = vaxis.Key.page_up } }, + .{ .action = .select_next, .key = .{ .codepoint = 'j' } }, + .{ .action = .select_next, .key = .{ .codepoint = vaxis.Key.down } }, + .{ .action = .select_prev, .key = .{ .codepoint = 'k' } }, + .{ .action = .select_prev, .key = .{ .codepoint = vaxis.Key.up } }, + .{ .action = .expand_collapse, .key = .{ .codepoint = vaxis.Key.enter } }, + .{ .action = .select_symbol, .key = .{ .codepoint = 's' } }, + .{ .action = .symbol_input, .key = .{ .codepoint = '/' } }, + .{ .action = .help, .key = .{ .codepoint = '?' } }, + .{ .action = .edit, .key = .{ .codepoint = 'e' } }, + .{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } }, + .{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } }, + .{ .action = .options_filter_1, .key = .{ .codepoint = '1', .mods = .{ .ctrl = true } } }, + .{ .action = .options_filter_2, .key = .{ .codepoint = '2', .mods = .{ .ctrl = true } } }, + .{ .action = .options_filter_3, .key = .{ .codepoint = '3', .mods = .{ .ctrl = true } } }, + .{ .action = .options_filter_4, .key = .{ .codepoint = '4', .mods = .{ .ctrl = true } } }, + .{ .action = .options_filter_5, .key = .{ .codepoint = '5', .mods = .{ .ctrl = true } } }, + .{ .action = .options_filter_6, .key = .{ .codepoint = '6', .mods = .{ .ctrl = true } } }, + .{ .action = .options_filter_7, .key = .{ .codepoint = '7', .mods = .{ .ctrl = true } } }, + .{ .action = .options_filter_8, .key = .{ .codepoint = '8', .mods = .{ .ctrl = true } } }, + .{ .action = .options_filter_9, .key = .{ .codepoint = '9', .mods = .{ .ctrl = true } } }, +}; + +pub fn defaults() KeyMap { + return .{ .bindings = &default_bindings }; +} + +// ── SRF serialization ──────────────────────────────────────── + +const special_key_names = [_]struct { name: []const u8, cp: u21 }{ + .{ .name = "tab", .cp = vaxis.Key.tab }, + .{ .name = "enter", .cp = vaxis.Key.enter }, + .{ .name = "escape", .cp = vaxis.Key.escape }, + .{ .name = "space", .cp = vaxis.Key.space }, + .{ .name = "backspace", .cp = vaxis.Key.backspace }, + .{ .name = "insert", .cp = vaxis.Key.insert }, + .{ .name = "delete", .cp = vaxis.Key.delete }, + .{ .name = "left", .cp = vaxis.Key.left }, + .{ .name = "right", .cp = vaxis.Key.right }, + .{ .name = "up", .cp = vaxis.Key.up }, + .{ .name = "down", .cp = vaxis.Key.down }, + .{ .name = "page_up", .cp = vaxis.Key.page_up }, + .{ .name = "page_down", .cp = vaxis.Key.page_down }, + .{ .name = "home", .cp = vaxis.Key.home }, + .{ .name = "end", .cp = vaxis.Key.end }, + .{ .name = "F1", .cp = vaxis.Key.f1 }, + .{ .name = "F2", .cp = vaxis.Key.f2 }, + .{ .name = "F3", .cp = vaxis.Key.f3 }, + .{ .name = "F4", .cp = vaxis.Key.f4 }, + .{ .name = "F5", .cp = vaxis.Key.f5 }, + .{ .name = "F6", .cp = vaxis.Key.f6 }, + .{ .name = "F7", .cp = vaxis.Key.f7 }, + .{ .name = "F8", .cp = vaxis.Key.f8 }, + .{ .name = "F9", .cp = vaxis.Key.f9 }, + .{ .name = "F10", .cp = vaxis.Key.f10 }, + .{ .name = "F11", .cp = vaxis.Key.f11 }, + .{ .name = "F12", .cp = vaxis.Key.f12 }, +}; + +fn codepointToName(cp: u21) ?[]const u8 { + for (special_key_names) |entry| { + if (entry.cp == cp) return entry.name; + } + return null; +} + +fn nameToCodepoint(name: []const u8) ?u21 { + // Check our table first (case-insensitive for F-keys) + for (special_key_names) |entry| { + if (std.ascii.eqlIgnoreCase(entry.name, name)) return entry.cp; + } + // Fall back to vaxis name_map (lowercase) + var lower_buf: [32]u8 = undefined; + const lower = toLower(name, &lower_buf) orelse return null; + return vaxis.Key.name_map.get(lower); +} + +fn toLower(s: []const u8, buf: []u8) ?[]const u8 { + if (s.len > buf.len) return null; + for (s, 0..) |c, i| { + buf[i] = std.ascii.toLower(c); + } + return buf[0..s.len]; +} + +pub fn formatKeyCombo(combo: KeyCombo, buf: []u8) ?[]const u8 { + var pos: usize = 0; + + if (combo.mods.ctrl) { + const prefix = "ctrl+"; + if (pos + prefix.len > buf.len) return null; + @memcpy(buf[pos..][0..prefix.len], prefix); + pos += prefix.len; + } + if (combo.mods.alt) { + const prefix = "alt+"; + if (pos + prefix.len > buf.len) return null; + @memcpy(buf[pos..][0..prefix.len], prefix); + pos += prefix.len; + } + if (combo.mods.shift) { + const prefix = "shift+"; + if (pos + prefix.len > buf.len) return null; + @memcpy(buf[pos..][0..prefix.len], prefix); + pos += prefix.len; + } + + if (codepointToName(combo.codepoint)) |name| { + if (pos + name.len > buf.len) return null; + @memcpy(buf[pos..][0..name.len], name); + pos += name.len; + } else if (combo.codepoint >= 0x20 and combo.codepoint < 0x7f) { + if (pos + 1 > buf.len) return null; + buf[pos] = @intCast(combo.codepoint); + pos += 1; + } else { + return null; + } + + return buf[0..pos]; +} + +fn parseKeyCombo(key_str: []const u8) ?KeyCombo { + var mods: vaxis.Key.Modifiers = .{}; + var rest = key_str; + + // Parse modifier prefixes + while (true) { + if (rest.len > 5 and std.ascii.eqlIgnoreCase(rest[0..5], "ctrl+")) { + mods.ctrl = true; + rest = rest[5..]; + } else if (rest.len > 4 and std.ascii.eqlIgnoreCase(rest[0..4], "alt+")) { + mods.alt = true; + rest = rest[4..]; + } else if (rest.len > 6 and std.ascii.eqlIgnoreCase(rest[0..6], "shift+")) { + mods.shift = true; + rest = rest[6..]; + } else break; + } + + if (rest.len == 0) return null; + + // Single printable character + if (rest.len == 1 and rest[0] >= 0x20 and rest[0] < 0x7f) { + return .{ .codepoint = rest[0], .mods = mods }; + } + + // Named key + if (nameToCodepoint(rest)) |cp| { + return .{ .codepoint = cp, .mods = mods }; + } + + return null; +} + +/// Print default keybindings in SRF format to stdout. +pub fn printDefaults() !void { + var buf: [4096]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.writeAll("#!srfv1\n"); + try out.writeAll("# zfin TUI keybindings\n"); + try out.writeAll("# This file is the sole source of keybindings when present.\n"); + try out.writeAll("# If this file is removed, built-in defaults are used.\n"); + try out.writeAll("# Regenerate: zfin interactive --default-keys > ~/.config/zfin/keys.srf\n"); + try out.writeAll("#\n"); + try out.writeAll("# Format: action::ACTION_NAME,key::KEY_STRING\n"); + try out.writeAll("# Modifiers: ctrl+, alt+, shift+ (e.g. ctrl+c)\n"); + try out.writeAll("# Special keys: tab, enter, escape, space, backspace,\n"); + try out.writeAll("# left, right, up, down, page_up, page_down, home, end,\n"); + try out.writeAll("# F1-F12, insert, delete\n"); + try out.writeAll("# Multiple lines with the same action = multiple bindings.\n"); + + for (default_bindings) |b| { + var key_buf: [32]u8 = undefined; + const key_str = formatKeyCombo(b.key, &key_buf) orelse continue; + try out.print("action::{s},key::{s}\n", .{ @tagName(b.action), key_str }); + } + + try out.flush(); +} + +// ── SRF loading ────────────────────────────────────────────── + +fn parseAction(name: []const u8) ?Action { + inline for (std.meta.fields(Action)) |f| { + if (std.mem.eql(u8, name, f.name)) return @enumFromInt(f.value); + } + return null; +} + +/// Load keybindings from an SRF file. Returns null if the file doesn't exist +/// or can't be parsed. On success, the caller owns the returned KeyMap and +/// must call deinit(). +pub fn loadFromFile(allocator: std.mem.Allocator, path: []const u8) ?KeyMap { + const data = std.fs.cwd().readFileAlloc(allocator, path, 64 * 1024) catch return null; + defer allocator.free(data); + return loadFromData(allocator, data); +} + +pub fn loadFromData(allocator: std.mem.Allocator, data: []const u8) ?KeyMap { + const arena = allocator.create(std.heap.ArenaAllocator) catch return null; + arena.* = std.heap.ArenaAllocator.init(allocator); + errdefer { + arena.deinit(); + allocator.destroy(arena); + } + const aa = arena.allocator(); + + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, aa, .{}) catch return null; + // Don't defer parsed.deinit() -- arena owns everything + + var bindings = std.ArrayList(Binding).empty; + + for (parsed.records.items) |record| { + var action: ?Action = null; + var key: ?KeyCombo = null; + + for (record.fields) |field| { + if (std.mem.eql(u8, field.key, "action")) { + if (field.value) |v| { + switch (v) { + .string => |s| action = parseAction(s), + else => {}, + } + } + } else if (std.mem.eql(u8, field.key, "key")) { + if (field.value) |v| { + switch (v) { + .string => |s| key = parseKeyCombo(s), + else => {}, + } + } + } + } + + if (action != null and key != null) { + bindings.append(aa, .{ .action = action.?, .key = key.? }) catch return null; + } + } + + return .{ + .bindings = bindings.toOwnedSlice(aa) catch return null, + .arena = arena, + }; +} + +// ── Tests ──────────────────────────────────────────────────── + +test "parseKeyCombo single char" { + const combo = parseKeyCombo("q").?; + try std.testing.expectEqual(@as(u21, 'q'), combo.codepoint); + try std.testing.expect(!combo.mods.ctrl); +} + +test "parseKeyCombo ctrl modifier" { + const combo = parseKeyCombo("ctrl+c").?; + try std.testing.expectEqual(@as(u21, 'c'), combo.codepoint); + try std.testing.expect(combo.mods.ctrl); +} + +test "parseKeyCombo special key" { + const combo = parseKeyCombo("F5").?; + try std.testing.expectEqual(vaxis.Key.f5, combo.codepoint); +} + +test "parseKeyCombo named key" { + const combo = parseKeyCombo("tab").?; + try std.testing.expectEqual(vaxis.Key.tab, combo.codepoint); +} + +test "formatKeyCombo roundtrip" { + var buf: [32]u8 = undefined; + const combo = KeyCombo{ .codepoint = 'c', .mods = .{ .ctrl = true } }; + const str = formatKeyCombo(combo, &buf).?; + try std.testing.expectEqualStrings("ctrl+c", str); + const parsed = parseKeyCombo(str).?; + try std.testing.expectEqual(combo.codepoint, parsed.codepoint); + try std.testing.expect(parsed.mods.ctrl); +} + +test "parseAction" { + try std.testing.expectEqual(Action.quit, parseAction("quit").?); + try std.testing.expectEqual(Action.refresh, parseAction("refresh").?); + try std.testing.expect(parseAction("nonexistent") == null); +} + +test "loadFromData basic" { + const data = + \\#!srfv1 + \\action::quit,key::q + \\action::quit,key::ctrl+c + \\action::refresh,key::F5 + ; + var km = loadFromData(std.testing.allocator, data) orelse return error.ParseFailed; + defer km.deinit(); + try std.testing.expectEqual(@as(usize, 3), km.bindings.len); + try std.testing.expectEqual(Action.quit, km.bindings[0].action); + try std.testing.expectEqual(Action.refresh, km.bindings[2].action); +} + +test "defaults returns valid keymap" { + const km = defaults(); + try std.testing.expect(km.bindings.len > 0); + // Verify quit is in there + var found_quit = false; + for (km.bindings) |b| { + if (b.action == .quit) found_quit = true; + } + try std.testing.expect(found_quit); +} diff --git a/src/tui/main.zig b/src/tui/main.zig new file mode 100644 index 0000000..8b96d52 --- /dev/null +++ b/src/tui/main.zig @@ -0,0 +1,2361 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const zfin = @import("zfin"); +const keybinds = @import("keybinds.zig"); +const theme_mod = @import("theme.zig"); + +/// Comptime-generated table of single-character grapheme slices with static lifetime. +/// This avoids dangling pointers from stack-allocated temporaries in draw functions. +const ascii_g = blk: { + var table: [128][]const u8 = undefined; + for (0..128) |i| { + const ch: [1]u8 = .{@as(u8, @intCast(i))}; + table[i] = &ch; + } + break :blk table; +}; + +fn glyph(ch: u8) []const u8 { + if (ch < 128) return ascii_g[ch]; + return " "; +} + +const Tab = enum { + portfolio, + quote, + performance, + options, + earnings, + + fn label(self: Tab) []const u8 { + return switch (self) { + .portfolio => " 1:Portfolio ", + .quote => " 2:Quote ", + .performance => " 3:Performance ", + .options => " 4:Options ", + .earnings => " 5:Earnings ", + }; + } +}; + +const tabs = [_]Tab{ .portfolio, .quote, .performance, .options, .earnings }; + +const InputMode = enum { + normal, + symbol_input, + help, +}; + +/// A row in the portfolio view -- either a position header or an individual lot. +const PortfolioRow = struct { + kind: Kind, + symbol: []const u8, + /// For position rows: index into allocations; for lot rows: lot data. + pos_idx: usize = 0, + lot: ?zfin.Lot = null, + /// Number of lots for this symbol (set on position rows) + lot_count: usize = 0, + + const Kind = enum { position, lot, watchlist }; +}; + +/// Styled line for rendering +const StyledLine = struct { + text: []const u8, + style: vaxis.Style, + // Optional per-character style override ranges (for mixed-color lines) + alt_text: ?[]const u8 = null, // text for the gain/loss column + alt_style: ?vaxis.Style = null, + alt_start: usize = 0, + alt_end: usize = 0, +}; + +const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put }; + +/// A row in the flattened options view (expiration header or contract sub-row). +const OptionsRow = struct { + kind: OptionsRowKind, + exp_idx: usize = 0, // index into options_data chains + contract: ?zfin.OptionContract = null, +}; + +const App = struct { + allocator: std.mem.Allocator, + config: zfin.Config, + svc: *zfin.DataService, + keymap: keybinds.KeyMap, + theme: theme_mod.Theme, + active_tab: Tab = .portfolio, + symbol: []const u8 = "", + symbol_buf: [16]u8 = undefined, + symbol_owned: bool = false, + scroll_offset: usize = 0, + visible_height: u16 = 24, // updated each draw + + has_explicit_symbol: bool = false, // true if -s was used + + portfolio: ?zfin.Portfolio = null, + portfolio_path: ?[]const u8 = null, + watchlist: ?[][]const u8 = null, + watchlist_path: ?[]const u8 = null, + status_msg: [256]u8 = undefined, + status_len: usize = 0, + + // Input mode state + mode: InputMode = .normal, + input_buf: [16]u8 = undefined, + input_len: usize = 0, + + // Portfolio navigation + cursor: usize = 0, // selected row in portfolio view + expanded: [64]bool = [_]bool{false} ** 64, // which positions are expanded + portfolio_rows: std.ArrayList(PortfolioRow) = .empty, + + // Options navigation (inline expand/collapse like portfolio) + options_cursor: usize = 0, // selected row in flattened options view + options_expanded: [64]bool = [_]bool{false} ** 64, // which expirations are expanded + options_calls_collapsed: [64]bool = [_]bool{false} ** 64, // per-expiration: calls section collapsed + options_puts_collapsed: [64]bool = [_]bool{false} ** 64, // per-expiration: puts section collapsed + options_near_the_money: usize = 8, // +/- strikes from ATM + options_rows: std.ArrayList(OptionsRow) = .empty, + + // Double-click tracking + last_click_row: usize = 0, + last_click_time: i64 = 0, + + // Cached data for rendering + candles: ?[]zfin.Candle = null, + dividends: ?[]zfin.Dividend = null, + earnings_data: ?[]zfin.EarningsEvent = null, + options_data: ?[]zfin.OptionsChain = null, + portfolio_summary: ?zfin.risk.PortfolioSummary = null, + risk_metrics: ?zfin.risk.RiskMetrics = null, + trailing_price: ?zfin.performance.TrailingReturns = null, + trailing_total: ?zfin.performance.TrailingReturns = null, + trailing_me_price: ?zfin.performance.TrailingReturns = null, + trailing_me_total: ?zfin.performance.TrailingReturns = null, + candle_count: usize = 0, + candle_first_date: ?zfin.Date = null, + candle_last_date: ?zfin.Date = null, + data_error: ?[]const u8 = null, + perf_loaded: bool = false, + earnings_loaded: bool = false, + options_loaded: bool = false, + portfolio_loaded: bool = false, + // Data timestamps (unix seconds) + candle_timestamp: i64 = 0, + options_timestamp: i64 = 0, + earnings_timestamp: i64 = 0, + // Stored real-time quote (only fetched on manual refresh) + quote: ?zfin.Quote = null, + quote_timestamp: i64 = 0, + // Track whether earnings tab should be disabled (ETF, no data) + earnings_disabled: bool = false, + // Signal to the run loop to launch $EDITOR then restart + wants_edit: bool = false, + + pub fn widget(self: *App) vaxis.vxfw.Widget { + return .{ + .userdata = self, + .eventHandler = typeErasedEventHandler, + .drawFn = typeErasedDrawFn, + }; + } + + fn typeErasedEventHandler(ptr: *anyopaque, ctx: *vaxis.vxfw.EventContext, event: vaxis.vxfw.Event) anyerror!void { + const self: *App = @ptrCast(@alignCast(ptr)); + switch (event) { + .key_press => |key| { + if (self.mode == .symbol_input) { + return self.handleInputKey(ctx, key); + } + if (self.mode == .help) { + self.mode = .normal; + return ctx.consumeAndRedraw(); + } + return self.handleNormalKey(ctx, key); + }, + .mouse => |mouse| { + return self.handleMouse(ctx, mouse); + }, + .init => { + self.loadTabData(); + }, + else => {}, + } + } + + fn handleMouse(self: *App, ctx: *vaxis.vxfw.EventContext, mouse: vaxis.Mouse) void { + switch (mouse.button) { + .wheel_up => { + if (self.active_tab == .portfolio) { + if (self.cursor > 0) self.cursor -= 1; + self.ensureCursorVisible(); + } else if (self.active_tab == .options) { + if (self.options_cursor > 0) self.options_cursor -= 1; + self.ensureOptionsCursorVisible(); + } else { + if (self.scroll_offset > 0) self.scroll_offset -= 3; + } + return ctx.consumeAndRedraw(); + }, + .wheel_down => { + if (self.active_tab == .portfolio) { + if (self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len - 1) + self.cursor += 1; + self.ensureCursorVisible(); + } else if (self.active_tab == .options) { + if (self.options_rows.items.len > 0 and self.options_cursor < self.options_rows.items.len - 1) + self.options_cursor += 1; + self.ensureOptionsCursorVisible(); + } else { + self.scroll_offset += 3; + } + return ctx.consumeAndRedraw(); + }, + .left => { + if (mouse.type == .press) { + if (mouse.row == 0) { + var col: i16 = 0; + for (tabs) |t| { + const lbl_len: i16 = @intCast(t.label().len); + if (mouse.col >= col and mouse.col < col + lbl_len) { + if (t == .earnings and self.earnings_disabled) return; + self.active_tab = t; + self.scroll_offset = 0; + self.loadTabData(); + return ctx.consumeAndRedraw(); + } + col += lbl_len; + } + } + if (self.active_tab == .portfolio and mouse.row > 0) { + const content_row = @as(usize, @intCast(mouse.row)) - 1 + self.scroll_offset; + if (content_row >= 4 and self.portfolio_rows.items.len > 0) { + const row_idx = content_row - 4; + if (row_idx < self.portfolio_rows.items.len) { + const now_ms = std.time.milliTimestamp(); + if (row_idx == self.last_click_row and (now_ms - self.last_click_time) < 500) { + // Double-click: expand/collapse + self.cursor = row_idx; + self.toggleExpand(); + self.last_click_time = 0; + } else { + self.cursor = row_idx; + self.last_click_row = row_idx; + self.last_click_time = now_ms; + } + return ctx.consumeAndRedraw(); + } + } + } + // Options tab: click to select row, double-click to expand/collapse + if (self.active_tab == .options and mouse.row > 0) { + const content_row = @as(usize, @intCast(mouse.row)) - 1 + self.scroll_offset; + // Options rows start after header lines (blank, header, underlying, blank, col header = 5 lines) + if (content_row >= 5 and self.options_rows.items.len > 0) { + const row_idx = content_row - 5; + if (row_idx < self.options_rows.items.len) { + const now_ms = std.time.milliTimestamp(); + if (row_idx == self.last_click_row and (now_ms - self.last_click_time) < 500) { + // Double-click: expand/collapse + self.options_cursor = row_idx; + self.toggleOptionsExpand(); + self.last_click_time = 0; + } else { + self.options_cursor = row_idx; + self.last_click_row = row_idx; + self.last_click_time = now_ms; + } + return ctx.consumeAndRedraw(); + } + } + } + } + }, + else => {}, + } + } + + fn handleInputKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { + if (key.codepoint == vaxis.Key.escape) { + self.mode = .normal; + self.input_len = 0; + self.setStatus("Cancelled"); + return ctx.consumeAndRedraw(); + } + if (key.codepoint == vaxis.Key.enter) { + if (self.input_len > 0) { + for (self.input_buf[0..self.input_len]) |*ch| { + if (ch.* >= 'a' and ch.* <= 'z') ch.* = ch.* - 32; + } + @memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]); + self.symbol = self.symbol_buf[0..self.input_len]; + self.symbol_owned = true; + self.has_explicit_symbol = true; + self.resetSymbolData(); + self.active_tab = .quote; + self.loadTabData(); + } + self.mode = .normal; + self.input_len = 0; + return ctx.consumeAndRedraw(); + } + if (key.codepoint == vaxis.Key.backspace) { + if (self.input_len > 0) self.input_len -= 1; + return ctx.consumeAndRedraw(); + } + if (key.matches('u', .{ .ctrl = true })) { + self.input_len = 0; + return ctx.consumeAndRedraw(); + } + if (key.codepoint >= 0x20 and key.codepoint < 0x7f and self.input_len < self.input_buf.len) { + self.input_buf[self.input_len] = @intCast(key.codepoint); + self.input_len += 1; + return ctx.consumeAndRedraw(); + } + } + + fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { + // Escape: no special behavior needed (options is now inline) + if (key.codepoint == vaxis.Key.escape) { + return; + } + + const action = self.keymap.matchAction(key) orelse return; + switch (action) { + .quit => { + ctx.quit = true; + }, + .symbol_input => { + self.mode = .symbol_input; + self.input_len = 0; + return ctx.consumeAndRedraw(); + }, + .select_symbol => { + // 's' selects the current portfolio row's symbol as the active symbol + if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len) { + const row = self.portfolio_rows.items[self.cursor]; + self.setActiveSymbol(row.symbol); + // Format into a separate buffer to avoid aliasing with status_msg + var tmp_buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&tmp_buf, "Active: {s}", .{row.symbol}) catch "Active"; + self.setStatus(msg); + return ctx.consumeAndRedraw(); + } + }, + .refresh => { + self.refreshCurrentTab(); + return ctx.consumeAndRedraw(); + }, + .prev_tab => { + self.prevTab(); + self.scroll_offset = 0; + self.loadTabData(); + return ctx.consumeAndRedraw(); + }, + .next_tab => { + self.nextTab(); + self.scroll_offset = 0; + self.loadTabData(); + return ctx.consumeAndRedraw(); + }, + .tab_1, .tab_2, .tab_3, .tab_4, .tab_5 => { + const idx = @intFromEnum(action) - @intFromEnum(keybinds.Action.tab_1); + if (idx < tabs.len) { + const target = tabs[idx]; + if (target == .earnings and self.earnings_disabled) return; + self.active_tab = target; + self.scroll_offset = 0; + self.loadTabData(); + return ctx.consumeAndRedraw(); + } + }, + .select_next => { + if (self.active_tab == .portfolio) { + if (self.portfolio_rows.items.len > 0 and self.cursor < self.portfolio_rows.items.len - 1) + self.cursor += 1; + self.ensureCursorVisible(); + } else if (self.active_tab == .options) { + if (self.options_rows.items.len > 0 and self.options_cursor < self.options_rows.items.len - 1) + self.options_cursor += 1; + self.ensureOptionsCursorVisible(); + } else { + self.scroll_offset += 1; + } + return ctx.consumeAndRedraw(); + }, + .select_prev => { + if (self.active_tab == .portfolio) { + if (self.cursor > 0) self.cursor -= 1; + self.ensureCursorVisible(); + } else if (self.active_tab == .options) { + if (self.options_cursor > 0) + self.options_cursor -= 1; + self.ensureOptionsCursorVisible(); + } else { + if (self.scroll_offset > 0) self.scroll_offset -= 1; + } + return ctx.consumeAndRedraw(); + }, + .expand_collapse => { + if (self.active_tab == .portfolio) { + self.toggleExpand(); + return ctx.consumeAndRedraw(); + } else if (self.active_tab == .options) { + self.toggleOptionsExpand(); + return ctx.consumeAndRedraw(); + } + }, + .scroll_down => { + const half = @max(1, self.visible_height / 2); + self.scroll_offset += half; + return ctx.consumeAndRedraw(); + }, + .scroll_up => { + const half = @max(1, self.visible_height / 2); + if (self.scroll_offset > half) self.scroll_offset -= half else self.scroll_offset = 0; + return ctx.consumeAndRedraw(); + }, + .page_down => { + self.scroll_offset += self.visible_height; + return ctx.consumeAndRedraw(); + }, + .page_up => { + if (self.scroll_offset > self.visible_height) + self.scroll_offset -= self.visible_height + else + self.scroll_offset = 0; + return ctx.consumeAndRedraw(); + }, + .scroll_top => { + self.scroll_offset = 0; + if (self.active_tab == .portfolio) self.cursor = 0; + if (self.active_tab == .options) self.options_cursor = 0; + return ctx.consumeAndRedraw(); + }, + .scroll_bottom => { + self.scroll_offset = 999; + if (self.active_tab == .portfolio and self.portfolio_rows.items.len > 0) + self.cursor = self.portfolio_rows.items.len - 1; + if (self.active_tab == .options and self.options_rows.items.len > 0) + self.options_cursor = self.options_rows.items.len - 1; + return ctx.consumeAndRedraw(); + }, + .help => { + self.mode = .help; + self.scroll_offset = 0; + return ctx.consumeAndRedraw(); + }, + .edit => { + if (self.portfolio_path != null or self.watchlist_path != null) { + self.wants_edit = true; + ctx.quit = true; + } else { + self.setStatus("No portfolio or watchlist file to edit"); + return ctx.consumeAndRedraw(); + } + }, + .collapse_all_calls => { + if (self.active_tab == .options) { + self.toggleAllCallsPuts(true); + return ctx.consumeAndRedraw(); + } + }, + .collapse_all_puts => { + if (self.active_tab == .options) { + self.toggleAllCallsPuts(false); + return ctx.consumeAndRedraw(); + } + }, + .options_filter_1, .options_filter_2, .options_filter_3, .options_filter_4, .options_filter_5, .options_filter_6, .options_filter_7, .options_filter_8, .options_filter_9 => { + if (self.active_tab == .options) { + const n = @intFromEnum(action) - @intFromEnum(keybinds.Action.options_filter_1) + 1; + self.options_near_the_money = n; + self.rebuildOptionsRows(); + var tmp_buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint(&tmp_buf, "Filtered to +/- {d} strikes NTM", .{n}) catch "Filtered"; + self.setStatus(msg); + return ctx.consumeAndRedraw(); + } + }, + } + } + + fn ensureCursorVisible(self: *App) void { + const cursor_row = self.cursor + 4; // 4 header lines + if (cursor_row < self.scroll_offset) { + self.scroll_offset = cursor_row; + } + const vis: usize = self.visible_height; + if (cursor_row >= self.scroll_offset + vis) { + self.scroll_offset = cursor_row - vis + 1; + } + } + + fn toggleExpand(self: *App) void { + if (self.portfolio_rows.items.len == 0) return; + if (self.cursor >= self.portfolio_rows.items.len) return; + const row = self.portfolio_rows.items[self.cursor]; + switch (row.kind) { + .position => { + // Single-lot positions don't expand + if (row.lot_count <= 1) return; + if (row.pos_idx < self.expanded.len) { + self.expanded[row.pos_idx] = !self.expanded[row.pos_idx]; + self.rebuildPortfolioRows(); + } + }, + .lot => {}, + .watchlist => { + self.setActiveSymbol(row.symbol); + self.active_tab = .quote; + self.loadTabData(); + }, + } + } + + fn toggleOptionsExpand(self: *App) void { + if (self.options_rows.items.len == 0) return; + if (self.options_cursor >= self.options_rows.items.len) return; + const row = self.options_rows.items[self.options_cursor]; + switch (row.kind) { + .expiration => { + if (row.exp_idx < self.options_expanded.len) { + self.options_expanded[row.exp_idx] = !self.options_expanded[row.exp_idx]; + self.rebuildOptionsRows(); + } + }, + .calls_header => { + if (row.exp_idx < self.options_calls_collapsed.len) { + self.options_calls_collapsed[row.exp_idx] = !self.options_calls_collapsed[row.exp_idx]; + self.rebuildOptionsRows(); + } + }, + .puts_header => { + if (row.exp_idx < self.options_puts_collapsed.len) { + self.options_puts_collapsed[row.exp_idx] = !self.options_puts_collapsed[row.exp_idx]; + self.rebuildOptionsRows(); + } + }, + // Clicking on a contract does nothing + else => {}, + } + } + + /// Toggle all calls (is_calls=true) or all puts (is_calls=false) collapsed state. + fn toggleAllCallsPuts(self: *App, is_calls: bool) void { + const chains = self.options_data orelse return; + // Determine whether to collapse or expand: if any expanded chain has this section visible, collapse all; otherwise expand all + var any_visible = false; + for (chains, 0..) |_, ci| { + if (ci >= self.options_expanded.len) break; + if (!self.options_expanded[ci]) continue; // only count expanded expirations + if (is_calls) { + if (ci < self.options_calls_collapsed.len and !self.options_calls_collapsed[ci]) { + any_visible = true; + break; + } + } else { + if (ci < self.options_puts_collapsed.len and !self.options_puts_collapsed[ci]) { + any_visible = true; + break; + } + } + } + // If any are visible, collapse all; otherwise expand all + const new_state = any_visible; + for (chains, 0..) |_, ci| { + if (ci >= 64) break; + if (is_calls) { + self.options_calls_collapsed[ci] = new_state; + } else { + self.options_puts_collapsed[ci] = new_state; + } + } + self.rebuildOptionsRows(); + if (is_calls) { + self.setStatus(if (new_state) "All calls collapsed" else "All calls expanded"); + } else { + self.setStatus(if (new_state) "All puts collapsed" else "All puts expanded"); + } + } + + fn rebuildOptionsRows(self: *App) void { + self.options_rows.clearRetainingCapacity(); + const chains = self.options_data orelse return; + const atm_price = if (chains.len > 0) chains[0].underlying_price orelse 0 else @as(f64, 0); + + for (chains, 0..) |chain, ci| { + self.options_rows.append(self.allocator, .{ + .kind = .expiration, + .exp_idx = ci, + }) catch continue; + + if (ci < self.options_expanded.len and self.options_expanded[ci]) { + // Calls header (always shown, acts as toggle) + self.options_rows.append(self.allocator, .{ + .kind = .calls_header, + .exp_idx = ci, + }) catch continue; + + // Calls contracts (only if not collapsed) + if (!(ci < self.options_calls_collapsed.len and self.options_calls_collapsed[ci])) { + const filtered_calls = filterNearMoney(chain.calls, atm_price, self.options_near_the_money); + for (filtered_calls) |cc| { + self.options_rows.append(self.allocator, .{ + .kind = .call, + .exp_idx = ci, + .contract = cc, + }) catch continue; + } + } + + // Puts header (always shown, acts as toggle) + self.options_rows.append(self.allocator, .{ + .kind = .puts_header, + .exp_idx = ci, + }) catch continue; + + // Puts contracts (only if not collapsed) + if (!(ci < self.options_puts_collapsed.len and self.options_puts_collapsed[ci])) { + const filtered_puts = filterNearMoney(chain.puts, atm_price, self.options_near_the_money); + for (filtered_puts) |p| { + self.options_rows.append(self.allocator, .{ + .kind = .put, + .exp_idx = ci, + .contract = p, + }) catch continue; + } + } + } + } + } + + fn ensureOptionsCursorVisible(self: *App) void { + const cursor_row = self.options_cursor + 5; // 5 header lines in options content + if (cursor_row < self.scroll_offset) { + self.scroll_offset = cursor_row; + } + const vis: usize = self.visible_height; + if (cursor_row >= self.scroll_offset + vis) { + self.scroll_offset = cursor_row - vis + 1; + } + } + + fn setActiveSymbol(self: *App, sym: []const u8) void { + const len = @min(sym.len, self.symbol_buf.len); + @memcpy(self.symbol_buf[0..len], sym[0..len]); + self.symbol = self.symbol_buf[0..len]; + self.symbol_owned = true; + self.has_explicit_symbol = true; + self.resetSymbolData(); + } + + fn resetSymbolData(self: *App) void { + self.perf_loaded = false; + self.earnings_loaded = false; + self.earnings_disabled = false; + self.options_loaded = false; + self.options_cursor = 0; + self.options_expanded = [_]bool{false} ** 64; + self.options_calls_collapsed = [_]bool{false} ** 64; + self.options_puts_collapsed = [_]bool{false} ** 64; + self.options_rows.clearRetainingCapacity(); + self.candle_timestamp = 0; + self.options_timestamp = 0; + self.earnings_timestamp = 0; + self.quote = null; + self.quote_timestamp = 0; + self.freeCandles(); + self.freeDividends(); + self.freeEarnings(); + self.freeOptions(); + self.trailing_price = null; + self.trailing_total = null; + self.trailing_me_price = null; + self.trailing_me_total = null; + self.risk_metrics = null; + self.scroll_offset = 0; + } + + fn refreshCurrentTab(self: *App) void { + // Invalidate cache so the next load forces a fresh fetch + if (self.symbol.len > 0) { + switch (self.active_tab) { + .quote, .performance => { + self.svc.invalidate(self.symbol, .candles_daily); + self.svc.invalidate(self.symbol, .dividends); + }, + .earnings => { + self.svc.invalidate(self.symbol, .earnings); + }, + .options => { + self.svc.invalidate(self.symbol, .options); + }, + .portfolio => {}, + } + } + switch (self.active_tab) { + .portfolio => { + self.portfolio_loaded = false; + self.freePortfolioSummary(); + }, + .quote, .performance => { + self.perf_loaded = false; + self.freeCandles(); + self.freeDividends(); + }, + .earnings => { + self.earnings_loaded = false; + self.freeEarnings(); + }, + .options => { + self.options_loaded = false; + self.freeOptions(); + }, + } + self.loadTabData(); + + // After reload, fetch live quote for active symbol (costs 1 API call) + switch (self.active_tab) { + .quote, .performance => { + if (self.symbol.len > 0) { + if (self.svc.getQuote(self.symbol)) |q| { + self.quote = q; + self.quote_timestamp = std.time.timestamp(); + } else |_| {} + } + }, + else => {}, + } + } + + fn loadTabData(self: *App) void { + self.data_error = null; + switch (self.active_tab) { + .portfolio => { + if (!self.portfolio_loaded) self.loadPortfolioData(); + }, + .quote, .performance => { + if (self.symbol.len == 0) return; + if (!self.perf_loaded) self.loadPerfData(); + }, + .earnings => { + if (self.symbol.len == 0) return; + if (self.earnings_disabled) return; + if (!self.earnings_loaded) self.loadEarningsData(); + }, + .options => { + if (self.symbol.len == 0) return; + if (!self.options_loaded) self.loadOptionsData(); + }, + } + } + + fn loadPortfolioData(self: *App) void { + self.portfolio_loaded = true; + self.freePortfolioSummary(); + + // Fetch data for watchlist symbols so they have prices to display + if (self.watchlist) |wl| { + for (wl) |sym| { + const result = self.svc.getCandles(sym) catch continue; + self.allocator.free(result.data); + } + } + + const pf = self.portfolio orelse return; + + const positions = pf.positions(self.allocator) catch { + self.setStatus("Error computing positions"); + return; + }; + defer self.allocator.free(positions); + + var prices = std.StringHashMap(f64).init(self.allocator); + defer prices.deinit(); + + const syms = pf.symbols(self.allocator) catch { + self.setStatus("Error getting symbols"); + return; + }; + defer self.allocator.free(syms); + + var latest_date: ?zfin.Date = null; + for (syms) |sym| { + // Try cache first; if miss, fetch (handles new securities / stale cache) + const candles_slice = self.svc.getCachedCandles(sym) orelse blk: { + const result = self.svc.getCandles(sym) catch break :blk null; + break :blk result.data; + }; + if (candles_slice) |cs| { + defer self.allocator.free(cs); + if (cs.len > 0) { + prices.put(sym, cs[cs.len - 1].close) catch {}; + const d = cs[cs.len - 1].date; + if (latest_date == null or d.days > latest_date.?.days) latest_date = d; + } + } + } + self.candle_last_date = latest_date; + + var summary = zfin.risk.portfolioSummary(self.allocator, positions, prices) catch { + self.setStatus("Error computing portfolio summary"); + return; + }; + + if (summary.allocations.len == 0) { + summary.deinit(self.allocator); + self.setStatus("No cached prices. Run: zfin perf first"); + return; + } + + self.portfolio_summary = summary; + self.rebuildPortfolioRows(); + + if (self.symbol.len == 0 and summary.allocations.len > 0) { + self.setActiveSymbol(summary.allocations[0].symbol); + } + + self.setStatus("j/k navigate | Enter expand | s select symbol | / search | ? help"); + } + + fn rebuildPortfolioRows(self: *App) void { + self.portfolio_rows.clearRetainingCapacity(); + + if (self.portfolio_summary) |s| { + for (s.allocations, 0..) |a, i| { + // Count lots for this symbol + var lcount: usize = 0; + if (self.portfolio) |pf| { + for (pf.lots) |lot| { + if (std.mem.eql(u8, lot.symbol, a.symbol)) lcount += 1; + } + } + + self.portfolio_rows.append(self.allocator, .{ + .kind = .position, + .symbol = a.symbol, + .pos_idx = i, + .lot_count = lcount, + }) catch continue; + + // Only expand if multi-lot + if (lcount > 1 and i < self.expanded.len and self.expanded[i]) { + if (self.portfolio) |pf| { + // Collect matching lots, sort: open first (date desc), then closed (date desc) + var matching: std.ArrayList(zfin.Lot) = .empty; + defer matching.deinit(self.allocator); + for (pf.lots) |lot| { + if (std.mem.eql(u8, lot.symbol, a.symbol)) { + matching.append(self.allocator, lot) catch continue; + } + } + std.mem.sort(zfin.Lot, matching.items, {}, lotSortFn); + for (matching.items) |lot| { + self.portfolio_rows.append(self.allocator, .{ + .kind = .lot, + .symbol = lot.symbol, + .pos_idx = i, + .lot = lot, + }) catch continue; + } + } + } + } + } + + // Add watchlist items (integrated, dimmed) + if (self.watchlist) |wl| { + for (wl) |sym| { + if (self.portfolio_summary) |s| { + var found = false; + for (s.allocations) |a| { + if (std.mem.eql(u8, a.symbol, sym)) { + found = true; + break; + } + } + if (found) continue; + } + self.portfolio_rows.append(self.allocator, .{ + .kind = .watchlist, + .symbol = sym, + }) catch continue; + } + } + } + + fn loadPerfData(self: *App) void { + self.perf_loaded = true; + self.freeCandles(); + self.freeDividends(); + self.trailing_price = null; + self.trailing_total = null; + self.trailing_me_price = null; + self.trailing_me_total = null; + self.candle_count = 0; + self.candle_first_date = null; + self.candle_last_date = null; + + const candle_result = self.svc.getCandles(self.symbol) catch |err| { + switch (err) { + zfin.DataError.NoApiKey => self.setStatus("No API key. Set TWELVEDATA_API_KEY"), + zfin.DataError.FetchFailed => self.setStatus("Fetch failed (network error or rate limit)"), + else => self.setStatus("Error loading data"), + } + return; + }; + self.candles = candle_result.data; + self.candle_timestamp = candle_result.timestamp; + + const c = self.candles.?; + if (c.len == 0) { + self.setStatus("No data available for symbol"); + return; + } + self.candle_count = c.len; + self.candle_first_date = c[0].date; + self.candle_last_date = c[c.len - 1].date; + + const today = todayDate(); + self.trailing_price = zfin.performance.trailingReturns(c); + self.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today); + + if (self.svc.getDividends(self.symbol)) |div_result| { + self.dividends = div_result.data; + self.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data); + self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); + } else |_| {} + + self.risk_metrics = zfin.risk.computeRisk(c); + self.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); + } + + fn loadEarningsData(self: *App) void { + self.earnings_loaded = true; + self.freeEarnings(); + + const result = self.svc.getEarnings(self.symbol) catch |err| { + switch (err) { + zfin.DataError.NoApiKey => self.setStatus("No API key. Set FINNHUB_API_KEY"), + zfin.DataError.FetchFailed => { + self.earnings_disabled = true; + self.setStatus("No earnings data (ETF/index?)"); + }, + else => self.setStatus("Error loading earnings"), + } + return; + }; + self.earnings_data = result.data; + self.earnings_timestamp = result.timestamp; + + if (result.data.len == 0) { + self.earnings_disabled = true; + self.setStatus("No earnings data available (ETF/index?)"); + return; + } + self.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh"); + } + + fn loadOptionsData(self: *App) void { + self.options_loaded = true; + self.freeOptions(); + + const result = self.svc.getOptions(self.symbol) catch |err| { + switch (err) { + zfin.DataError.FetchFailed => self.setStatus("CBOE fetch failed (network error)"), + else => self.setStatus("Error loading options"), + } + return; + }; + self.options_data = result.data; + self.options_timestamp = result.timestamp; + self.options_cursor = 0; + self.options_expanded = [_]bool{false} ** 64; + self.options_calls_collapsed = [_]bool{false} ** 64; + self.options_puts_collapsed = [_]bool{false} ** 64; + self.rebuildOptionsRows(); + self.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh"); + } + + fn setStatus(self: *App, msg: []const u8) void { + const len = @min(msg.len, self.status_msg.len); + @memcpy(self.status_msg[0..len], msg[0..len]); + self.status_len = len; + } + + fn getStatus(self: *App) []const u8 { + if (self.status_len == 0) return "h/l tabs | j/k select | Enter expand | s select | / symbol | ? help"; + return self.status_msg[0..self.status_len]; + } + + fn freeCandles(self: *App) void { + if (self.candles) |c| self.allocator.free(c); + self.candles = null; + } + + fn freeDividends(self: *App) void { + if (self.dividends) |d| self.allocator.free(d); + self.dividends = null; + } + + fn freeEarnings(self: *App) void { + if (self.earnings_data) |e| self.allocator.free(e); + self.earnings_data = null; + } + + fn freeOptions(self: *App) void { + if (self.options_data) |chains| { + for (chains) |chain| { + self.allocator.free(chain.calls); + self.allocator.free(chain.puts); + self.allocator.free(chain.underlying_symbol); + } + self.allocator.free(chains); + } + self.options_data = null; + } + + fn freePortfolioSummary(self: *App) void { + if (self.portfolio_summary) |*s| s.deinit(self.allocator); + self.portfolio_summary = null; + } + + fn deinitData(self: *App) void { + self.freeCandles(); + self.freeDividends(); + self.freeEarnings(); + self.freeOptions(); + self.freePortfolioSummary(); + self.portfolio_rows.deinit(self.allocator); + self.options_rows.deinit(self.allocator); + } + + fn reloadFiles(self: *App) void { + // Reload portfolio + if (self.portfolio) |*pf| pf.deinit(); + self.portfolio = null; + if (self.portfolio_path) |path| { + const file_data = std.fs.cwd().readFileAlloc(self.allocator, path, 10 * 1024 * 1024) catch null; + if (file_data) |d| { + defer self.allocator.free(d); + if (zfin.cache.deserializePortfolio(self.allocator, d)) |pf| { + self.portfolio = pf; + } else |_| {} + } + } + + // Reload watchlist + freeWatchlist(self.allocator, self.watchlist); + self.watchlist = null; + if (self.watchlist_path) |path| { + self.watchlist = loadWatchlist(self.allocator, path); + } + + // Reset portfolio view state + self.portfolio_loaded = false; + self.freePortfolioSummary(); + self.expanded = [_]bool{false} ** 64; + self.cursor = 0; + self.scroll_offset = 0; + self.portfolio_rows.clearRetainingCapacity(); + } + + // ── Drawing ────────────────────────────────────────────────── + + fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface { + const self: *App = @ptrCast(@alignCast(ptr)); + const max_size = ctx.max.size(); + + if (max_size.height < 3) { + return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = &.{} }; + } + + self.visible_height = max_size.height -| 2; + + var children: std.ArrayList(vaxis.vxfw.SubSurface) = .empty; + + const tab_surface = try self.drawTabBar(ctx, max_size.width); + try children.append(ctx.arena, .{ .origin = .{ .row = 0, .col = 0 }, .surface = tab_surface }); + + const content_height = max_size.height - 2; + const content_surface = try self.drawContent(ctx, max_size.width, content_height); + try children.append(ctx.arena, .{ .origin = .{ .row = 1, .col = 0 }, .surface = content_surface }); + + const status_surface = try self.drawStatusBar(ctx, max_size.width); + try children.append(ctx.arena, .{ .origin = .{ .row = @intCast(max_size.height - 1), .col = 0 }, .surface = status_surface }); + + return .{ .size = max_size, .widget = self.widget(), .buffer = &.{}, .children = try children.toOwnedSlice(ctx.arena) }; + } + + fn drawTabBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface { + const th = self.theme; + const buf = try ctx.arena.alloc(vaxis.Cell, width); + const inactive_style = th.tabStyle(); + @memset(buf, .{ .char = .{ .grapheme = " " }, .style = inactive_style }); + + var col: usize = 0; + for (tabs) |t| { + const lbl = t.label(); + const is_active = t == self.active_tab; + const is_disabled = t == .earnings and self.earnings_disabled; + const tab_style: vaxis.Style = if (is_active) th.tabActiveStyle() else if (is_disabled) th.tabDisabledStyle() else inactive_style; + + for (lbl) |ch| { + if (col >= width) break; + buf[col] = .{ .char = .{ .grapheme = glyph(ch) }, .style = tab_style }; + col += 1; + } + } + + // Right-align the active symbol if set + if (self.symbol.len > 0) { + const is_selected = self.isSymbolSelected(); + const prefix: []const u8 = if (is_selected) " * " else " "; + const sym_label = try std.fmt.allocPrint(ctx.arena, "{s}{s} ", .{ prefix, self.symbol }); + if (width > sym_label.len + col) { + const sym_start = width - sym_label.len; + const sym_style: vaxis.Style = .{ + .fg = theme_mod.Theme.vcolor(if (is_selected) th.warning else th.info), + .bg = theme_mod.Theme.vcolor(th.tab_bg), + .bold = is_selected, + }; + for (0..sym_label.len) |i| { + buf[sym_start + i] = .{ .char = .{ .grapheme = glyph(sym_label[i]) }, .style = sym_style }; + } + } + } + + return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; + } + + fn isSymbolSelected(self: *App) bool { + // Symbol is "selected" if it matches a portfolio/watchlist row the user explicitly selected with 's' + if (self.active_tab != .portfolio) return false; + if (self.portfolio_rows.items.len == 0) return false; + if (self.cursor >= self.portfolio_rows.items.len) return false; + return std.mem.eql(u8, self.portfolio_rows.items[self.cursor].symbol, self.symbol); + } + + fn drawContent(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16, height: u16) !vaxis.vxfw.Surface { + const th = self.theme; + const content_style = th.contentStyle(); + const buf_size: usize = @as(usize, width) * height; + const buf = try ctx.arena.alloc(vaxis.Cell, buf_size); + @memset(buf, .{ .char = .{ .grapheme = " " }, .style = content_style }); + + if (self.mode == .help) { + try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildHelpStyledLines(ctx.arena)); + } else { + switch (self.active_tab) { + .portfolio => try self.drawPortfolioContent(ctx.arena, buf, width, height), + .quote => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildQuoteStyledLines(ctx.arena)), + .performance => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildPerfStyledLines(ctx.arena)), + .options => try self.drawOptionsContent(ctx.arena, buf, width, height), + .earnings => try self.drawStyledContent(ctx.arena, buf, width, height, try self.buildEarningsStyledLines(ctx.arena)), + } + } + + return .{ .size = .{ .width = width, .height = height }, .widget = self.widget(), .buffer = buf, .children = &.{} }; + } + + fn drawStyledContent(_: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void { + for (lines, 0..) |line, row| { + if (row >= height) break; + // Fill row with style bg + for (0..width) |ci| { + buf[row * width + ci] = .{ .char = .{ .grapheme = " " }, .style = line.style }; + } + for (0..@min(line.text.len, width)) |ci| { + var s = line.style; + // Apply alt_style for the gain/loss column range + if (line.alt_style) |alt| { + if (ci >= line.alt_start and ci < line.alt_end) s = alt; + } + buf[row * width + ci] = .{ .char = .{ .grapheme = glyph(line.text[ci]) }, .style = s }; + } + } + } + + fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface { + const t = self.theme; + const buf = try ctx.arena.alloc(vaxis.Cell, width); + + if (self.mode == .symbol_input) { + const prompt_style = t.inputStyle(); + @memset(buf, .{ .char = .{ .grapheme = " " }, .style = prompt_style }); + + const prompt = "Symbol: "; + for (0..@min(prompt.len, width)) |i| { + buf[i] = .{ .char = .{ .grapheme = glyph(prompt[i]) }, .style = prompt_style }; + } + const input = self.input_buf[0..self.input_len]; + for (0..@min(input.len, @as(usize, width) -| prompt.len)) |i| { + buf[prompt.len + i] = .{ .char = .{ .grapheme = glyph(input[i]) }, .style = prompt_style }; + } + const cursor_pos = prompt.len + self.input_len; + if (cursor_pos < width) { + var cursor_style = prompt_style; + cursor_style.blink = true; + buf[cursor_pos] = .{ .char = .{ .grapheme = "_" }, .style = cursor_style }; + } + const hint = " Enter=confirm Esc=cancel "; + if (width > hint.len + cursor_pos + 2) { + const hint_start = width - hint.len; + const hint_style = t.inputHintStyle(); + for (0..hint.len) |i| { + buf[hint_start + i] = .{ .char = .{ .grapheme = glyph(hint[i]) }, .style = hint_style }; + } + } + } else { + const status_style = t.statusStyle(); + @memset(buf, .{ .char = .{ .grapheme = " " }, .style = status_style }); + const msg = self.getStatus(); + for (0..@min(msg.len, width)) |i| { + buf[i] = .{ .char = .{ .grapheme = glyph(msg[i]) }, .style = status_style }; + } + } + + return .{ .size = .{ .width = width, .height = 1 }, .widget = self.widget(), .buffer = buf, .children = &.{} }; + } + + // ── Portfolio content ───────────────────────────────────────── + + fn drawPortfolioContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { + const th = self.theme; + + if (self.portfolio == null and self.watchlist == null) { + try self.drawWelcomeScreen(arena, buf, width, height); + return; + } + + var lines: std.ArrayList(StyledLine) = .empty; + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + if (self.portfolio_summary) |s| { + var val_buf: [24]u8 = undefined; + var cost_buf: [24]u8 = undefined; + var gl_buf: [24]u8 = undefined; + const val_str = fmtMoney(&val_buf, s.total_value); + const cost_str = fmtMoney(&cost_buf, s.total_cost); + const gl_abs = if (s.unrealized_pnl >= 0) s.unrealized_pnl else -s.unrealized_pnl; + const gl_str = fmtMoney(&gl_buf, gl_abs); + const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{ + val_str, cost_str, if (s.unrealized_pnl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0, + }); + const summary_style = if (s.unrealized_pnl >= 0) th.positiveStyle() else th.negativeStyle(); + try lines.append(arena, .{ .text = summary_text, .style = summary_style }); + + // "as of" date indicator + if (self.candle_last_date) |d| { + var asof_buf: [10]u8 = undefined; + const asof_text = try std.fmt.allocPrint(arena, " (as of close on {s})", .{d.format(&asof_buf)}); + try lines.append(arena, .{ .text = asof_text, .style = th.mutedStyle() }); + } + } else if (self.portfolio != null) { + try lines.append(arena, .{ .text = " No cached prices. Run 'zfin perf ' for each holding.", .style = th.mutedStyle() }); + } else { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + } + + // Empty line before header + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Column header (4-char prefix to match arrow(2)+star(2) in data rows) + const hdr = try std.fmt.allocPrint(arena, " {s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{ + "Symbol", "Shares", "Avg Cost", "Price", "Market Value", "Gain/Loss", "Weight", "Date", "Account", + }); + try lines.append(arena, .{ .text = hdr, .style = th.headerStyle() }); + + // Data rows + for (self.portfolio_rows.items, 0..) |row, ri| { + const is_cursor = ri == self.cursor; + const is_active_sym = std.mem.eql(u8, row.symbol, self.symbol); + switch (row.kind) { + .position => { + if (self.portfolio_summary) |s| { + if (row.pos_idx < s.allocations.len) { + const a = s.allocations[row.pos_idx]; + const is_multi = row.lot_count > 1; + const is_expanded = is_multi and row.pos_idx < self.expanded.len and self.expanded[row.pos_idx]; + const arrow: []const u8 = if (!is_multi) " " else if (is_expanded) "v " else "> "; + const star: []const u8 = if (is_active_sym) "* " else " "; + const pnl_pct = if (a.cost_basis > 0) (a.unrealized_pnl / a.cost_basis) * 100.0 else @as(f64, 0); + var gl_val_buf: [24]u8 = undefined; + const gl_abs = if (a.unrealized_pnl >= 0) a.unrealized_pnl else -a.unrealized_pnl; + const gl_money = fmtMoney(&gl_val_buf, gl_abs); + var pnl_buf: [20]u8 = undefined; + const pnl_str = if (a.unrealized_pnl >= 0) + std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?" + else + std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?"; + var mv_buf: [24]u8 = undefined; + const mv_str = fmtMoney(&mv_buf, a.market_value); + var cost_buf2: [24]u8 = undefined; + const cost_str = fmtMoney2(&cost_buf2, a.avg_cost); + var price_buf2: [24]u8 = undefined; + const price_str = fmtMoney2(&price_buf2, a.current_price); + + // Date + ST/LT: show for single-lot, blank for multi-lot + var pos_date_buf: [10]u8 = undefined; + const date_col: []const u8 = if (!is_multi) blk: { + if (self.portfolio) |pf| { + for (pf.lots) |lot| { + if (std.mem.eql(u8, lot.symbol, a.symbol)) { + const ds = lot.open_date.format(&pos_date_buf); + const indicator = capitalGainsIndicator(lot.open_date); + break :blk std.fmt.allocPrint(arena, "{s} {s}", .{ ds, indicator }) catch ds; + } + } + } + break :blk ""; + } else ""; + + const text = try std.fmt.allocPrint(arena, "{s}{s}{s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {d:>7.1}% {s:>13}", .{ + arrow, star, a.symbol, a.shares, cost_str, price_str, mv_str, pnl_str, a.weight * 100.0, date_col, + }); + + // base: neutral text for main cols, green/red only for gain/loss col + const base_style = if (is_cursor) th.selectStyle() else th.contentStyle(); + const gl_style = if (is_cursor) th.selectStyle() else if (pnl_pct >= 0) th.positiveStyle() else th.negativeStyle(); + + // The gain/loss column starts after market value + // prefix(4) + sym(6+1) + shares(8+1) + avgcost(10+1) + price(10+1) + mv(16+1) = 59 + try lines.append(arena, .{ + .text = text, + .style = base_style, + .alt_style = gl_style, + .alt_start = 59, + .alt_end = 59 + 14, + }); + } + } + }, + .lot => { + if (row.lot) |lot| { + var date_buf: [10]u8 = undefined; + const date_str = lot.open_date.format(&date_buf); + + // Compute lot gain/loss if we have a price + var lot_gl_str: []const u8 = ""; + var lot_positive = true; + if (self.portfolio_summary) |s| { + if (row.pos_idx < s.allocations.len) { + const price = s.allocations[row.pos_idx].current_price; + const use_price = lot.close_price orelse price; + const gl = lot.shares * (use_price - lot.open_price); + lot_positive = gl >= 0; + var lot_gl_money_buf: [24]u8 = undefined; + const lot_gl_money = fmtMoney(&lot_gl_money_buf, if (gl >= 0) gl else -gl); + lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{ + if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), lot_gl_money, + }); + } + } + + var price_str2: [24]u8 = undefined; + const lot_price_str = fmtMoney2(&price_str2, lot.open_price); + const status_str: []const u8 = if (lot.isOpen()) "open" else "closed"; + const indicator = capitalGainsIndicator(lot.open_date); + const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator }); + const acct_col: []const u8 = lot.account orelse ""; + const text = try std.fmt.allocPrint(arena, " {s:<6} {d:>8.1} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13} {s}", .{ + status_str, lot.shares, lot_price_str, "", "", lot_gl_str, "", lot_date_col, acct_col, + }); + const base_style = if (is_cursor) th.selectStyle() else th.mutedStyle(); + const gl_col_style = if (is_cursor) th.selectStyle() else if (lot_positive) th.positiveStyle() else th.negativeStyle(); + try lines.append(arena, .{ + .text = text, + .style = base_style, + .alt_style = gl_col_style, + .alt_start = 59, + .alt_end = 59 + 14, + }); + } + }, + .watchlist => { + var price_str3: [16]u8 = undefined; + const ps = if (self.svc.getCachedCandles(row.symbol)) |candles_slice| blk: { + defer self.allocator.free(candles_slice); + if (candles_slice.len > 0) + break :blk fmtMoney2(&price_str3, candles_slice[candles_slice.len - 1].close) + else + break :blk @as([]const u8, "--"); + } else "--"; + const star2: []const u8 = if (is_active_sym) "* " else " "; + const text = try std.fmt.allocPrint(arena, " {s}{s:<6} {s:>8} {s:>10} {s:>10} {s:>16} {s:>14} {s:>8} {s:>13}", .{ + star2, row.symbol, "--", "--", ps, "--", "--", "watch", "", + }); + const row_style = if (is_cursor) th.selectStyle() else th.contentStyle(); + try lines.append(arena, .{ .text = text, .style = row_style }); + }, + } + } + + // Render + const start = @min(self.scroll_offset, if (lines.items.len > 0) lines.items.len - 1 else 0); + try self.drawStyledContent(arena, buf, width, height, lines.items[start..]); + } + + fn drawWelcomeScreen(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { + const th = self.theme; + const welcome_lines = [_]StyledLine{ + .{ .text = "", .style = th.contentStyle() }, + .{ .text = " zfin", .style = th.headerStyle() }, + .{ .text = "", .style = th.contentStyle() }, + .{ .text = " No portfolio loaded.", .style = th.mutedStyle() }, + .{ .text = "", .style = th.contentStyle() }, + .{ .text = " Getting started:", .style = th.contentStyle() }, + .{ .text = " / Enter a stock symbol (e.g. AAPL, VTI)", .style = th.contentStyle() }, + .{ .text = "", .style = th.contentStyle() }, + .{ .text = " Portfolio mode:", .style = th.contentStyle() }, + .{ .text = " zfin -p portfolio.srf Load a portfolio file", .style = th.mutedStyle() }, + .{ .text = try std.fmt.allocPrint(arena, " portfolio.srf Auto-loaded from cwd if present", .{}), .style = th.mutedStyle() }, + .{ .text = "", .style = th.contentStyle() }, + .{ .text = " Navigation:", .style = th.contentStyle() }, + .{ .text = " h / l Previous / next tab", .style = th.mutedStyle() }, + .{ .text = " j / k Select next / prev item", .style = th.mutedStyle() }, + .{ .text = " Enter Expand position lots", .style = th.mutedStyle() }, + .{ .text = " s Select symbol for other tabs", .style = th.mutedStyle() }, + .{ .text = " 1-5 Jump to tab", .style = th.mutedStyle() }, + .{ .text = " ? Full help", .style = th.mutedStyle() }, + .{ .text = " q Quit", .style = th.mutedStyle() }, + .{ .text = "", .style = th.contentStyle() }, + .{ .text = " Sample portfolio.srf:", .style = th.contentStyle() }, + .{ .text = " symbol::VTI,shares::100,open_date::2024-01-15,open_price::220.50", .style = th.dimStyle() }, + .{ .text = " symbol::AAPL,shares::50,open_date::2024-03-01,open_price::170.00", .style = th.dimStyle() }, + }; + try self.drawStyledContent(arena, buf, width, height, &welcome_lines); + } + + // ── Options content (with cursor/scroll) ───────────────────── + + fn drawOptionsContent(self: *App, arena: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16) !void { + const styled_lines = try self.buildOptionsStyledLines(arena); + const start = @min(self.scroll_offset, if (styled_lines.len > 0) styled_lines.len - 1 else 0); + try self.drawStyledContent(arena, buf, width, height, styled_lines[start..]); + } + + // ── Quote tab ──────────────────────────────────────────────── + + fn buildQuoteStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = self.theme; + var lines: std.ArrayList(StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + if (self.symbol.len == 0) { + try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + var ago_buf: [16]u8 = undefined; + if (self.quote != null and self.quote_timestamp > 0) { + const ago_str = fmtTimeAgo(&ago_buf, self.quote_timestamp); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ self.symbol, ago_str }), .style = th.headerStyle() }); + } else if (self.candle_last_date) |d| { + var cdate_buf: [10]u8 = undefined; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ self.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{self.symbol}), .style = th.headerStyle() }); + } + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + if (self.candles == null and !self.perf_loaded) self.loadPerfData(); + + // Use stored real-time quote if available (fetched on manual refresh) + const quote_data = self.quote; + + const c = self.candles orelse { + if (quote_data) |q| { + // No candle data but have a quote - show it + var qclose_buf: [24]u8 = undefined; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmtMoney(&qclose_buf, q.close)}), .style = th.contentStyle() }); + if (q.change >= 0) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ q.change, q.percent_change }), .style = th.positiveStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -q.change, q.percent_change }), .style = th.negativeStyle() }); + } + return lines.toOwnedSlice(arena); + } + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + }; + if (c.len == 0) { + try lines.append(arena, .{ .text = " No candle data.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + // Use real-time quote price if available, otherwise latest candle + const price = if (quote_data) |q| q.close else c[c.len - 1].close; + const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0); + const latest = c[c.len - 1]; + var date_buf: [10]u8 = undefined; + var close_buf: [24]u8 = undefined; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmtMoney(&close_buf, price)}), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volume: {d}", .{if (quote_data) |q| q.volume else latest.volume}), .style = th.mutedStyle() }); + + if (prev_close > 0) { + const change = price - prev_close; + const pct = (change / prev_close) * 100.0; + const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle(); + if (change >= 0) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: +${d:.2} (+{d:.2}%)", .{ change, pct }), .style = change_style }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: -${d:.2} ({d:.2}%)", .{ -change, pct }), .style = change_style }); + } + } + + // Braille chart of recent 60 trading days + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + const chart_days: usize = @min(c.len, 60); + const chart_data = c[c.len - chart_days ..]; + try buildBrailleChart(arena, &lines, chart_data, th); + + // Recent history table + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() }); + + const start_idx = if (c.len > 20) c.len - 20 else 0; + for (c[start_idx..]) |candle| { + var db: [10]u8 = undefined; + const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle(); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {d:>12}", .{ + candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, candle.volume, + }), .style = day_change }); + } + + return lines.toOwnedSlice(arena); + } + + // ── Performance tab ────────────────────────────────────────── + + fn buildPerfStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = self.theme; + var lines: std.ArrayList(StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + if (self.symbol.len == 0) { + try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + if (self.candle_last_date) |d| { + var pdate_buf: [10]u8 = undefined; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ self.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{self.symbol}), .style = th.headerStyle() }); + } + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + if (self.candles == null and !self.perf_loaded) self.loadPerfData(); + + if (self.trailing_price == null) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{self.symbol}), .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + if (self.candle_count > 0) { + if (self.candle_first_date) |first| { + if (self.candle_last_date) |last| { + var fb: [10]u8 = undefined; + var lb: [10]u8 = undefined; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{ + self.candle_count, first.format(&fb), last.format(&lb), + }), .style = th.mutedStyle() }); + } + } + } + + if (self.candles) |cc| { + if (cc.len > 0) { + var close_buf: [24]u8 = undefined; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmtMoney(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() }); + } + } + + const has_total = self.trailing_total != null; + + if (self.candle_last_date) |last| { + var db: [10]u8 = undefined; + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {s}:", .{last.format(&db)}), .style = th.headerStyle() }); + } + try appendStyledReturnsTable(arena, &lines, self.trailing_price.?, if (has_total) self.trailing_total else null, th); + + { + const today = todayDate(); + const month_end = today.lastDayOfPriorMonth(); + var db: [10]u8 = undefined; + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({s}):", .{month_end.format(&db)}), .style = th.headerStyle() }); + } + if (self.trailing_me_price) |me_price| { + try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) self.trailing_me_total else null, th); + } + + if (!has_total) { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() }); + } + + if (self.risk_metrics) |rm| { + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Risk Metrics:", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Volatility (ann.): {d:.1}%", .{rm.volatility * 100.0}), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Sharpe Ratio: {d:.2}", .{rm.sharpe}), .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Max Drawdown: {d:.1}%", .{rm.max_drawdown * 100.0}), .style = th.negativeStyle() }); + if (rm.drawdown_trough) |dt| { + var db2: [10]u8 = undefined; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " DD Trough: {s}", .{dt.format(&db2)}), .style = th.mutedStyle() }); + } + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Sample Size: {d} days", .{rm.sample_size}), .style = th.mutedStyle() }); + } + + return lines.toOwnedSlice(arena); + } + + fn appendStyledReturnsTable( + arena: std.mem.Allocator, + lines: *std.ArrayList(StyledLine), + price: zfin.performance.TrailingReturns, + total: ?zfin.performance.TrailingReturns, + th: theme_mod.Theme, + ) !void { + const has_total = total != null; + if (has_total) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}", .{ "", "Price Only", "Total Return" }), .style = th.mutedStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{ "", "Price Only" }), .style = th.mutedStyle() }); + } + + const price_arr = [4]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year }; + const labels = [4][]const u8{ "1-Year Return:", "3-Year Return:", "5-Year Return:", "10-Year Return:" }; + const annualize = [4]bool{ false, true, true, true }; + + for (0..4) |i| { + var price_str: [16]u8 = undefined; + var price_val: f64 = 0; + const ps = if (price_arr[i]) |r| blk: { + const val = if (annualize[i]) r.annualized_return orelse r.total_return else r.total_return; + price_val = val; + break :blk zfin.performance.formatReturn(&price_str, val); + } else "N/A"; + + const row_style = if (price_arr[i] != null) + (if (price_val >= 0) th.positiveStyle() else th.negativeStyle()) + else + th.mutedStyle(); + + if (has_total) { + const t = total.?; + const total_arr = [4]?zfin.performance.PerformanceResult{ t.one_year, t.three_year, t.five_year, t.ten_year }; + var total_str: [16]u8 = undefined; + const ts = if (total_arr[i]) |r| blk: { + const val = if (annualize[i]) r.annualized_return orelse r.total_return else r.total_return; + break :blk zfin.performance.formatReturn(&total_str, val); + } else "N/A"; + + const suffix: []const u8 = if (annualize[i]) " ann." else ""; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], ps, ts, suffix }), .style = row_style }); + } else { + const suffix: []const u8 = if (annualize[i]) " ann." else ""; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], ps, suffix }), .style = row_style }); + } + } + } + + // ── Options tab ────────────────────────────────────────────── + + fn buildOptionsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = self.theme; + var lines: std.ArrayList(StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + if (self.symbol.len == 0) { + try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + const chains = self.options_data orelse { + try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + }; + + if (chains.len == 0) { + try lines.append(arena, .{ .text = " No options data found.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + var opt_ago_buf: [16]u8 = undefined; + const opt_ago = fmtTimeAgo(&opt_ago_buf, self.options_timestamp); + if (opt_ago.len > 0) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ self.symbol, opt_ago }), .style = th.headerStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{self.symbol}), .style = th.headerStyle() }); + } + + if (chains[0].underlying_price) |price| { + var price_buf: [24]u8 = undefined; + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmtMoney(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() }); + } + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + // Flat list of options rows with inline expand/collapse + for (self.options_rows.items, 0..) |row, ri| { + const is_cursor = ri == self.options_cursor; + switch (row.kind) { + .expiration => { + if (row.exp_idx < chains.len) { + const chain = chains[row.exp_idx]; + var db: [10]u8 = undefined; + const is_expanded = row.exp_idx < self.options_expanded.len and self.options_expanded[row.exp_idx]; + const is_monthly = isMonthlyExpiration(chain.expiration); + const arrow: []const u8 = if (is_expanded) "v " else "> "; + const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{ + arrow, + chain.expiration.format(&db), + chain.calls.len, + chain.puts.len, + }); + const style = if (is_cursor) th.selectStyle() else if (is_monthly) th.contentStyle() else th.mutedStyle(); + try lines.append(arena, .{ .text = text, .style = style }); + } + }, + .calls_header => { + const calls_collapsed = row.exp_idx < self.options_calls_collapsed.len and self.options_calls_collapsed[row.exp_idx]; + const arrow: []const u8 = if (calls_collapsed) " > " else " v "; + const style = if (is_cursor) th.selectStyle() else th.headerStyle(); + if (calls_collapsed) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}Calls (collapsed, Enter to expand)", .{arrow}), .style = style }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{ + arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV", + }), .style = style }); + } + }, + .puts_header => { + const puts_collapsed = row.exp_idx < self.options_puts_collapsed.len and self.options_puts_collapsed[row.exp_idx]; + const arrow: []const u8 = if (puts_collapsed) " > " else " v "; + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + const style = if (is_cursor) th.selectStyle() else th.headerStyle(); + if (puts_collapsed) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}Puts (collapsed, Enter to expand)", .{arrow}), .style = style }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Puts", .{ + arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV", + }), .style = style }); + } + }, + .call => { + if (row.contract) |cc| { + const atm_price = chains[0].underlying_price orelse 0; + const itm = cc.strike <= atm_price; + const prefix: []const u8 = if (itm) " |" else " "; + const text = try fmtContractLine(arena, prefix, cc); + const style = if (is_cursor) th.selectStyle() else th.contentStyle(); + try lines.append(arena, .{ .text = text, .style = style }); + } + }, + .put => { + if (row.contract) |p| { + const atm_price = chains[0].underlying_price orelse 0; + const itm = p.strike >= atm_price; + const prefix: []const u8 = if (itm) " |" else " "; + const text = try fmtContractLine(arena, prefix, p); + const style = if (is_cursor) th.selectStyle() else th.contentStyle(); + try lines.append(arena, .{ .text = text, .style = style }); + } + }, + } + } + + return lines.toOwnedSlice(arena); + } + + // ── Earnings tab ───────────────────────────────────────────── + + fn buildEarningsStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = self.theme; + var lines: std.ArrayList(StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + if (self.symbol.len == 0) { + try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + if (self.earnings_disabled) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{self.symbol}), .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + var earn_ago_buf: [16]u8 = undefined; + const earn_ago = fmtTimeAgo(&earn_ago_buf, self.earnings_timestamp); + if (earn_ago.len > 0) { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ self.symbol, earn_ago }), .style = th.headerStyle() }); + } else { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{self.symbol}), .style = th.headerStyle() }); + } + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + const ev = self.earnings_data orelse { + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{self.symbol}), .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + }; + if (ev.len == 0) { + try lines.append(arena, .{ .text = " No earnings events found.", .style = th.mutedStyle() }); + return lines.toOwnedSlice(arena); + } + + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{ + "Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", + }), .style = th.mutedStyle() }); + + for (ev) |e| { + var db: [10]u8 = undefined; + const date_str = e.date.format(&db); + + var q_buf: [4]u8 = undefined; + const q_str = if (e.quarter) |q| std.fmt.bufPrint(&q_buf, "Q{d}", .{q}) catch "--" else "--"; + + var est_buf: [12]u8 = undefined; + const est_str = if (e.estimate) |est| std.fmt.bufPrint(&est_buf, "${d:.2}", .{est}) catch "--" else "--"; + + var act_buf: [12]u8 = undefined; + const act_str = if (e.actual) |act| std.fmt.bufPrint(&act_buf, "${d:.2}", .{act}) catch "--" else "--"; + + var surp_buf: [12]u8 = undefined; + const surp_str = if (e.surpriseAmount()) |s| + (if (s >= 0) std.fmt.bufPrint(&surp_buf, "+${d:.4}", .{s}) catch "?" else std.fmt.bufPrint(&surp_buf, "-${d:.4}", .{-s}) catch "?") + else + @as([]const u8, "--"); + + var surp_pct_buf: [12]u8 = undefined; + const surp_pct_str = if (e.surprisePct()) |sp| + (if (sp >= 0) std.fmt.bufPrint(&surp_pct_buf, "+{d:.1}%", .{sp}) catch "?" else std.fmt.bufPrint(&surp_pct_buf, "{d:.1}%", .{sp}) catch "?") + else + @as([]const u8, "--"); + + const text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10}", .{ + date_str, q_str, est_str, act_str, surp_str, surp_pct_str, + }); + + // Color by surprise + const surprise_positive = if (e.surpriseAmount()) |s| s >= 0 else true; + const row_style = if (e.isFuture()) th.mutedStyle() else if (surprise_positive) th.positiveStyle() else th.negativeStyle(); + + try lines.append(arena, .{ .text = text, .style = row_style }); + } + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() }); + + return lines.toOwnedSlice(arena); + } + + // ── Help ───────────────────────────────────────────────────── + + fn buildHelpStyledLines(self: *App, arena: std.mem.Allocator) ![]const StyledLine { + const th = self.theme; + var lines: std.ArrayList(StyledLine) = .empty; + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " zfin TUI -- Keybindings", .style = th.headerStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + + const actions = comptime std.enums.values(keybinds.Action); + const action_labels = [_][]const u8{ + "Quit", "Refresh", "Previous tab", "Next tab", + "Tab 1", "Tab 2", "Tab 3", "Tab 4", + "Tab 5", "Scroll down", "Scroll up", "Scroll to top", + "Scroll to bottom", "Page down", "Page up", "Select next", + "Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)", + "This help", "Edit portfolio/watchlist", + "Toggle all calls (options)", "Toggle all puts (options)", + "Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM", + "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM", + "Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", + }; + + for (actions, 0..) |action, ai| { + var key_strs: [8][]const u8 = undefined; + var key_count: usize = 0; + for (self.keymap.bindings) |b| { + if (b.action == action and key_count < key_strs.len) { + var key_buf: [32]u8 = undefined; + if (keybinds.formatKeyCombo(b.key, &key_buf)) |s| { + key_strs[key_count] = try arena.dupe(u8, s); + key_count += 1; + } + } + } + if (key_count == 0) continue; + + var combined_buf: [128]u8 = undefined; + var pos: usize = 0; + for (0..key_count) |ki| { + if (ki > 0) { + if (pos + 2 <= combined_buf.len) { + combined_buf[pos] = ','; + combined_buf[pos + 1] = ' '; + pos += 2; + } + } + const ks = key_strs[ki]; + if (pos + ks.len <= combined_buf.len) { + @memcpy(combined_buf[pos..][0..ks.len], ks); + pos += ks.len; + } + } + + const label_text = if (ai < action_labels.len) action_labels[ai] else @tagName(action); + try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s}", .{ combined_buf[0..pos], label_text }), .style = th.contentStyle() }); + } + + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Mouse: click tabs, scroll wheel, click rows", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = " Config: ~/.config/zfin/keys.srf | theme.srf", .style = th.mutedStyle() }); + try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); + try lines.append(arena, .{ .text = " Press any key to close.", .style = th.dimStyle() }); + + return lines.toOwnedSlice(arena); + } + + // ── Tab navigation ─────────────────────────────────────────── + + fn nextTab(self: *App) void { + const idx = @intFromEnum(self.active_tab); + var next_idx = if (idx + 1 < tabs.len) idx + 1 else 0; + if (tabs[next_idx] == .earnings and self.earnings_disabled) + next_idx = if (next_idx + 1 < tabs.len) next_idx + 1 else 0; + self.active_tab = tabs[next_idx]; + } + + fn prevTab(self: *App) void { + const idx = @intFromEnum(self.active_tab); + var prev_idx = if (idx > 0) idx - 1 else tabs.len - 1; + if (tabs[prev_idx] == .earnings and self.earnings_disabled) + prev_idx = if (prev_idx > 0) prev_idx - 1 else tabs.len - 1; + self.active_tab = tabs[prev_idx]; + } +}; + +// ── Utility functions ──────────────────────────────────────── + +fn todayDate() zfin.Date { + const ts = std.time.timestamp(); + const days: i32 = @intCast(@divFloor(ts, 86400)); + return .{ .days = days }; +} + +/// Format a dollar amount with commas and 2 decimals: $1,234.56 +fn fmtMoney(buf: []u8, amount: f64) []const u8 { + const cents = @as(i64, @intFromFloat(@round(amount * 100.0))); + const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents)); + const dollars = abs_cents / 100; + const rem = abs_cents % 100; + + // Build digits from right to left + var tmp: [24]u8 = undefined; + var pos: usize = tmp.len; + + // Cents + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(rem % 10)); + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(rem / 10)); + pos -= 1; + tmp[pos] = '.'; + + // Dollars with commas + var d = dollars; + var digit_count: usize = 0; + if (d == 0) { + pos -= 1; + tmp[pos] = '0'; + } else { + while (d > 0) { + if (digit_count > 0 and digit_count % 3 == 0) { + pos -= 1; + tmp[pos] = ','; + } + pos -= 1; + tmp[pos] = '0' + @as(u8, @intCast(d % 10)); + d /= 10; + digit_count += 1; + } + } + pos -= 1; + tmp[pos] = '$'; + + const len = tmp.len - pos; + if (len > buf.len) return "$?"; + @memcpy(buf[0..len], tmp[pos..]); + return buf[0..len]; +} + +/// Format price with 2 decimals (no commas, for per-share prices) +fn fmtMoney2(buf: []u8, amount: f64) []const u8 { + return std.fmt.bufPrint(buf, "${d:.2}", .{amount}) catch "$?"; +} + +/// Format a unix timestamp as relative time ("just now", "5m ago", "2h ago", "3d ago"). +fn fmtTimeAgo(buf: []u8, timestamp: i64) []const u8 { + if (timestamp == 0) return ""; + const now = std.time.timestamp(); + const delta = now - timestamp; + if (delta < 0) return "just now"; + if (delta < 60) return "just now"; + if (delta < 3600) { + return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divFloor(delta, 60)))}) catch "?"; + } + if (delta < 86400) { + return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, 3600)))}) catch "?"; + } + return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, 86400)))}) catch "?"; +} + +/// Check if an expiration date is a standard monthly (3rd Friday of the month) +fn isMonthlyExpiration(date: zfin.Date) bool { + const dow = date.dayOfWeek(); // 0=Mon..4=Fri + if (dow != 4) return false; // Must be Friday + const d = date.day(); + return d >= 15 and d <= 21; // 3rd Friday is between 15th and 21st +} + +/// Return "LT" if held > 1 year from open_date to today, "ST" otherwise. +fn capitalGainsIndicator(open_date: zfin.Date) []const u8 { + const today = todayDate(); + // Long-term if held more than 365 days + return if (today.days - open_date.days > 365) "LT" else "ST"; +} + +/// Sort lots: open lots first (date descending), closed lots last (date descending). +fn lotSortFn(_: void, a: zfin.Lot, b: zfin.Lot) bool { + const a_open = a.isOpen(); + const b_open = b.isOpen(); + if (a_open and !b_open) return true; // open before closed + if (!a_open and b_open) return false; + return a.open_date.days > b.open_date.days; // newest first +} + +/// Filter options contracts to +/- N strikes from ATM +fn filterNearMoney(contracts: []const zfin.OptionContract, atm: f64, n: usize) []const zfin.OptionContract { + if (atm <= 0 or contracts.len == 0) return contracts; + + // Find the ATM index + var best_idx: usize = 0; + var best_dist: f64 = @abs(contracts[0].strike - atm); + for (contracts, 0..) |c, i| { + const dist = @abs(c.strike - atm); + if (dist < best_dist) { + best_dist = dist; + best_idx = i; + } + } + + const start = if (best_idx >= n) best_idx - n else 0; + const end = @min(best_idx + n + 1, contracts.len); + return contracts[start..end]; +} + +fn fmtContractLine(arena: std.mem.Allocator, prefix: []const u8, c: zfin.OptionContract) ![]const u8 { + var last_buf: [12]u8 = undefined; + const last_str = if (c.last_price) |p| std.fmt.bufPrint(&last_buf, "{d:>10.2}", .{p}) catch "--" else "--"; + var bid_buf: [12]u8 = undefined; + const bid_str = if (c.bid) |b| std.fmt.bufPrint(&bid_buf, "{d:>10.2}", .{b}) catch "--" else "--"; + var ask_buf: [12]u8 = undefined; + const ask_str = if (c.ask) |a| std.fmt.bufPrint(&ask_buf, "{d:>10.2}", .{a}) catch "--" else "--"; + var vol_buf: [12]u8 = undefined; + const vol_str = if (c.volume) |v| std.fmt.bufPrint(&vol_buf, "{d:>10}", .{v}) catch "--" else "--"; + var oi_buf: [10]u8 = undefined; + const oi_str = if (c.open_interest) |oi| std.fmt.bufPrint(&oi_buf, "{d:>8}", .{oi}) catch "--" else "--"; + var iv_buf: [10]u8 = undefined; + const iv_str = if (c.implied_volatility) |iv| std.fmt.bufPrint(&iv_buf, "{d:>6.1}%", .{iv * 100.0}) catch "--" else "--"; + + return std.fmt.allocPrint(arena, "{s}{d:>10.2} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}", .{ + prefix, c.strike, last_str, bid_str, ask_str, vol_str, oi_str, iv_str, + }); +} + +/// Build a braille sparkline chart from candle close prices. +/// Uses Unicode braille characters (U+2800..U+28FF) for 2x4 dot matrix per character. +fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme_mod.Theme) !void { + if (data.len < 2) return; + + const chart_width: usize = 60; + + // Find min/max close prices + var min_price: f64 = data[0].close; + var max_price: f64 = data[0].close; + for (data) |d| { + if (d.close < min_price) min_price = d.close; + if (d.close > max_price) max_price = d.close; + } + if (max_price == min_price) max_price = min_price + 1.0; + const price_range = max_price - min_price; + + // Price labels + var max_buf: [16]u8 = undefined; + var min_buf: [16]u8 = undefined; + const max_label = std.fmt.bufPrint(&max_buf, "${d:.0}", .{max_price}) catch ""; + const min_label = std.fmt.bufPrint(&min_buf, "${d:.0}", .{min_price}) catch ""; + + // ASCII block chart (compatible with glyph() which is ASCII-only) + const chart_height: usize = 10; + for (0..chart_height) |row| { + var line_chars: [72]u8 = undefined; + var lpos: usize = 0; + line_chars[0] = ' '; + line_chars[1] = ' '; + lpos = 2; + + for (0..chart_width) |col| { + // Map column to data index + const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(chart_width - 1)); + const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1); + const close = data[data_idx].close; + + // Map price to row (inverted) + const norm = (close - min_price) / price_range; + const fill_row: usize = @intFromFloat((1.0 - norm) * @as(f64, @floatFromInt(chart_height - 1))); + + if (row == fill_row) { + if (lpos < line_chars.len) { + line_chars[lpos] = '#'; + lpos += 1; + } + } else if (row > fill_row) { + if (lpos < line_chars.len) { + line_chars[lpos] = ':'; + lpos += 1; + } + } else { + if (lpos < line_chars.len) { + line_chars[lpos] = ' '; + lpos += 1; + } + } + } + + // Right label + if (row == 0) { + const lbl = try std.fmt.allocPrint(arena, " {s}", .{max_label}); + for (lbl) |ch| { + if (lpos < line_chars.len) { + line_chars[lpos] = ch; + lpos += 1; + } + } + } else if (row == chart_height - 1) { + const lbl = try std.fmt.allocPrint(arena, " {s}", .{min_label}); + for (lbl) |ch| { + if (lpos < line_chars.len) { + line_chars[lpos] = ch; + lpos += 1; + } + } + } + + const chart_style: vaxis.Style = .{ .fg = theme_mod.Theme.vcolor(th.accent), .bg = theme_mod.Theme.vcolor(th.bg) }; + try lines.append(arena, .{ .text = try arena.dupe(u8, line_chars[0..lpos]), .style = chart_style }); + } +} + +/// Load a watchlist from an SRF file. +fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 { + const file_data = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch return null; + defer allocator.free(file_data); + + var syms: std.ArrayList([]const u8) = .empty; + var file_lines = std.mem.splitScalar(u8, file_data, '\n'); + while (file_lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); + if (trimmed.len == 0 or trimmed[0] == '#') continue; + if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| { + const rest = trimmed[idx + "symbol::".len ..]; + const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len; + const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace); + if (sym.len > 0 and sym.len <= 10) { + const duped = allocator.dupe(u8, sym) catch continue; + syms.append(allocator, duped) catch { + allocator.free(duped); + continue; + }; + } + } + } + if (syms.items.len == 0) { + syms.deinit(allocator); + return null; + } + return syms.toOwnedSlice(allocator) catch null; +} + +fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void { + if (watchlist) |wl| { + for (wl) |sym| allocator.free(sym); + allocator.free(wl); + } +} + +/// Entry point for the interactive TUI. +pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []const u8) !void { + var portfolio_path: ?[]const u8 = null; + var watchlist_path: ?[]const u8 = null; + var symbol: []const u8 = ""; + var has_explicit_symbol = false; + var skip_watchlist = false; + var i: usize = 2; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--default-keys")) { + try keybinds.printDefaults(); + return; + } else if (std.mem.eql(u8, args[i], "--default-theme")) { + try theme_mod.printDefaults(); + return; + } else if (std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) { + if (i + 1 < args.len) { + i += 1; + portfolio_path = args[i]; + } + } else if (std.mem.eql(u8, args[i], "--watchlist") or std.mem.eql(u8, args[i], "-w")) { + if (i + 1 < args.len) { + i += 1; + watchlist_path = args[i]; + } else { + watchlist_path = "watchlist.srf"; + } + } else if (std.mem.eql(u8, args[i], "--symbol") or std.mem.eql(u8, args[i], "-s")) { + if (i + 1 < args.len) { + i += 1; + symbol = args[i]; + has_explicit_symbol = true; + skip_watchlist = true; + } + } else if (args[i].len > 0 and args[i][0] != '-') { + symbol = args[i]; + has_explicit_symbol = true; + } + } + + if (portfolio_path == null and !has_explicit_symbol) { + if (std.fs.cwd().access("portfolio.srf", .{})) |_| { + portfolio_path = "portfolio.srf"; + } else |_| {} + } + + var keymap = blk: { + const home = std.posix.getenv("HOME") orelse break :blk keybinds.defaults(); + const keys_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "keys.srf" }) catch + break :blk keybinds.defaults(); + defer allocator.free(keys_path); + break :blk keybinds.loadFromFile(allocator, keys_path) orelse keybinds.defaults(); + }; + defer keymap.deinit(); + + const theme = blk: { + const home = std.posix.getenv("HOME") orelse break :blk theme_mod.default_theme; + const theme_path = std.fs.path.join(allocator, &.{ home, ".config", "zfin", "theme.srf" }) catch + break :blk theme_mod.default_theme; + defer allocator.free(theme_path); + break :blk theme_mod.loadFromFile(allocator, theme_path) orelse theme_mod.default_theme; + }; + + var svc = try allocator.create(zfin.DataService); + defer allocator.destroy(svc); + svc.* = zfin.DataService.init(allocator, config); + defer svc.deinit(); + + var app_inst = try allocator.create(App); + defer allocator.destroy(app_inst); + app_inst.* = .{ + .allocator = allocator, + .config = config, + .svc = svc, + .keymap = keymap, + .theme = theme, + .portfolio_path = portfolio_path, + .symbol = symbol, + .has_explicit_symbol = has_explicit_symbol, + }; + + if (portfolio_path) |path| { + const file_data = std.fs.cwd().readFileAlloc(allocator, path, 10 * 1024 * 1024) catch null; + if (file_data) |d| { + defer allocator.free(d); + if (zfin.cache.deserializePortfolio(allocator, d)) |pf| { + app_inst.portfolio = pf; + } else |_| {} + } + } + + if (!skip_watchlist) { + const wl_path = watchlist_path orelse blk: { + std.fs.cwd().access("watchlist.srf", .{}) catch break :blk null; + break :blk @as(?[]const u8, "watchlist.srf"); + }; + if (wl_path) |path| { + app_inst.watchlist = loadWatchlist(allocator, path); + app_inst.watchlist_path = path; + } + } + + if (has_explicit_symbol and symbol.len > 0) { + app_inst.active_tab = .quote; + } + + defer if (app_inst.portfolio) |*pf| pf.deinit(); + defer freeWatchlist(allocator, app_inst.watchlist); + defer app_inst.deinitData(); + + while (true) { + { + var vx_app = try vaxis.vxfw.App.init(allocator); + defer vx_app.deinit(); + try vx_app.run(app_inst.widget(), .{}); + } + // vx_app is fully torn down here (terminal restored to cooked mode) + + if (!app_inst.wants_edit) break; + app_inst.wants_edit = false; + + launchEditor(allocator, app_inst.portfolio_path, app_inst.watchlist_path); + app_inst.reloadFiles(); + app_inst.active_tab = .portfolio; + } +} + +/// Launch $EDITOR on the portfolio and/or watchlist files. +fn launchEditor(allocator: std.mem.Allocator, portfolio_path: ?[]const u8, watchlist_path: ?[]const u8) void { + const editor = std.posix.getenv("EDITOR") orelse std.posix.getenv("VISUAL") orelse "vi"; + + var argv_buf: [4][]const u8 = undefined; + var argc: usize = 0; + argv_buf[argc] = editor; + argc += 1; + if (portfolio_path) |p| { + argv_buf[argc] = p; + argc += 1; + } + if (watchlist_path) |p| { + argv_buf[argc] = p; + argc += 1; + } + const argv = argv_buf[0..argc]; + + var child = std.process.Child.init(argv, allocator); + + child.spawn() catch return; + _ = child.wait() catch {}; +} diff --git a/src/tui/theme.zig b/src/tui/theme.zig new file mode 100644 index 0000000..d1a4739 --- /dev/null +++ b/src/tui/theme.zig @@ -0,0 +1,308 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const srf = @import("srf"); + +pub const Color = [3]u8; + +pub const Theme = struct { + // Backgrounds + bg: Color, + bg_panel: Color, + bg_element: Color, + + // Tab bar + tab_bg: Color, + tab_fg: Color, + tab_active_bg: Color, + tab_active_fg: Color, + + // Content + text: Color, + text_muted: Color, + text_dim: Color, + + // Status bar + status_bg: Color, + status_fg: Color, + + // Input prompt + input_bg: Color, + input_fg: Color, + input_hint: Color, + + // Semantic + accent: Color, + positive: Color, + negative: Color, + warning: Color, + info: Color, + + // Selection / cursor highlight + select_bg: Color, + select_fg: Color, + + // Border + border: Color, + + pub fn vcolor(c: Color) vaxis.Cell.Color { + return .{ .rgb = c }; + } + + pub fn style(_: Theme, fg_color: Color, bg_color: Color) vaxis.Style { + return .{ .fg = vcolor(fg_color), .bg = vcolor(bg_color) }; + } + + pub fn contentStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.text), .bg = vcolor(self.bg) }; + } + + pub fn mutedStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.text_muted), .bg = vcolor(self.bg) }; + } + + pub fn dimStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.text_dim), .bg = vcolor(self.bg) }; + } + + pub fn statusStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.status_fg), .bg = vcolor(self.status_bg) }; + } + + pub fn tabStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.tab_fg), .bg = vcolor(self.tab_bg) }; + } + + pub fn tabActiveStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.tab_active_fg), .bg = vcolor(self.tab_active_bg), .bold = true }; + } + + pub fn tabDisabledStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.text_dim), .bg = vcolor(self.tab_bg) }; + } + + pub fn inputStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.input_fg), .bg = vcolor(self.input_bg), .bold = true }; + } + + pub fn inputHintStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.input_hint), .bg = vcolor(self.input_bg) }; + } + + pub fn selectStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.select_fg), .bg = vcolor(self.select_bg), .bold = true }; + } + + pub fn positiveStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.positive), .bg = vcolor(self.bg) }; + } + + pub fn negativeStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.negative), .bg = vcolor(self.bg) }; + } + + pub fn borderStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.border), .bg = vcolor(self.bg) }; + } + + pub fn headerStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.accent), .bg = vcolor(self.bg), .bold = true }; + } + + pub fn watchlistStyle(self: Theme) vaxis.Style { + return .{ .fg = vcolor(self.text_dim), .bg = vcolor(self.bg) }; + } +}; + +// Monokai-inspired dark theme, influenced by opencode color system. +// Backgrounds are near-black for transparent terminal compatibility. +// Accent colors draw from Monokai's iconic palette: orange, purple, green, pink, yellow, cyan. +pub const default_theme = Theme{ + .bg = .{ 0x0a, 0x0a, 0x0a }, // near-black (opencode darkStep1) + .bg_panel = .{ 0x14, 0x14, 0x14 }, // slightly lighter (opencode darkStep2) + .bg_element = .{ 0x1e, 0x1e, 0x1e }, // element bg (opencode darkStep3) + + .tab_bg = .{ 0x14, 0x14, 0x14 }, // panel bg + .tab_fg = .{ 0x80, 0x80, 0x80 }, // muted gray + .tab_active_bg = .{ 0xfa, 0xb2, 0x83 }, // warm orange (opencode primary/darkStep9) + .tab_active_fg = .{ 0x0a, 0x0a, 0x0a }, // dark on orange + + .text = .{ 0xee, 0xee, 0xee }, // bright text (opencode darkStep12) + .text_muted = .{ 0x80, 0x80, 0x80 }, // muted (opencode darkStep11) + .text_dim = .{ 0x48, 0x48, 0x48 }, // dim (opencode darkStep7) + + .status_bg = .{ 0x14, 0x14, 0x14 }, // panel bg + .status_fg = .{ 0x80, 0x80, 0x80 }, // muted gray + + .input_bg = .{ 0x28, 0x28, 0x28 }, // subtle element bg + .input_fg = .{ 0xfa, 0xb2, 0x83 }, // warm orange prompt + .input_hint = .{ 0x60, 0x60, 0x60 }, // dim hint + + .accent = .{ 0x9d, 0x7c, 0xd8 }, // purple (opencode darkAccent) + .positive = .{ 0x7f, 0xd8, 0x8f }, // green (opencode darkGreen) + .negative = .{ 0xe0, 0x6c, 0x75 }, // red (opencode darkRed) + .warning = .{ 0xe5, 0xc0, 0x7b }, // yellow (opencode darkYellow) + .info = .{ 0x56, 0xb6, 0xc2 }, // cyan (opencode darkCyan) + + .select_bg = .{ 0x32, 0x32, 0x32 }, // subtle highlight (opencode darkStep5) + .select_fg = .{ 0xff, 0xc0, 0x9f }, // bright orange (opencode darkStep10) + + .border = .{ 0x3c, 0x3c, 0x3c }, // subtle border (opencode darkStep6) +}; + +// ── SRF serialization ──────────────────────────────────────── + +const field_names = [_]struct { name: []const u8, offset: usize }{ + .{ .name = "bg", .offset = @offsetOf(Theme, "bg") }, + .{ .name = "bg_panel", .offset = @offsetOf(Theme, "bg_panel") }, + .{ .name = "bg_element", .offset = @offsetOf(Theme, "bg_element") }, + .{ .name = "tab_bg", .offset = @offsetOf(Theme, "tab_bg") }, + .{ .name = "tab_fg", .offset = @offsetOf(Theme, "tab_fg") }, + .{ .name = "tab_active_bg", .offset = @offsetOf(Theme, "tab_active_bg") }, + .{ .name = "tab_active_fg", .offset = @offsetOf(Theme, "tab_active_fg") }, + .{ .name = "text", .offset = @offsetOf(Theme, "text") }, + .{ .name = "text_muted", .offset = @offsetOf(Theme, "text_muted") }, + .{ .name = "text_dim", .offset = @offsetOf(Theme, "text_dim") }, + .{ .name = "status_bg", .offset = @offsetOf(Theme, "status_bg") }, + .{ .name = "status_fg", .offset = @offsetOf(Theme, "status_fg") }, + .{ .name = "input_bg", .offset = @offsetOf(Theme, "input_bg") }, + .{ .name = "input_fg", .offset = @offsetOf(Theme, "input_fg") }, + .{ .name = "input_hint", .offset = @offsetOf(Theme, "input_hint") }, + .{ .name = "accent", .offset = @offsetOf(Theme, "accent") }, + .{ .name = "positive", .offset = @offsetOf(Theme, "positive") }, + .{ .name = "negative", .offset = @offsetOf(Theme, "negative") }, + .{ .name = "warning", .offset = @offsetOf(Theme, "warning") }, + .{ .name = "info", .offset = @offsetOf(Theme, "info") }, + .{ .name = "select_bg", .offset = @offsetOf(Theme, "select_bg") }, + .{ .name = "select_fg", .offset = @offsetOf(Theme, "select_fg") }, + .{ .name = "border", .offset = @offsetOf(Theme, "border") }, +}; + +fn colorPtr(theme: *Theme, offset: usize) *Color { + const bytes: [*]u8 = @ptrCast(theme); + return @ptrCast(@alignCast(bytes + offset)); +} + +fn colorPtrConst(theme: *const Theme, offset: usize) *const Color { + const bytes: [*]const u8 = @ptrCast(theme); + return @ptrCast(@alignCast(bytes + offset)); +} + +fn formatHex(c: Color) [7]u8 { + var buf: [7]u8 = undefined; + _ = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ c[0], c[1], c[2] }) catch {}; + return buf; +} + +fn parseHex(s: []const u8) ?Color { + const hex = if (s.len > 0 and s[0] == '#') s[1..] else s; + if (hex.len != 6) return null; + const r = std.fmt.parseUnsigned(u8, hex[0..2], 16) catch return null; + const g = std.fmt.parseUnsigned(u8, hex[2..4], 16) catch return null; + const b = std.fmt.parseUnsigned(u8, hex[4..6], 16) catch return null; + return .{ r, g, b }; +} + +pub fn printDefaults() !void { + var buf: [4096]u8 = undefined; + var writer = std.fs.File.stdout().writer(&buf); + const out = &writer.interface; + + try out.writeAll("#!srfv1\n"); + try out.writeAll("# zfin TUI theme\n"); + try out.writeAll("# This file is the sole source of colors when present.\n"); + try out.writeAll("# If removed, built-in defaults (monokai/opencode) are used.\n"); + try out.writeAll("# Regenerate: zfin interactive --default-theme > ~/.config/zfin/theme.srf\n"); + try out.writeAll("#\n"); + try out.writeAll("# All values are hex RGB: #rrggbb\n"); + + for (field_names) |f| { + const c = colorPtrConst(&default_theme, f.offset); + const hex = formatHex(c.*); + try out.print("{s}::{s}\n", .{ f.name, hex }); + } + + try out.flush(); +} + +pub fn loadFromFile(allocator: std.mem.Allocator, path: []const u8) ?Theme { + const data = std.fs.cwd().readFileAlloc(allocator, path, 64 * 1024) catch return null; + defer allocator.free(data); + return loadFromData(data); +} + +pub fn loadFromData(data: []const u8) ?Theme { + // Use a stack allocator for parsing -- we don't need to keep parsed data + var arena_buf: [32 * 1024]u8 = undefined; + var fba = std.heap.FixedBufferAllocator.init(&arena_buf); + const alloc = fba.allocator(); + + var reader = std.Io.Reader.fixed(data); + const parsed = srf.parse(&reader, alloc, .{ .alloc_strings = false }) catch return null; + _ = &parsed; // don't deinit, fba owns everything + + var theme = default_theme; + + for (parsed.records.items) |record| { + for (record.fields) |field| { + if (field.value) |v| { + const str = switch (v) { + .string => |s| s, + else => continue, + }; + const color = parseHex(str) orelse continue; + for (field_names) |f| { + if (std.mem.eql(u8, field.key, f.name)) { + colorPtr(&theme, f.offset).* = color; + break; + } + } + } + } + } + + return theme; +} + +// ── Tests ──────────────────────────────────────────────────── + +test "parseHex" { + const c = parseHex("#f8f8f2").?; + try std.testing.expectEqual(@as(u8, 0xf8), c[0]); + try std.testing.expectEqual(@as(u8, 0xf8), c[1]); + try std.testing.expectEqual(@as(u8, 0xf2), c[2]); +} + +test "parseHex no hash" { + const c = parseHex("272822").?; + try std.testing.expectEqual(@as(u8, 0x27), c[0]); +} + +test "formatHex roundtrip" { + const c = Color{ 0xae, 0x81, 0xff }; + const hex = formatHex(c); + const parsed = parseHex(&hex).?; + try std.testing.expectEqual(c[0], parsed[0]); + try std.testing.expectEqual(c[1], parsed[1]); + try std.testing.expectEqual(c[2], parsed[2]); +} + +test "loadFromData" { + const data = + \\#!srfv1 + \\bg::#ff0000 + \\text::#00ff00 + ; + const t = loadFromData(data).?; + try std.testing.expectEqual(@as(u8, 0xff), t.bg[0]); + try std.testing.expectEqual(@as(u8, 0x00), t.bg[1]); + try std.testing.expectEqual(@as(u8, 0x00), t.text[0]); + try std.testing.expectEqual(@as(u8, 0xff), t.text[1]); +} + +test "default theme has valid colors" { + const t = default_theme; + // Background should be dark + try std.testing.expect(t.bg[0] < 0x20); + // Text should be bright + try std.testing.expect(t.text[0] > 0xc0); +}