diff --git a/.gitignore b/.gitignore index 79aa481..179205e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ coverage/ .env *.srf !metadata.srf +scripts/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d5120a2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,152 @@ +# AGENTS.md + +## Commands + +```bash +zig build # build the zfin binary (output: zig-out/bin/zfin) +zig build test # run all tests (single binary, discovers all tests via refAllDeclsRecursive) +zig build run -- # build and run CLI +zig build docs # generate library documentation +zig build coverage -Dcoverage-threshold=40 # run tests with kcov coverage (Linux only) +``` + +**Tooling** (managed via `.mise.toml`): +- Zig 0.15.2 (minimum) +- ZLS 0.15.1 +- zlint 0.7.9 + +**Linting**: `zlint --deny-warnings --fix` (runs via pre-commit on staged `.zig` files). + +**Formatting**: `zig fmt` (enforced by pre-commit). Always run before committing. + +**Pre-commit hooks** run in order: trailing-whitespace, end-of-file-fixer, zig-fmt, zlint, zig-build, then tests with coverage. + +## Architecture + +Single binary (CLI + TUI) built from `src/main.zig`. No separate library binary for internal use — the library module (`src/root.zig`) exists only for downstream consumers and documentation generation. + +### Data flow + +``` +User input → main.zig (CLI dispatch) or tui.zig (TUI event loop) + → commands/*.zig (CLI) or tui/*.zig (TUI tab renderers) + → DataService (service.zig) — sole data access layer + → Cache check (cache/store.zig, SRF files in ~/.cache/zfin/{SYMBOL}/) + → Server sync (optional ZFIN_SERVER, parallel HTTP) + → Provider fetch (providers/*.zig, rate-limited HTTP) + → Cache write + → analytics/*.zig (performance, risk, valuation calculations) + → format.zig (shared formatters, braille charts) + → views/*.zig (view models — renderer-agnostic display data) + → stdout (CLI via buffered Writer) or vaxis (TUI terminal rendering) +``` + +### Key design decisions + +- **Internal imports use file paths, not module names.** Only external dependencies (`srf`, `vaxis`, `z2d`) use `@import("name")`. Internal code uses relative paths like `@import("models/date.zig")`. This is intentional — it lets `refAllDeclsRecursive` in the test binary discover all tests across the entire source tree. + +- **DataService is the sole data source.** Both CLI and TUI go through `DataService` for all fetched data. Never call provider APIs directly from commands or TUI tabs. + +- **Providers are lazily initialized.** `DataService` fields like `td`, `pg`, `fh` start as `null` and are created on first use via `getProvider()`. The provider field name is derived from the type name at comptime. + +- **Cache uses SRF format.** [SRF](https://git.lerch.org/lobo/srf) (Simple Record Format) is a line-oriented key-value format. Cache layout: `{cache_dir}/{SYMBOL}/{data_type}.srf`. Freshness is determined by file mtime vs TTL. + +- **Candles use incremental updates.** On cache miss, only candles newer than the last cached date are fetched (not the full 10-year history). The `candles_meta.srf` file tracks the last date and provider without deserializing the full candle file. + +- **View models separate data from rendering.** `views/portfolio_sections.zig` produces renderer-agnostic structs with `StyleIntent` enums. CLI and TUI renderers are thin adapters that map `StyleIntent` to ANSI colors or vaxis styles. + +- **Negative cache entries.** When a provider fetch fails permanently (not rate-limited), a negative cache entry is written to prevent repeated retries for nonexistent symbols. + +### Module map + +| Directory | Purpose | +|-----------|---------| +| `src/models/` | Data types: `Date` (days since epoch), `Candle`, `Dividend`, `Split`, `Lot`, `Portfolio`, `OptionContract`, `EarningsEvent`, `EtfProfile`, `Quote` | +| `src/providers/` | API clients: each provider has its own struct with `init(allocator, api_key)` + fetch methods. `json_utils.zig` has shared JSON parsing helpers. | +| `src/analytics/` | Pure computation: `performance.zig` (Morningstar-style trailing returns), `risk.zig` (Sharpe, drawdown), `valuation.zig` (portfolio summary), `analysis.zig` (breakdowns by class/sector/geo) | +| `src/commands/` | CLI command handlers: each has a `run()` function taking `(allocator, *DataService, symbol, color, *Writer)`. `common.zig` has shared CLI helpers and color constants. | +| `src/tui/` | TUI tab renderers: each tab (portfolio, quote, perf, options, earnings, analysis) is a separate file. `keybinds.zig` and `theme.zig` handle configurable input/colors. `chart.zig` renders pixel charts via Kitty graphics protocol. | +| `src/views/` | View models producing renderer-agnostic display data with `StyleIntent` | +| `src/cache/` | `store.zig`: SRF cache read/write with TTL freshness checks | +| `src/net/` | `http.zig`: HTTP client with retry and error classification. `RateLimiter.zig`: token-bucket rate limiter. | +| `build/` | Build-time support: `Coverage.zig` (kcov integration) | + +## Code patterns and conventions + +### Error handling + +- Provider HTTP errors are classified in `net/http.zig`: `RequestFailed`, `RateLimited`, `Unauthorized`, `NotFound`, `ServerError`, `InvalidResponse`. +- `DataService` wraps these into `DataError`: `NoApiKey`, `FetchFailed`, `TransientError`, `AuthError`, etc. +- Transient errors (server 5xx, connection failures) cause the refresh to stop. Non-transient errors (NotFound, ParseError) cause fallback to the next provider. +- Rate limit hits trigger a single retry after `rateLimitBackoff()`. + +### The `Date` type + +`Date` is an `i32` of days since Unix epoch. It is used everywhere instead of timestamps. Construction: `Date.fromYmd(2024, 1, 15)` or `Date.parse("2024-01-15")`. Formatting: `date.format(&buf)` writes `YYYY-MM-DD` into a `*[10]u8`. The type has SRF serialization hooks (`srfParse`, `srfFormat`). + +### Formatting pattern + +Functions in `format.zig` write into caller-provided buffers and return slices. They never allocate. Example: `fmtMoneyAbs(&buf, amount)` returns `[]const u8`. The sign handling is always caller-side. + +### Provider pattern + +Each provider in `src/providers/` follows the same structure: +1. Struct with `client: http.Client`, `allocator`, `api_key` +2. `init(allocator, api_key)` / `deinit()` +3. `fetch*(allocator, symbol, ...)` methods that build a URL, call `self.client.get(url)`, and parse the JSON response +4. Private `parse*` functions that handle the provider-specific JSON format +5. Shared JSON helpers from `json_utils.zig` (`parseJsonFloat`, `optFloat`, `optUint`, `jsonStr`) + +### Test pattern + +All tests are inline (in `test` blocks within source files). There is a single test binary rooted at `src/main.zig` which uses `refAllDeclsRecursive(@This())` to discover all tests transitively via file imports. The `tests/` directory exists but fixtures are empty — all test data is defined inline. + +Tests use `std.testing.allocator` (which detects leaks) and are structured as unit tests that verify individual functions. Network-dependent code is not tested (no mocking infrastructure). + +### Adding a new CLI command + +1. Create `src/commands/newcmd.zig` with a `pub fn run(allocator, *DataService, symbol, color, *Writer) !void` +2. Add the import to the `commands` struct in `src/main.zig` +3. Add the dispatch branch in `main.zig`'s command matching chain +4. Update the `usage` string in `main.zig` + +### Adding a new provider + +1. Create `src/providers/newprovider.zig` following the existing struct pattern +2. Add a field to `DataService` (e.g., `np: ?NewProvider = null`) +3. Add the API key to `Config` (e.g., `newprovider_key: ?[]const u8 = null`) — the field name must be the lowercased type name + `_key` for the comptime `getProvider` lookup to work +4. Wire `resolve("NEWPROVIDER_API_KEY")` in `Config.fromEnv` + +### Adding a new TUI tab + +1. Create `src/tui/newtab_tab.zig` +2. Add the tab variant to `tui.Tab` enum +3. Wire rendering in `tui.zig`'s draw and event handling + +## Gotchas + +- **Provider field naming is comptime-derived.** `DataService.getProvider(T)` finds the `?T` field by iterating struct fields at comptime, and the config key is derived by lowercasing the type name and appending `_key`. If you rename a provider struct, you must also rename the config field or the comptime logic breaks. + +- **Candle data has two cache files.** `candles_daily.srf` holds the actual OHLCV data; `candles_meta.srf` holds metadata (last_date, provider, fail_count). When invalidating candles, both must be cleared (this is handled by `DataService.invalidate`). + +- **TwelveData candles are force-refetched.** Cached candles from the TwelveData provider are treated as stale regardless of TTL because TwelveData's `adj_close` values were found to be unreliable. The code in `getCandles` explicitly checks `m.provider == .twelvedata` and falls through. + +- **Mutual fund detection is heuristic.** `isMutualFund` checks if the symbol is exactly 5 chars ending in 'X'. This skips earnings fetching for mutual funds. It's imperfect but covers the common case. + +- **SRF string lifetimes.** When reading SRF records, string fields point into the iterator's internal buffer. If you need strings to outlive the iterator, use a `postProcess` callback to `allocator.dupe()` them (see `dividendPostProcess` in `service.zig`). + +- **Buffered stdout.** CLI output uses a single `std.Io.Writer` with a 4096-byte stack buffer, flushed once at the end of `main()`. Don't write to stdout through other means. + +- **The `color` parameter flows through everything.** CLI commands accept a `color: bool` parameter. Don't use ANSI escapes unconditionally — always gate on the `color` flag. + +- **Portfolio auto-detection.** Both CLI and TUI auto-load `portfolio.srf` from cwd if no explicit path is given. `watchlist.srf` is similarly auto-detected. + +- **Server sync is optional.** The `ZFIN_SERVER` env var enables parallel cache syncing from a remote zfin-server instance. All server sync code silently no-ops when the URL is null. + +## Dependencies + +| Dependency | Purpose | +|------------|---------| +| [SRF](https://git.lerch.org/lobo/srf) | Cache file format, portfolio/watchlist parsing, serialization | +| [libvaxis](https://github.com/rockorager/libvaxis) (v0.5.1) | Terminal UI rendering | +| [z2d](https://github.com/vancluever/z2d) (v0.10.0) | Pixel chart rendering (Kitty graphics protocol) |