9.9 KiB
AGENTS.md
Commands
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 -- <args> # 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 letsrefAllDeclsRecursivein the test binary discover all tests across the entire source tree. -
DataService is the sole data source. Both CLI and TUI go through
DataServicefor all fetched data. Never call provider APIs directly from commands or TUI tabs. -
Providers are lazily initialized.
DataServicefields liketd,pg,fhstart asnulland are created on first use viagetProvider(). The provider field name is derived from the type name at comptime. -
Cache uses SRF format. 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.srffile tracks the last date and provider without deserializing the full candle file. -
View models separate data from rendering.
views/portfolio_sections.zigproduces renderer-agnostic structs withStyleIntentenums. CLI and TUI renderers are thin adapters that mapStyleIntentto 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. DataServicewraps these intoDataError: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:
- Struct with
client: http.Client,allocator,api_key init(allocator, api_key)/deinit()fetch*(allocator, symbol, ...)methods that build a URL, callself.client.get(url), and parse the JSON response- Private
parse*functions that handle the provider-specific JSON format - 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
- Create
src/commands/newcmd.zigwith apub fn run(allocator, *DataService, symbol, color, *Writer) !void - Add the import to the
commandsstruct insrc/main.zig - Add the dispatch branch in
main.zig's command matching chain - Update the
usagestring inmain.zig
Adding a new provider
- Create
src/providers/newprovider.zigfollowing the existing struct pattern - Add a field to
DataService(e.g.,np: ?NewProvider = null) - Add the API key to
Config(e.g.,newprovider_key: ?[]const u8 = null) — the field name must be the lowercased type name +_keyfor the comptimegetProviderlookup to work - Wire
resolve("NEWPROVIDER_API_KEY")inConfig.fromEnv
Adding a new TUI tab
- Create
src/tui/newtab_tab.zig - Add the tab variant to
tui.Tabenum - Wire rendering in
tui.zig's draw and event handling
Gotchas
-
Provider field naming is comptime-derived.
DataService.getProvider(T)finds the?Tfield 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.srfholds the actual OHLCV data;candles_meta.srfholds metadata (last_date, provider, fail_count). When invalidating candles, both must be cleared (this is handled byDataService.invalidate). -
TwelveData candles are force-refetched. Cached candles from the TwelveData provider are treated as stale regardless of TTL because TwelveData's
adj_closevalues were found to be unreliable. The code ingetCandlesexplicitly checksm.provider == .twelvedataand falls through. -
Mutual fund detection is heuristic.
isMutualFundchecks 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
postProcesscallback toallocator.dupe()them (seedividendPostProcessinservice.zig). -
Buffered stdout. CLI output uses a single
std.Io.Writerwith a 4096-byte stack buffer, flushed once at the end ofmain(). Don't write to stdout through other means. -
The
colorparameter flows through everything. CLI commands accept acolor: boolparameter. Don't use ANSI escapes unconditionally — always gate on thecolorflag. -
Portfolio auto-detection. Both CLI and TUI auto-load
portfolio.srffrom cwd if no explicit path is given.watchlist.srfis similarly auto-detected. -
Server sync is optional. The
ZFIN_SERVERenv 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 | Cache file format, portfolio/watchlist parsing, serialization |
| libvaxis (v0.5.1) | Terminal UI rendering |
| z2d (v0.10.0) | Pixel chart rendering (Kitty graphics protocol) |