Compare commits

..

No commits in common. "4f3f795420d2af99f1095ef408a53373411274ca" and "5ee2151a4737dffc420616d028cd10b2c36f1600" have entirely different histories.

9 changed files with 69 additions and 442 deletions

1
.gitignore vendored
View file

@ -4,4 +4,3 @@ coverage/
.env
*.srf
!metadata.srf
scripts/

152
AGENTS.md
View file

@ -1,152 +0,0 @@
# 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
View file

@ -105,48 +105,3 @@ 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
data, then returning 202 Accepted, which could then be polled client side. Maybe
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.

View file

@ -65,7 +65,15 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co
defer cm.deinit();
// Load account tax type metadata (optional)
var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(file_path);
const acct_path = std.fmt.allocPrint(allocator, "{s}accounts.srf", .{file_path[0..dir_end]}) catch return;
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();
var result = zfin.analysis.analyzePortfolio(

View file

@ -487,7 +487,25 @@ pub fn compareSchwabSummary(
if (portfolio_acct) |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;
@ -773,7 +791,6 @@ pub fn compareAccounts(
const acct_positions = portfolio.positionsForAccount(allocator, portfolio_acct_name.?) catch &.{};
defer allocator.free(acct_positions);
var found_stock = false;
for (acct_positions) |pos| {
if (!std.mem.eql(u8, pos.symbol, bp.symbol) and
!std.mem.eql(u8, pos.lot_symbol, bp.symbol))
@ -784,30 +801,6 @@ pub fn compareAccounts(
pf_value = pos.shares * price * pos.price_ratio;
try matched_symbols.put(pos.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, {});
}
}
@ -871,63 +864,6 @@ pub fn compareAccounts(
.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, .{
@ -1219,8 +1155,18 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
defer portfolio.deinit();
// Load accounts.srf
var account_map = svc.loadAccountMap(portfolio_path) orelse {
try cli.stderrPrint("Error: Cannot read/parse accounts.srf (needed for account number mapping)\n");
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(allocator, "{s}accounts.srf", .{portfolio_path[0..dir_end]}) catch return;
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;
};
defer account_map.deinit();

View file

@ -93,12 +93,7 @@ pub const Lot = struct {
}
pub fn isOpen(self: Lot) bool {
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;
return self.close_date == null;
}
pub fn costBasis(self: Lot) f64 {
@ -383,41 +378,6 @@ pub const Portfolio = struct {
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.
pub fn totalCostBasis(self: Portfolio) f64 {
var total: f64 = 0;
@ -711,98 +671,3 @@ test "positionsForAccount excludes closed-only symbols" {
try std.testing.expectEqualStrings("XLV", pos_b[0].symbol);
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);
}

View file

@ -20,7 +20,6 @@ const EtfProfile = @import("models/etf_profile.zig").EtfProfile;
const Config = @import("config.zig").Config;
const cache = @import("cache/store.zig");
const srf = @import("srf");
const analysis = @import("analytics/analysis.zig");
const TwelveData = @import("providers/twelvedata.zig").TwelveData;
const Polygon = @import("providers/polygon.zig").Polygon;
const Finnhub = @import("providers/finnhub.zig").Finnhub;
@ -1335,22 +1334,6 @@ pub const DataService = struct {
fn isMutualFund(symbol: []const u8) bool {
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

View file

@ -827,7 +827,14 @@ pub const App = struct {
pub fn ensureAccountMap(self: *App) void {
if (self.account_map != null) return;
const ppath = self.portfolio_path orelse return;
self.account_map = self.svc.loadAccountMap(ppath);
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
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.

View file

@ -719,7 +719,7 @@ const FilteredTotals = struct {
/// Compute total value and cost across all asset types for the active account filter.
/// Returns {0, 0} if no filter is active.
fn computeFilteredTotals(app: *const App) FilteredTotals {
const af = app.account_filter orelse return .{ .value = 0, .cost = 0 };
if (app.account_filter == null) return .{ .value = 0, .cost = 0 };
var value: f64 = 0;
var cost: f64 = 0;
if (app.portfolio_summary) |s| {
@ -732,9 +732,25 @@ fn computeFilteredTotals(app: *const App) FilteredTotals {
}
}
if (app.portfolio) |pf| {
const ns = pf.nonStockValueForAccount(af);
value += ns;
cost += ns;
for (pf.lots) |lot| {
if (!matchesAccountFilter(app, lot.account)) continue;
switch (lot.security_type) {
.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 };
}