Compare commits
5 commits
5ee2151a47
...
4f3f795420
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f3f795420 | |||
| ad38fe75c9 | |||
| 5b0af2486c | |||
| 3171be6f70 | |||
| 518af59717 |
9 changed files with 442 additions and 69 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ coverage/
|
||||||
.env
|
.env
|
||||||
*.srf
|
*.srf
|
||||||
!metadata.srf
|
!metadata.srf
|
||||||
|
scripts/
|
||||||
|
|
|
||||||
152
AGENTS.md
Normal file
152
AGENTS.md
Normal file
|
|
@ -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 -- <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](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) |
|
||||||
45
TODO.md
45
TODO.md
|
|
@ -105,3 +105,48 @@ introduce some opacity to the process as we wait for candles (for example) to
|
||||||
populate. This could be solved on the server by spawning a thread to fetch the
|
populate. This could be solved on the server by spawning a thread to fetch the
|
||||||
data, then returning 202 Accepted, which could then be polled client side. Maybe
|
data, then returning 202 Accepted, which could then be polled client side. Maybe
|
||||||
this is a better long term approach?
|
this is a better long term approach?
|
||||||
|
|
||||||
|
## Per-account covered call adjustment
|
||||||
|
|
||||||
|
`adjustForCoveredCalls` in `valuation.zig` operates on portfolio-wide aggregated
|
||||||
|
allocations. It matches sold calls against total underlying shares across all
|
||||||
|
accounts. This is wrong — calls in one account can only cover shares in that
|
||||||
|
same account. If NVDA calls are sold in Emil IRA, they shouldn't cap NVDA
|
||||||
|
shares held in Joint trust.
|
||||||
|
|
||||||
|
Fixing this means restructuring `portfolioSummary`, since `Allocation` is
|
||||||
|
currently account-agnostic. Approach: compute per-account reductions using
|
||||||
|
`positionsForAccount` + account-filtered option lots, then sum into
|
||||||
|
portfolio-wide reductions. Each account's reduction capped by that account's
|
||||||
|
shares, not the global total.
|
||||||
|
|
||||||
|
Low priority — naked calls are rare, and calls are typically in the same
|
||||||
|
account as the underlying.
|
||||||
|
|
||||||
|
## Covered call adjustment optimization
|
||||||
|
|
||||||
|
`adjustForCoveredCalls` has a nested loop — for each allocation, it iterates
|
||||||
|
all lots to find matching option contracts. O(N*M) is fine for personal
|
||||||
|
portfolios (<1000 lots). Pre-indexing options by underlying would help if
|
||||||
|
someone had a very large options-heavy portfolio.
|
||||||
|
|
||||||
|
## Mixed price_ratio grouping
|
||||||
|
|
||||||
|
`Position` grouping in `portfolio.zig` keys on `priceSymbol` alone. Lots with
|
||||||
|
different `price_ratio` values sharing the same `priceSymbol` get incorrectly
|
||||||
|
merged (e.g. investor vs institutional shares of the same fund). Should key
|
||||||
|
on `(priceSymbol, price_ratio)` tuple. Edge case — most people don't hold
|
||||||
|
both share classes simultaneously.
|
||||||
|
|
||||||
|
## HTTP connection pooling
|
||||||
|
|
||||||
|
Parallel server sync in `loadAllPrices` spawns up to 8 threads, each with its
|
||||||
|
own HTTP connection. Could reuse connections to reduce TCP handshake overhead.
|
||||||
|
Only matters with very large portfolios (100+ symbols) hitting ZFIN_SERVER.
|
||||||
|
8 concurrent connections is fine for now.
|
||||||
|
|
||||||
|
## Streaming cache deserialization
|
||||||
|
|
||||||
|
Cache store reads entire files into memory (`readFileAlloc` with 50MB limit).
|
||||||
|
For portfolios with 10+ years of daily candles, this could use significant
|
||||||
|
memory. Keep current approach unless memory becomes a real problem.
|
||||||
|
|
|
||||||
|
|
@ -65,15 +65,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
|
||||||
defer cm.deinit();
|
defer cm.deinit();
|
||||||
|
|
||||||
// Load account tax type metadata (optional)
|
// Load account tax type metadata (optional)
|
||||||
const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{file_path[0..dir_end]}) catch return;
|
var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(file_path);
|
||||||
defer allocator.free(acct_path);
|
|
||||||
|
|
||||||
var acct_map_opt: ?zfin.analysis.AccountMap = null;
|
|
||||||
const acct_data = std.fs.cwd().readFileAlloc(allocator, acct_path, 1024 * 1024) catch null;
|
|
||||||
if (acct_data) |ad| {
|
|
||||||
defer allocator.free(ad);
|
|
||||||
acct_map_opt = zfin.analysis.parseAccountsFile(allocator, ad) catch null;
|
|
||||||
}
|
|
||||||
defer if (acct_map_opt) |*am| am.deinit();
|
defer if (acct_map_opt) |*am| am.deinit();
|
||||||
|
|
||||||
var result = zfin.analysis.analyzePortfolio(
|
var result = zfin.analysis.analyzePortfolio(
|
||||||
|
|
|
||||||
|
|
@ -487,25 +487,7 @@ pub fn compareSchwabSummary(
|
||||||
|
|
||||||
if (portfolio_acct) |pa| {
|
if (portfolio_acct) |pa| {
|
||||||
pf_cash = portfolio.cashForAccount(pa);
|
pf_cash = portfolio.cashForAccount(pa);
|
||||||
|
pf_total = portfolio.totalForAccount(allocator, pa, prices);
|
||||||
const acct_positions = portfolio.positionsForAccount(allocator, pa) catch &.{};
|
|
||||||
defer allocator.free(acct_positions);
|
|
||||||
for (acct_positions) |pos| {
|
|
||||||
const price = prices.get(pos.symbol) orelse pos.avg_cost;
|
|
||||||
pf_total += pos.shares * price * pos.price_ratio;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add cash, CDs, options for this account
|
|
||||||
for (portfolio.lots) |lot| {
|
|
||||||
const lot_acct = lot.account orelse continue;
|
|
||||||
if (!std.mem.eql(u8, lot_acct, pa)) continue;
|
|
||||||
switch (lot.security_type) {
|
|
||||||
.cash => pf_total += lot.shares,
|
|
||||||
.cd => pf_total += lot.shares,
|
|
||||||
.option => pf_total += @abs(lot.shares) * lot.open_price * lot.multiplier,
|
|
||||||
else => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
|
const cash_delta = if (sa.cash) |sc| sc - pf_cash else null;
|
||||||
|
|
@ -791,6 +773,7 @@ pub fn compareAccounts(
|
||||||
const acct_positions = portfolio.positionsForAccount(allocator, portfolio_acct_name.?) catch &.{};
|
const acct_positions = portfolio.positionsForAccount(allocator, portfolio_acct_name.?) catch &.{};
|
||||||
defer allocator.free(acct_positions);
|
defer allocator.free(acct_positions);
|
||||||
|
|
||||||
|
var found_stock = false;
|
||||||
for (acct_positions) |pos| {
|
for (acct_positions) |pos| {
|
||||||
if (!std.mem.eql(u8, pos.symbol, bp.symbol) and
|
if (!std.mem.eql(u8, pos.symbol, bp.symbol) and
|
||||||
!std.mem.eql(u8, pos.lot_symbol, bp.symbol))
|
!std.mem.eql(u8, pos.lot_symbol, bp.symbol))
|
||||||
|
|
@ -801,6 +784,30 @@ pub fn compareAccounts(
|
||||||
pf_value = pos.shares * price * pos.price_ratio;
|
pf_value = pos.shares * price * pos.price_ratio;
|
||||||
try matched_symbols.put(pos.symbol, {});
|
try matched_symbols.put(pos.symbol, {});
|
||||||
try matched_symbols.put(pos.lot_symbol, {});
|
try matched_symbols.put(pos.lot_symbol, {});
|
||||||
|
found_stock = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found_stock) {
|
||||||
|
for (portfolio.lots) |lot| {
|
||||||
|
const lot_acct = lot.account orelse continue;
|
||||||
|
if (!std.mem.eql(u8, lot_acct, portfolio_acct_name.?)) continue;
|
||||||
|
if (!lot.isOpen()) continue;
|
||||||
|
if (!std.mem.eql(u8, lot.symbol, bp.symbol)) continue;
|
||||||
|
switch (lot.security_type) {
|
||||||
|
.cd => {
|
||||||
|
pf_shares += lot.shares;
|
||||||
|
pf_value += lot.shares;
|
||||||
|
pf_price = 1.0;
|
||||||
|
},
|
||||||
|
.option => {
|
||||||
|
pf_shares += lot.shares;
|
||||||
|
pf_value += @abs(lot.shares) * lot.open_price * lot.multiplier;
|
||||||
|
pf_price = lot.open_price * lot.multiplier;
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pf_shares != 0) try matched_symbols.put(bp.symbol, {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -864,6 +871,63 @@ pub fn compareAccounts(
|
||||||
.only_in_portfolio = true,
|
.only_in_portfolio = true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Portfolio-only CDs and options
|
||||||
|
for (portfolio.lots) |lot| {
|
||||||
|
const lot_acct = lot.account orelse continue;
|
||||||
|
if (!std.mem.eql(u8, lot_acct, pa)) continue;
|
||||||
|
if (!lot.isOpen()) continue;
|
||||||
|
if (lot.security_type != .cd and lot.security_type != .option) continue;
|
||||||
|
if (matched_symbols.contains(lot.symbol)) continue;
|
||||||
|
|
||||||
|
try matched_symbols.put(lot.symbol, {});
|
||||||
|
|
||||||
|
var pf_shares: f64 = 0;
|
||||||
|
var pf_value: f64 = 0;
|
||||||
|
var pf_price: ?f64 = null;
|
||||||
|
var is_cd = false;
|
||||||
|
|
||||||
|
// Aggregate all lots with same symbol in this account
|
||||||
|
for (portfolio.lots) |lot2| {
|
||||||
|
const la2 = lot2.account orelse continue;
|
||||||
|
if (!std.mem.eql(u8, la2, pa)) continue;
|
||||||
|
if (!lot2.isOpen()) continue;
|
||||||
|
if (!std.mem.eql(u8, lot2.symbol, lot.symbol)) continue;
|
||||||
|
switch (lot2.security_type) {
|
||||||
|
.cd => {
|
||||||
|
pf_shares += lot2.shares;
|
||||||
|
pf_value += lot2.shares;
|
||||||
|
pf_price = 1.0;
|
||||||
|
is_cd = true;
|
||||||
|
},
|
||||||
|
.option => {
|
||||||
|
pf_shares += lot2.shares;
|
||||||
|
pf_value += @abs(lot2.shares) * lot2.open_price * lot2.multiplier;
|
||||||
|
pf_price = lot2.open_price * lot2.multiplier;
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pf_value != 0 or pf_shares != 0) {
|
||||||
|
portfolio_total += pf_value;
|
||||||
|
has_discrepancies = true;
|
||||||
|
try comparisons.append(allocator, .{
|
||||||
|
.symbol = lot.symbol,
|
||||||
|
.portfolio_shares = pf_shares,
|
||||||
|
.brokerage_shares = null,
|
||||||
|
.portfolio_price = pf_price,
|
||||||
|
.brokerage_price = null,
|
||||||
|
.portfolio_value = pf_value,
|
||||||
|
.brokerage_value = null,
|
||||||
|
.shares_delta = null,
|
||||||
|
.value_delta = null,
|
||||||
|
.is_cash = is_cd,
|
||||||
|
.only_in_brokerage = false,
|
||||||
|
.only_in_portfolio = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try results.append(allocator, .{
|
try results.append(allocator, .{
|
||||||
|
|
@ -1155,18 +1219,8 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
|
||||||
defer portfolio.deinit();
|
defer portfolio.deinit();
|
||||||
|
|
||||||
// Load accounts.srf
|
// Load accounts.srf
|
||||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
var account_map = svc.loadAccountMap(portfolio_path) orelse {
|
||||||
const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{portfolio_path[0..dir_end]}) catch return;
|
try cli.stderrPrint("Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
|
||||||
defer allocator.free(acct_path);
|
|
||||||
|
|
||||||
const acct_data = std.fs.cwd().readFileAlloc(allocator, acct_path, 1024 * 1024) catch {
|
|
||||||
try cli.stderrPrint("Error: Cannot read accounts.srf (needed for account number mapping)\n");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
defer allocator.free(acct_data);
|
|
||||||
|
|
||||||
var account_map = analysis.parseAccountsFile(allocator, acct_data) catch {
|
|
||||||
try cli.stderrPrint("Error: Cannot parse accounts.srf\n");
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
defer account_map.deinit();
|
defer account_map.deinit();
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,12 @@ pub const Lot = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn isOpen(self: Lot) bool {
|
pub fn isOpen(self: Lot) bool {
|
||||||
return self.close_date == null;
|
if (self.close_date != null) return false;
|
||||||
|
if (self.maturity_date) |mat| {
|
||||||
|
const today = Date.fromEpoch(std.time.timestamp());
|
||||||
|
if (!today.lessThan(mat)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn costBasis(self: Lot) f64 {
|
pub fn costBasis(self: Lot) f64 {
|
||||||
|
|
@ -378,6 +383,41 @@ pub const Portfolio = struct {
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Total value of non-stock holdings (cash, CDs, options) for a single account.
|
||||||
|
/// Only includes open lots (respects close_date and maturity_date).
|
||||||
|
pub fn nonStockValueForAccount(self: Portfolio, account_name: []const u8) f64 {
|
||||||
|
var total: f64 = 0;
|
||||||
|
for (self.lots) |lot| {
|
||||||
|
if (!lot.isOpen()) continue;
|
||||||
|
const lot_acct = lot.account orelse continue;
|
||||||
|
if (!std.mem.eql(u8, lot_acct, account_name)) continue;
|
||||||
|
switch (lot.security_type) {
|
||||||
|
.cash => total += lot.shares,
|
||||||
|
.cd => total += lot.shares,
|
||||||
|
.option => total += @abs(lot.shares) * lot.open_price * lot.multiplier,
|
||||||
|
else => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total value of an account: stocks (priced from the given map, falling back to avg_cost)
|
||||||
|
/// plus cash, CDs, and options. Only includes open lots.
|
||||||
|
pub fn totalForAccount(self: Portfolio, allocator: std.mem.Allocator, account_name: []const u8, prices: std.StringHashMap(f64)) f64 {
|
||||||
|
var total: f64 = 0;
|
||||||
|
|
||||||
|
const acct_positions = self.positionsForAccount(allocator, account_name) catch return self.nonStockValueForAccount(account_name);
|
||||||
|
defer allocator.free(acct_positions);
|
||||||
|
|
||||||
|
for (acct_positions) |pos| {
|
||||||
|
const price = prices.get(pos.symbol) orelse pos.avg_cost;
|
||||||
|
total += pos.shares * price * pos.price_ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
total += self.nonStockValueForAccount(account_name);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
/// Total cost basis of all open stock lots.
|
/// Total cost basis of all open stock lots.
|
||||||
pub fn totalCostBasis(self: Portfolio) f64 {
|
pub fn totalCostBasis(self: Portfolio) f64 {
|
||||||
var total: f64 = 0;
|
var total: f64 = 0;
|
||||||
|
|
@ -671,3 +711,98 @@ test "positionsForAccount excludes closed-only symbols" {
|
||||||
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
|
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
|
||||||
try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
|
try std.testing.expectApproxEqAbs(@as(f64, 50.0), pos_b[0].shares, 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "isOpen respects maturity_date" {
|
||||||
|
const past = Date.fromYmd(2024, 1, 1);
|
||||||
|
const future = Date.fromYmd(2099, 12, 31);
|
||||||
|
|
||||||
|
const expired_option = Lot{
|
||||||
|
.symbol = "AAPL 01/01/2024 150 C",
|
||||||
|
.shares = -1,
|
||||||
|
.open_date = Date.fromYmd(2023, 6, 1),
|
||||||
|
.open_price = 5.0,
|
||||||
|
.security_type = .option,
|
||||||
|
.maturity_date = past,
|
||||||
|
};
|
||||||
|
try std.testing.expect(!expired_option.isOpen());
|
||||||
|
|
||||||
|
const active_option = Lot{
|
||||||
|
.symbol = "AAPL 12/31/2099 150 C",
|
||||||
|
.shares = -1,
|
||||||
|
.open_date = Date.fromYmd(2023, 6, 1),
|
||||||
|
.open_price = 5.0,
|
||||||
|
.security_type = .option,
|
||||||
|
.maturity_date = future,
|
||||||
|
};
|
||||||
|
try std.testing.expect(active_option.isOpen());
|
||||||
|
|
||||||
|
const closed_option = Lot{
|
||||||
|
.symbol = "AAPL 12/31/2099 150 C",
|
||||||
|
.shares = -1,
|
||||||
|
.open_date = Date.fromYmd(2023, 6, 1),
|
||||||
|
.open_price = 5.0,
|
||||||
|
.security_type = .option,
|
||||||
|
.maturity_date = future,
|
||||||
|
.close_date = Date.fromYmd(2024, 6, 1),
|
||||||
|
};
|
||||||
|
try std.testing.expect(!closed_option.isOpen());
|
||||||
|
|
||||||
|
const stock = Lot{
|
||||||
|
.symbol = "AAPL",
|
||||||
|
.shares = 100,
|
||||||
|
.open_date = Date.fromYmd(2023, 1, 1),
|
||||||
|
.open_price = 150.0,
|
||||||
|
};
|
||||||
|
try std.testing.expect(stock.isOpen());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "nonStockValueForAccount" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const future = Date.fromYmd(2099, 12, 31);
|
||||||
|
const past = Date.fromYmd(2024, 1, 1);
|
||||||
|
|
||||||
|
var lots = [_]Lot{
|
||||||
|
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" },
|
||||||
|
.{ .symbol = "", .shares = 5000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
|
||||||
|
.{ .symbol = "CD123", .shares = 50000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future },
|
||||||
|
.{ .symbol = "AAPL 12/31/2099 200 C", .shares = -2, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 3.50, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 },
|
||||||
|
.{ .symbol = "AAPL 01/01/2024 180 C", .shares = -1, .open_date = Date.fromYmd(2023, 6, 1), .open_price = 4.0, .security_type = .option, .account = "IRA", .maturity_date = past, .multiplier = 100 },
|
||||||
|
.{ .symbol = "", .shares = 1000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "Other" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
|
||||||
|
// cash(5000) + cd(50000) + open option(2*3.50*100=700) = 55700
|
||||||
|
// expired option excluded
|
||||||
|
const ns = portfolio.nonStockValueForAccount("IRA");
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 55700.0), ns, 0.01);
|
||||||
|
|
||||||
|
const ns_other = portfolio.nonStockValueForAccount("Other");
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 1000.0), ns_other, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "totalForAccount" {
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const future = Date.fromYmd(2099, 12, 31);
|
||||||
|
|
||||||
|
var lots = [_]Lot{
|
||||||
|
.{ .symbol = "AAPL", .shares = 100, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 150.0, .account = "IRA" },
|
||||||
|
.{ .symbol = "MSFT", .shares = 50, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 300.0, .account = "IRA" },
|
||||||
|
.{ .symbol = "", .shares = 2000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cash, .account = "IRA" },
|
||||||
|
.{ .symbol = "CD456", .shares = 10000, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 1.0, .security_type = .cd, .account = "IRA", .maturity_date = future },
|
||||||
|
.{ .symbol = "AAPL C", .shares = -1, .open_date = Date.fromYmd(2024, 1, 1), .open_price = 5.0, .security_type = .option, .account = "IRA", .maturity_date = future, .multiplier = 100 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const portfolio = Portfolio{ .lots = &lots, .allocator = allocator };
|
||||||
|
|
||||||
|
var prices = std.StringHashMap(f64).init(allocator);
|
||||||
|
defer prices.deinit();
|
||||||
|
try prices.put("AAPL", 170.0);
|
||||||
|
// MSFT not in prices — should fall back to avg_cost (300.0)
|
||||||
|
|
||||||
|
// stocks: AAPL(100*170=17000) + MSFT(50*300=15000) = 32000
|
||||||
|
// non-stock: cash(2000) + cd(10000) + option(1*5*100=500) = 12500
|
||||||
|
// total = 44500
|
||||||
|
const total = portfolio.totalForAccount(allocator, "IRA", prices);
|
||||||
|
try std.testing.expectApproxEqAbs(@as(f64, 44500.0), total, 0.01);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const EtfProfile = @import("models/etf_profile.zig").EtfProfile;
|
||||||
const Config = @import("config.zig").Config;
|
const Config = @import("config.zig").Config;
|
||||||
const cache = @import("cache/store.zig");
|
const cache = @import("cache/store.zig");
|
||||||
const srf = @import("srf");
|
const srf = @import("srf");
|
||||||
|
const analysis = @import("analytics/analysis.zig");
|
||||||
const TwelveData = @import("providers/twelvedata.zig").TwelveData;
|
const TwelveData = @import("providers/twelvedata.zig").TwelveData;
|
||||||
const Polygon = @import("providers/polygon.zig").Polygon;
|
const Polygon = @import("providers/polygon.zig").Polygon;
|
||||||
const Finnhub = @import("providers/finnhub.zig").Finnhub;
|
const Finnhub = @import("providers/finnhub.zig").Finnhub;
|
||||||
|
|
@ -1334,6 +1335,22 @@ pub const DataService = struct {
|
||||||
fn isMutualFund(symbol: []const u8) bool {
|
fn isMutualFund(symbol: []const u8) bool {
|
||||||
return symbol.len == 5 and symbol[4] == 'X';
|
return symbol.len == 5 and symbol[4] == 'X';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User config files ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Load and parse accounts.srf from the same directory as the given portfolio path.
|
||||||
|
/// Returns null if the file doesn't exist or can't be parsed.
|
||||||
|
/// Caller owns the returned AccountMap and must call deinit().
|
||||||
|
pub fn loadAccountMap(self: *DataService, portfolio_path: []const u8) ?analysis.AccountMap {
|
||||||
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, portfolio_path, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||||||
|
const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{portfolio_path[0..dir_end]}) catch return null;
|
||||||
|
defer self.allocator.free(acct_path);
|
||||||
|
|
||||||
|
const data = std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024) catch return null;
|
||||||
|
defer self.allocator.free(data);
|
||||||
|
|
||||||
|
return analysis.parseAccountsFile(self.allocator, data) catch null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -827,14 +827,7 @@ pub const App = struct {
|
||||||
pub fn ensureAccountMap(self: *App) void {
|
pub fn ensureAccountMap(self: *App) void {
|
||||||
if (self.account_map != null) return;
|
if (self.account_map != null) return;
|
||||||
const ppath = self.portfolio_path orelse return;
|
const ppath = self.portfolio_path orelse return;
|
||||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
self.account_map = self.svc.loadAccountMap(ppath);
|
||||||
const acct_path = std.fmt.allocPrint(self.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch return;
|
|
||||||
defer self.allocator.free(acct_path);
|
|
||||||
|
|
||||||
if (std.fs.cwd().readFileAlloc(self.allocator, acct_path, 1024 * 1024)) |acct_data| {
|
|
||||||
defer self.allocator.free(acct_data);
|
|
||||||
self.account_map = zfin.analysis.parseAccountsFile(self.allocator, acct_data) catch null;
|
|
||||||
} else |_| {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set or clear the account filter. Owns the string via allocator.
|
/// Set or clear the account filter. Owns the string via allocator.
|
||||||
|
|
|
||||||
|
|
@ -719,7 +719,7 @@ const FilteredTotals = struct {
|
||||||
/// Compute total value and cost across all asset types for the active account filter.
|
/// Compute total value and cost across all asset types for the active account filter.
|
||||||
/// Returns {0, 0} if no filter is active.
|
/// Returns {0, 0} if no filter is active.
|
||||||
fn computeFilteredTotals(app: *const App) FilteredTotals {
|
fn computeFilteredTotals(app: *const App) FilteredTotals {
|
||||||
if (app.account_filter == null) return .{ .value = 0, .cost = 0 };
|
const af = app.account_filter orelse return .{ .value = 0, .cost = 0 };
|
||||||
var value: f64 = 0;
|
var value: f64 = 0;
|
||||||
var cost: f64 = 0;
|
var cost: f64 = 0;
|
||||||
if (app.portfolio_summary) |s| {
|
if (app.portfolio_summary) |s| {
|
||||||
|
|
@ -732,25 +732,9 @@ fn computeFilteredTotals(app: *const App) FilteredTotals {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (app.portfolio) |pf| {
|
if (app.portfolio) |pf| {
|
||||||
for (pf.lots) |lot| {
|
const ns = pf.nonStockValueForAccount(af);
|
||||||
if (!matchesAccountFilter(app, lot.account)) continue;
|
value += ns;
|
||||||
switch (lot.security_type) {
|
cost += ns;
|
||||||
.cash => {
|
|
||||||
value += lot.shares;
|
|
||||||
cost += lot.shares;
|
|
||||||
},
|
|
||||||
.cd => {
|
|
||||||
value += lot.shares;
|
|
||||||
cost += lot.shares;
|
|
||||||
},
|
|
||||||
.option => {
|
|
||||||
const opt_cost = @abs(lot.shares) * lot.open_price;
|
|
||||||
value += opt_cost;
|
|
||||||
cost += opt_cost;
|
|
||||||
},
|
|
||||||
else => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return .{ .value = value, .cost = cost };
|
return .{ .value = value, .cost = cost };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue