zfin/AGENTS.md
Emil Lerch 4f3f795420
All checks were successful
Generic zig build / build (push) Successful in 36s
add AGENTS.md/add scripts to gitignore
2026-04-11 10:35:50 -07:00

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 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 (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 Cache file format, portfolio/watchlist parsing, serialization
libvaxis (v0.5.1) Terminal UI rendering
z2d (v0.10.0) Pixel chart rendering (Kitty graphics protocol)