Compare commits

..

10 commits

22 changed files with 509 additions and 426 deletions

View file

@ -0,0 +1,27 @@
name: Generic zig build
on:
workflow_dispatch:
push:
branches:
- '*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Setup Zig
uses: https://codeberg.org/mlugg/setup-zig@v2.2.1
- name: Build project
run: zig build --summary all
- name: Run tests
run: zig build test --summary all
- name: Notify
uses: https://git.lerch.org/lobo/action-notify-ntfy@v2
if: always() && env.GITEA_ACTIONS == 'true'
with:
host: ${{ secrets.NTFY_HOST }}
topic: ${{ secrets.NTFY_TOPIC }}
status: ${{ job.status }}
user: ${{ secrets.NTFY_USER }}
password: ${{ secrets.NTFY_PASSWORD }}

86
TODO.md Normal file
View file

@ -0,0 +1,86 @@
# Future Work
## Covered call portfolio valuation
Portfolio value should account for sold call options. Shares covered by
in-the-money calls should be valued at the strike price, not the market price.
Example: 500 shares of AMZN at $225, with 3 sold calls at $220 strike.
300 shares should be valued at $220 (covered), 200 shares at $225 (uncovered).
## Institutional share class price ratios
Vanguard target date funds (e.g. 2035/VTTHX, 2040) held through Fidelity are
institutional share classes with prices that differ from the publicly traded
fund by a fixed ratio. The price can only be sourced from Fidelity directly,
but performance data (1/3/5/10yr returns) should be identical to the public
symbol.
Investigate: can we store a static price ratio in metadata (e.g. if Fidelity
says $100 and Morningstar says $20, ratio = 5) and multiply TwelveData quote
data by that ratio? Would this hold consistently over time, or does the ratio
drift?
## Market-aware cache TTL for daily candles
Daily candle TTL is currently 24 hours, but candle data only becomes meaningful
after the market close. Investigate keying the cache freshness to ~4:30 PM
Eastern (or whenever TwelveData actually publishes the daily candle) rather
than a rolling 24-hour window. This would avoid unnecessary refetches during
the trading day and ensure a fetch shortly after close gets fresh data.
## Yahoo Finance as primary quote source
Consider adding Yahoo Finance as the primary provider for real-time quotes,
with a silent fallback to TwelveData. Yahoo is free and has no API key
requirement, but the unofficial API is brittle and can break without notice.
TwelveData would serve as the reliable backup when Yahoo is unavailable.
## Human review of analytics modules
AI review complete; human review still needed for:
- `src/analytics/performance.zig` — Morningstar-style trailing returns
- `src/analytics/risk.zig` — Sharpe, volatility, max drawdown, portfolio summary
- `src/analytics/indicators.zig` — SMA, Bollinger Bands, RSI
- `src/models/classification.zig` — Sector/geo/asset-class metadata parsing
Known issues from AI review:
- `risk.zig` uses population variance (divides by n) instead of sample
variance (n-1). Negligible with 252+ data points but technically wrong.
## Risk-free rate maintenance
`risk.zig` `default_risk_free_rate` is currently 4.5% (T-bill proxy as of
early 2026). This is now a parameter to `computeRisk` with the default
exported as a public constant. Callers currently pass the default.
**Action needed:** When the Fed moves rates significantly, update
`default_risk_free_rate` in `src/analytics/risk.zig`. Eventually consider
making this a config value (env var or .env) so it doesn't require a rebuild.
## CLI/TUI code review (lower priority)
No review has been done on these files. They are presentation-layer code
and not part of the reusable library API.
TUI:
- `src/tui.zig`
- `src/tui/chart.zig`
- `src/tui/keybinds.zig`
- `src/tui/theme.zig`
Commands:
- `src/commands/common.zig`
- `src/commands/analysis.zig`
- `src/commands/cache.zig`
- `src/commands/divs.zig`
- `src/commands/earnings.zig`
- `src/commands/enrich.zig`
- `src/commands/etf.zig`
- `src/commands/history.zig`
- `src/commands/lookup.zig`
- `src/commands/options.zig`
- `src/commands/perf.zig`
- `src/commands/portfolio.zig`
- `src/commands/quote.zig`
- `src/commands/splits.zig`

View file

@ -1,7 +1,7 @@
const std = @import("std");
const Candle = @import("../models/candle.zig").Candle;
const Date = @import("../models/date.zig").Date;
const fmt = @import("../format.zig");
const portfolio_mod = @import("../models/portfolio.zig");
/// Daily return series statistics.
pub const RiskMetrics = struct {
@ -19,12 +19,14 @@ pub const RiskMetrics = struct {
sample_size: usize,
};
const risk_free_annual = 0.045; // ~4.5% annualized, current T-bill proxy
/// Default risk-free rate (~4.5% annualized, current T-bill proxy).
/// Override via `computeRisk`'s `risk_free_rate` parameter.
pub const default_risk_free_rate: f64 = 0.045;
const trading_days_per_year: f64 = 252.0;
/// Compute risk metrics from a series of daily candles.
/// Candles must be sorted by date ascending.
pub fn computeRisk(candles: []const Candle) ?RiskMetrics {
pub fn computeRisk(candles: []const Candle, risk_free_rate: f64) ?RiskMetrics {
if (candles.len < 21) return null; // need at least ~1 month
// Compute daily log returns
@ -65,7 +67,7 @@ pub fn computeRisk(candles: []const Candle) ?RiskMetrics {
const annual_vol = daily_vol * @sqrt(trading_days_per_year);
const annual_return = mean * trading_days_per_year;
const sharpe = if (annual_vol > 0) (annual_return - risk_free_annual) / annual_vol else 0;
const sharpe = if (annual_vol > 0) (annual_return - risk_free_rate) / annual_vol else 0;
return .{
.volatility = annual_vol,
@ -164,7 +166,7 @@ pub fn portfolioSummary(
total_realized += pos.realized_gain_loss;
// For CUSIPs with a note, derive a short display label from the note.
const display = if (fmt.isCusipLike(pos.symbol) and pos.note != null)
const display = if (portfolio_mod.isCusipLike(pos.symbol) and pos.note != null)
shortLabel(pos.note.?)
else
pos.symbol;
@ -413,7 +415,7 @@ test "risk metrics basic" {
.volume = 1000,
};
}
const metrics = computeRisk(&candles);
const metrics = computeRisk(&candles, default_risk_free_rate);
try std.testing.expect(metrics != null);
const m = metrics.?;
// Monotonically increasing price -> 0 drawdown
@ -448,7 +450,7 @@ test "max drawdown" {
makeCandle(Date.fromYmd(2024, 1, 29), 140),
makeCandle(Date.fromYmd(2024, 1, 30), 142),
};
const metrics = computeRisk(&candles);
const metrics = computeRisk(&candles, default_risk_free_rate);
try std.testing.expect(metrics != null);
// Max drawdown: (120 - 90) / 120 = 0.25
try std.testing.expectApproxEqAbs(@as(f64, 0.25), metrics.?.max_drawdown, 0.001);
@ -550,7 +552,7 @@ test "computeRisk insufficient data" {
candles[i] = makeCandle(Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), 100.0 + @as(f64, @floatFromInt(i)));
}
// Less than 21 candles -> returns null
try std.testing.expect(computeRisk(&candles) == null);
try std.testing.expect(computeRisk(&candles, default_risk_free_rate) == null);
}
test "adjustForNonStockAssets" {

27
src/cache/store.zig vendored
View file

@ -57,6 +57,17 @@ pub const DataType = enum {
.meta => "meta.srf",
};
}
pub fn ttl(self: DataType) i64 {
return switch (self) {
.dividends => Ttl.dividends,
.splits => Ttl.splits,
.options => Ttl.options,
.earnings => Ttl.earnings,
.etf_profile => Ttl.etf_profile,
.candles_daily, .candles_meta, .meta => 0,
};
}
};
/// Persistent SRF-backed cache with per-symbol, per-data-type files.
@ -84,7 +95,7 @@ pub const Store = struct {
// Generic typed API
/// Map a model type to its cache DataType.
fn dataTypeFor(comptime T: type) DataType {
pub fn dataTypeFor(comptime T: type) DataType {
return switch (T) {
Candle => .candles_daily,
Dividend => .dividends,
@ -97,7 +108,7 @@ pub const Store = struct {
}
/// The data payload for a given type: single struct for EtfProfile, slice for everything else.
fn DataFor(comptime T: type) type {
pub fn DataFor(comptime T: type) type {
return if (T == EtfProfile) EtfProfile else []T;
}
@ -121,6 +132,18 @@ pub const Store = struct {
defer self.allocator.free(data);
if (T == EtfProfile or T == OptionsChain) {
const is_negative = std.mem.eql(u8, data, negative_cache_content);
if (is_negative) {
if (freshness == .fresh_only) {
// Negative entries are always fresh return empty data
if (T == EtfProfile)
return .{ .data = EtfProfile{ .symbol = "" }, .timestamp = std.time.timestamp() };
if (T == OptionsChain)
return .{ .data = &.{}, .timestamp = std.time.timestamp() };
}
return null;
}
var reader = std.Io.Reader.fixed(data);
var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null;
defer it.deinit();

View file

@ -188,7 +188,7 @@ pub fn printBreakdownSection(out: *std.Io.Writer, items: []const zfin.analysis.B
if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]);
try out.writeAll(bar);
if (color) try fmt.ansiReset(out);
try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) });
try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoneyAbs(&val_buf, item.value) });
}
}

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("../root.zig");
pub const fmt = zfin.format;
pub const fmt = @import("../format.zig");
// Default CLI colors (match TUI default Monokai theme)
pub const CLR_POSITIVE = [3]u8{ 0x7f, 0xd8, 0x8f }; // gains (TUI .positive)

View file

@ -1,6 +1,7 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data.
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,
@ -115,7 +116,7 @@ fn enrichPortfolio(allocator: std.mem.Allocator, svc: *zfin.DataService, file_pa
for (syms, 0..) |sym, i| {
// Skip CUSIPs and known non-stock symbols
if (cli.fmt.isCusipLike(sym)) {
if (isCusipLike(sym)) {
// Find the display name for this CUSIP
const display: []const u8 = sym;
var note: ?[]const u8 = null;

View file

@ -1,9 +1,10 @@
const std = @import("std");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
if (!cli.fmt.isCusipLike(cusip)) {
if (!isCusipLike(cusip)) {
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print("Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{cusip});
try cli.reset(out, color);

View file

@ -31,10 +31,10 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
return;
}
try display(out, allocator, ch, symbol, ntm, color);
try display(out, ch, symbol, ntm, color);
}
pub fn display(out: *std.Io.Writer, allocator: std.mem.Allocator, chains: []const zfin.OptionsChain, symbol: []const u8, ntm: usize, color: bool) !void {
pub fn display(out: *std.Io.Writer, chains: []const zfin.OptionsChain, symbol: []const u8, ntm: usize, color: bool) !void {
if (chains.len == 0) return;
try cli.setBold(out, color);
@ -43,7 +43,7 @@ pub fn display(out: *std.Io.Writer, allocator: std.mem.Allocator, chains: []cons
try out.print("========================================\n", .{});
if (chains[0].underlying_price) |price| {
var price_buf: [24]u8 = undefined;
try out.print("Underlying: {s} {d} expiration(s) +/- {d} strikes NTM\n", .{ fmt.fmtMoney(&price_buf, price), chains.len, ntm });
try out.print("Underlying: {s} {d} expiration(s) +/- {d} strikes NTM\n", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, ntm });
} else {
try out.print("{d} expiration(s) available\n", .{chains.len});
}
@ -78,10 +78,10 @@ pub fn display(out: *std.Io.Writer, allocator: std.mem.Allocator, chains: []cons
try out.print("\n", .{});
// Print calls
try printSection(out, allocator, "CALLS", chain.calls, atm_price, ntm, true, color);
try printSection(out, "CALLS", chain.calls, atm_price, ntm, true, color);
try out.print("\n", .{});
// Print puts
try printSection(out, allocator, "PUTS", chain.puts, atm_price, ntm, false, color);
try printSection(out, "PUTS", chain.puts, atm_price, ntm, false, color);
} else {
try cli.setFg(out, color, if (is_monthly) cli.CLR_HEADER else cli.CLR_MUTED);
try out.print("{s} ({d} calls, {d} puts)", .{
@ -98,7 +98,6 @@ pub fn display(out: *std.Io.Writer, allocator: std.mem.Allocator, chains: []cons
pub fn printSection(
out: *std.Io.Writer,
allocator: std.mem.Allocator,
label: []const u8,
contracts: []const zfin.OptionContract,
atm_price: f64,
@ -122,8 +121,8 @@ pub fn printSection(
for (filtered) |c| {
const itm = if (is_calls) c.strike <= atm_price else c.strike >= atm_price;
const prefix: []const u8 = if (itm) " |" else " ";
const line = try fmt.fmtContractLine(allocator, prefix, c);
defer allocator.free(line);
var contract_buf: [128]u8 = undefined;
const line = fmt.fmtContractLine(&contract_buf, prefix, c);
try out.print("{s}\n", .{line});
}
}
@ -139,7 +138,7 @@ test "printSection shows header and contracts" {
.{ .contract_type = .call, .strike = 150.0, .expiration = .{ .days = 20100 }, .bid = 5.0, .ask = 5.50, .last_price = 5.25 },
.{ .contract_type = .call, .strike = 155.0, .expiration = .{ .days = 20100 }, .bid = 2.0, .ask = 2.50, .last_price = 2.25 },
};
try printSection(&w, gpa.allocator(), "CALLS", &calls, 152.0, 8, true, false);
try printSection(&w, "CALLS", &calls, 152.0, 8, true, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "CALLS") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "Strike") != null);
@ -156,7 +155,7 @@ test "display shows chain header no color" {
const chains = [_]zfin.OptionsChain{
.{ .underlying_symbol = "SPY", .underlying_price = 500.0, .expiration = .{ .days = 20100 }, .calls = &calls, .puts = &puts },
};
try display(&w, gpa.allocator(), &chains, "SPY", 8, false);
try display(&w, &chains, "SPY", 8, false);
const out = w.buffered();
try std.testing.expect(std.mem.indexOf(u8, out, "Options Chain for SPY") != null);
try std.testing.expect(std.mem.indexOf(u8, out, "1 expiration(s)") != null);

View file

@ -41,7 +41,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
}
try cli.reset(out, color);
var close_buf: [24]u8 = undefined;
try out.print(")\nLatest close: {s}\n", .{fmt.fmtMoney(&close_buf, c[c.len - 1].close)});
try out.print(")\nLatest close: {s}\n", .{fmt.fmtMoneyAbs(&close_buf, c[c.len - 1].close)});
const has_divs = result.asof_total != null;

View file

@ -234,12 +234,12 @@ pub fn display(
var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined;
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoney(&val_buf, summary.total_value), fmt.fmtMoney(&cost_buf, summary.total_cost) });
try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoneyAbs(&val_buf, summary.total_value), fmt.fmtMoneyAbs(&cost_buf, summary.total_cost) });
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
if (summary.unrealized_gain_loss >= 0) {
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
} else {
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
}
try cli.reset(out, color);
try out.print("\n", .{});
@ -311,7 +311,7 @@ pub fn display(
var price_buf2: [24]u8 = undefined;
var gl_val_buf: [24]u8 = undefined;
const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs);
const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs);
const sign: []const u8 = if (a.unrealized_gain_loss >= 0) "+" else "-";
// Date + ST/LT for single-lot positions
@ -328,10 +328,10 @@ pub fn display(
if (a.is_manual_price) try cli.setFg(out, color, cli.CLR_WARNING);
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} ", .{
a.display_symbol, a.shares, fmt.fmtMoney2(&cost_buf2, a.avg_cost),
a.display_symbol, a.shares, fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost),
});
try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)});
try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)});
try out.print("{s:>10}", .{fmt.fmtMoneyAbs(&price_buf2, a.current_price)});
try out.print(" {s:>16} ", .{fmt.fmtMoneyAbs(&mv_buf, a.market_value)});
try cli.setGainLoss(out, color, a.unrealized_gain_loss);
try out.print("{s}{s:>13}", .{ sign, gl_money });
if (a.is_manual_price) {
@ -388,7 +388,7 @@ pub fn display(
try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
drip.st.lot_count,
drip.st.shares,
fmt.fmtMoney2(&avg_buf, drip.st.avgCost()),
fmt.fmtMoneyAbs(&avg_buf, drip.st.avgCost()),
if (drip.st.first_date) |d| d.format(&d1_buf)[0..7] else "?",
if (drip.st.last_date) |d| d.format(&d2_buf)[0..7] else "?",
});
@ -402,7 +402,7 @@ pub fn display(
try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
drip.lt.lot_count,
drip.lt.shares,
fmt.fmtMoney2(&avg_buf2, drip.lt.avgCost()),
fmt.fmtMoneyAbs(&avg_buf2, drip.lt.avgCost()),
if (drip.lt.first_date) |d| d.format(&d1_buf2)[0..7] else "?",
if (drip.lt.last_date) |d| d.format(&d2_buf2)[0..7] else "?",
});
@ -423,13 +423,13 @@ pub fn display(
var total_gl_buf: [24]u8 = undefined;
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
try out.print(" {s:>6} {s:>8} {s:>10} {s:>10} {s:>16} ", .{
"", "", "", "TOTAL", fmt.fmtMoney(&total_mv_buf, summary.total_value),
"", "", "", "TOTAL", fmt.fmtMoneyAbs(&total_mv_buf, summary.total_value),
});
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
if (summary.unrealized_gain_loss >= 0) {
try out.print("+{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)});
try out.print("+{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
} else {
try out.print("-{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)});
try out.print("-{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
}
try cli.reset(out, color);
try out.print(" {s:>7}\n", .{"100.0%"});
@ -440,9 +440,9 @@ pub fn display(
const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss;
try cli.setGainLoss(out, color, summary.realized_gain_loss);
if (summary.realized_gain_loss >= 0) {
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)});
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
} else {
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)});
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
}
try cli.reset(out, color);
}
@ -475,8 +475,8 @@ pub fn display(
try out.print(" {s:<30} {d:>6.0} {s:>12} {s:>14} {s}\n", .{
lot.symbol,
qty,
fmt.fmtMoney2(&cost_per_buf, cost_per),
fmt.fmtMoney(&total_cost_buf, total_cost_opt),
fmt.fmtMoneyAbs(&cost_per_buf, cost_per),
fmt.fmtMoneyAbs(&total_cost_buf, total_cost_opt),
acct,
});
}
@ -486,7 +486,7 @@ pub fn display(
try cli.reset(out, color);
var opt_total_buf: [24]u8 = undefined;
try out.print(" {s:>30} {s:>6} {s:>12} {s:>14}\n", .{
"", "", "TOTAL", fmt.fmtMoney(&opt_total_buf, opt_total_cost),
"", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_cost),
});
}
@ -530,7 +530,7 @@ pub fn display(
const note_display = if (note_str.len > 50) note_str[0..50] else note_str;
try out.print(" {s:<12} {s:>14} {s:>7} {s:>10} {s}\n", .{
lot.symbol,
fmt.fmtMoney(&face_buf, lot.shares),
fmt.fmtMoneyAbs(&face_buf, lot.shares),
rate_str,
mat_str,
note_display,
@ -542,7 +542,7 @@ pub fn display(
try cli.reset(out, color);
var cd_total_buf: [24]u8 = undefined;
try out.print(" {s:>12} {s:>14}\n", .{
"TOTAL", fmt.fmtMoney(&cd_total_buf, cd_section_total),
"TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total),
});
}
@ -615,9 +615,9 @@ pub fn display(
try out.print("\n", .{});
try cli.setBold(out, color);
try out.print(" Net Worth: {s} (Liquid: {s} Illiquid: {s})\n", .{
fmt.fmtMoney(&nw_buf, net_worth),
fmt.fmtMoney(&liq_buf, summary.total_value),
fmt.fmtMoney(&il_buf, illiquid_total),
fmt.fmtMoneyAbs(&nw_buf, net_worth),
fmt.fmtMoneyAbs(&liq_buf, summary.total_value),
fmt.fmtMoneyAbs(&il_buf, illiquid_total),
});
try cli.reset(out, color);
}
@ -631,7 +631,7 @@ pub fn display(
for (watch_symbols) |sym| {
var price_str: [16]u8 = undefined;
const ps: []const u8 = if (watch_prices.get(sym)) |close|
fmt.fmtMoney2(&price_str, close)
fmt.fmtMoneyAbs(&price_str, close)
else
"--";
try out.print(" " ++ fmt.sym_col_spec ++ " {s:>10}\n", .{ sym, ps });
@ -644,7 +644,7 @@ pub fn display(
for (summary.allocations) |a| {
if (candle_map.get(a.symbol)) |candles| {
if (zfin.risk.computeRisk(candles)) |metrics| {
if (zfin.risk.computeRisk(candles, zfin.risk.default_risk_free_rate)) |metrics| {
if (!any_risk) {
try out.print("\n", .{});
try cli.setBold(out, color);
@ -693,15 +693,15 @@ pub fn printLotRow(out: *std.Io.Writer, color: bool, lot: zfin.Lot, current_pric
const gl = lot.shares * (use_price - lot.open_price);
var lot_gl_buf: [24]u8 = undefined;
const lot_gl_abs = if (gl >= 0) gl else -gl;
const lot_gl_money = fmt.fmtMoney(&lot_gl_buf, lot_gl_abs);
const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_buf, lot_gl_abs);
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
var lot_mv_buf: [24]u8 = undefined;
const lot_mv = fmt.fmtMoney(&lot_mv_buf, lot.shares * use_price);
const lot_mv = fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price);
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" " ++ fmt.sym_col_spec ++ " {d:>8.1} {s:>10} {s:>10} {s:>16} ", .{
status_str, lot.shares, fmt.fmtMoney2(&lot_price_buf, lot.open_price), "", lot_mv,
status_str, lot.shares, fmt.fmtMoneyAbs(&lot_price_buf, lot.open_price), "", lot_mv,
});
try cli.reset(out, color);
try cli.setGainLoss(out, color, gl);

View file

@ -53,10 +53,10 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
try cli.setBold(out, color);
if (quote) |q| {
var price_buf: [24]u8 = undefined;
try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoney(&price_buf, q.price) });
try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoneyAbs(&price_buf, q.price) });
} else if (candles.len > 0) {
var price_buf: [24]u8 = undefined;
try out.print("\n{s} {s} (close)\n", .{ symbol, fmt.fmtMoney(&price_buf, candles[candles.len - 1].close) });
try out.print("\n{s} {s} (close)\n", .{ symbol, fmt.fmtMoneyAbs(&price_buf, candles[candles.len - 1].close) });
} else {
try out.print("\n{s}\n", .{symbol});
}

View file

@ -1,6 +1,6 @@
//! Shared formatting utilities used by both CLI and TUI.
//!
//! Number formatting (fmtMoney, fmtIntCommas, etc.), financial helpers
//! Number formatting (fmtMoneyAbs, fmtIntCommas, etc.), financial helpers
//! (capitalGainsIndicator, filterNearMoney), and braille chart computation.
const std = @import("std");
@ -52,7 +52,7 @@ pub fn fmtCashHeader(buf: []u8) []const u8 {
/// Returns a slice of `buf`.
pub fn fmtCashRow(buf: []u8, account: []const u8, amount: f64, note: ?[]const u8) []const u8 {
var money_buf: [24]u8 = undefined;
const money = fmtMoney(&money_buf, amount);
const money = fmtMoneyAbs(&money_buf, amount);
const w = cash_acct_width;
// " {name:<w} {money:>14} {note}"
const prefix = " ";
@ -102,7 +102,7 @@ pub fn fmtCashSep(buf: []u8) []const u8 {
/// Format the cash total row.
pub fn fmtCashTotal(buf: []u8, total: f64) []const u8 {
var money_buf: [24]u8 = undefined;
const money = fmtMoney(&money_buf, total);
const money = fmtMoneyAbs(&money_buf, total);
const w = cash_acct_width;
var pos: usize = 0;
@memcpy(buf[0..2], " ");
@ -157,19 +157,16 @@ pub fn fmtIlliquidRow(buf: []u8, name: []const u8, value: f64, note: ?[]const u8
}
/// Format the illiquid total separator line.
pub fn fmtIlliquidSep(buf: []u8) []const u8 {
return fmtCashSep(buf);
}
pub const fmtIlliquidSep = fmtCashSep;
/// Format the illiquid total row.
pub fn fmtIlliquidTotal(buf: []u8, total: f64) []const u8 {
return fmtCashTotal(buf, total);
}
pub const fmtIlliquidTotal = fmtCashTotal;
// Number formatters
/// Format a dollar amount with commas and 2 decimals: $1,234.56
pub fn fmtMoney(buf: []u8, amount: f64) []const u8 {
/// Always returns the absolute value callers handle sign display.
pub fn fmtMoneyAbs(buf: []u8, amount: f64) []const u8 {
const cents = @as(i64, @intFromFloat(@round(amount * 100.0)));
const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents));
const dollars = abs_cents / 100;
@ -213,11 +210,6 @@ pub fn fmtMoney(buf: []u8, amount: f64) []const u8 {
return buf[0..len];
}
/// Format price with 2 decimals (no commas, for per-share prices): $185.23
pub fn fmtMoney2(buf: []u8, amount: f64) []const u8 {
return std.fmt.bufPrint(buf, "${d:.2}", .{amount}) catch "$?";
}
/// Format an integer with commas (e.g. 1234567 -> "1,234,567").
pub fn fmtIntCommas(buf: []u8, value: u64) []const u8 {
var tmp: [32]u8 = undefined;
@ -252,13 +244,13 @@ pub fn fmtTimeAgo(buf: []u8, timestamp: i64) []const u8 {
const delta = now - timestamp;
if (delta < 0) return "just now";
if (delta < 60) return "just now";
if (delta < 3600) {
if (delta < std.time.s_per_hour) {
return std.fmt.bufPrint(buf, "{d}m ago", .{@as(u64, @intCast(@divFloor(delta, 60)))}) catch "?";
}
if (delta < 86400) {
return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, 3600)))}) catch "?";
if (delta < std.time.s_per_day) {
return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_hour)))}) catch "?";
}
return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, 86400)))}) catch "?";
return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_day)))}) catch "?";
}
/// Format large numbers with T/B/M suffixes (e.g. "1.5B", "45.6M").
@ -281,7 +273,7 @@ pub fn fmtLargeNum(val: f64) [15]u8 {
/// Get today's date.
pub fn todayDate() Date {
const ts = std.time.timestamp();
const days: i32 = @intCast(@divFloor(ts, 86400));
const days: i32 = @intCast(@divFloor(ts, std.time.s_per_day));
return .{ .days = days };
}
@ -336,20 +328,6 @@ pub fn isMonthlyExpiration(date: Date) bool {
return d >= 15 and d <= 21; // 3rd Friday is between 15th and 21st
}
/// Check if a string looks like a CUSIP (9 alphanumeric characters).
/// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit.
/// This is a heuristic it won't catch all CUSIPs and may have false positives.
pub fn isCusipLike(s: []const u8) bool {
if (s.len != 9) return false;
// Must contain at least one digit (all-alpha would be a ticker)
var has_digit = false;
for (s) |c| {
if (!std.ascii.isAlphanumeric(c)) return false;
if (std.ascii.isDigit(c)) has_digit = true;
}
return has_digit;
}
/// Convert a string to title case ("TECHNOLOGY" -> "Technology", "CONSUMER CYCLICAL" -> "Consumer Cyclical").
/// Writes into a caller-provided buffer and returns the slice.
pub fn toTitleCase(buf: []u8, s: []const u8) []const u8 {
@ -370,7 +348,7 @@ pub fn toTitleCase(buf: []u8, s: []const u8) []const u8 {
}
/// Format an options contract line: strike + last + bid + ask + volume + OI + IV.
pub fn fmtContractLine(alloc: std.mem.Allocator, prefix: []const u8, c: OptionContract) ![]const u8 {
pub fn fmtContractLine(buf: []u8, prefix: []const u8, c: OptionContract) []const u8 {
var last_buf: [12]u8 = undefined;
const last_str = if (c.last_price) |p| std.fmt.bufPrint(&last_buf, "{d:>10.2}", .{p}) catch "--" else "--";
var bid_buf: [12]u8 = undefined;
@ -384,9 +362,9 @@ pub fn fmtContractLine(alloc: std.mem.Allocator, prefix: []const u8, c: OptionCo
var iv_buf: [10]u8 = undefined;
const iv_str = if (c.implied_volatility) |iv| std.fmt.bufPrint(&iv_buf, "{d:>6.1}%", .{iv * 100.0}) catch "--" else "--";
return std.fmt.allocPrint(alloc, "{s}{d:>10.2} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}", .{
return std.fmt.bufPrint(buf, "{s}{d:>10.2} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}", .{
prefix, c.strike, last_str, bid_str, ask_str, vol_str, oi_str, iv_str,
});
}) catch "";
}
// Portfolio helpers
@ -449,8 +427,6 @@ pub fn aggregateDripLots(lots: []const Lot) DripAggregation {
return result;
}
// Color helpers
// Shared rendering helpers (CLI + TUI)
/// Layout constants for analysis breakdown views.
@ -465,7 +441,7 @@ pub fn fmtGainLoss(buf: []u8, pnl: f64) GainLossResult {
const positive = pnl >= 0;
const abs_val = if (positive) pnl else -pnl;
var money_buf: [24]u8 = undefined;
const money = fmtMoney(&money_buf, abs_val);
const money = fmtMoneyAbs(&money_buf, abs_val);
const sign: []const u8 = if (positive) "+" else "-";
const text = std.fmt.bufPrint(buf, "{s}{s}", .{ sign, money }) catch "?";
return .{ .text = text, .positive = positive };
@ -694,18 +670,14 @@ pub const BrailleChart = struct {
return self.patterns[row * self.n_cols + col];
}
/// Format a date as "MMM DD" or "MMM 'YY" depending on whether it's the same year as `ref_year`.
/// Format a date as "MMM DD" for the braille chart x-axis.
/// The year context is already visible in the surrounding CLI/TUI interface.
/// Returns the number of bytes written.
pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 {
const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
const m = date.month();
const d = date.day();
const y = date.year();
const mon = if (m >= 1 and m <= 12) months[m - 1] else "???";
// Use "MMM DD 'YY" is too long (10 chars). Use "MMM 'YY" (7 chars) for year context,
// or "MMM DD" (6 chars) for day-level precision. We'll use "MMM DD" for compactness
// and add the year as a separate concern if dates span multiple years.
// Actually let's just use "YYYY-MM-DD" is too long. "Mon DD" is 6 chars.
buf[0] = mon[0];
buf[1] = mon[1];
buf[2] = mon[2];
@ -716,8 +688,6 @@ pub const BrailleChart = struct {
buf[4] = '0';
}
buf[5] = '0' + d % 10;
// If we want to show year when it differs, store in extra chars:
_ = y;
return buf[0..6];
}
@ -922,28 +892,19 @@ pub fn ansiReset(out: anytype) !void {
// Tests
test "fmtMoney" {
test "fmtMoneyAbs" {
var buf: [24]u8 = undefined;
try std.testing.expectEqualStrings("$0.00", fmtMoney(&buf, 0));
try std.testing.expectEqualStrings("$1.23", fmtMoney(&buf, 1.23));
try std.testing.expectEqualStrings("$1,234.56", fmtMoney(&buf, 1234.56));
try std.testing.expectEqualStrings("$1,234,567.89", fmtMoney(&buf, 1234567.89));
try std.testing.expectEqualStrings("$0.00", fmtMoneyAbs(&buf, 0));
try std.testing.expectEqualStrings("$1.23", fmtMoneyAbs(&buf, 1.23));
try std.testing.expectEqualStrings("$1,234.56", fmtMoneyAbs(&buf, 1234.56));
try std.testing.expectEqualStrings("$1,234,567.89", fmtMoneyAbs(&buf, 1234567.89));
}
test "fmtMoney negative" {
// Negative amounts: the function uses abs(cents) so the sign is lost
// (implementation detail: no minus sign is produced, result is same as positive)
test "fmtMoneyAbs negative" {
// Returns absolute value callers handle sign display.
var buf: [24]u8 = undefined;
// Verify it doesn't crash on negative input
const result = fmtMoney(&buf, -1234.56);
try std.testing.expect(result.len > 0);
}
test "fmtMoney2" {
var buf: [24]u8 = undefined;
try std.testing.expectEqualStrings("$185.23", fmtMoney2(&buf, 185.23));
try std.testing.expectEqualStrings("$0.00", fmtMoney2(&buf, 0.0));
try std.testing.expectEqualStrings("$0.50", fmtMoney2(&buf, 0.5));
const result = fmtMoneyAbs(&buf, -1234.56);
try std.testing.expectEqualStrings("$1,234.56", result);
}
test "fmtIntCommas" {
@ -1078,17 +1039,6 @@ test "isMonthlyExpiration" {
try std.testing.expect(!isMonthlyExpiration(Date.fromYmd(2024, 1, 17)));
}
test "isCusipLike" {
try std.testing.expect(isCusipLike("02315N600")); // Vanguard Target 2035
try std.testing.expect(isCusipLike("02315N709")); // Vanguard Target 2040
try std.testing.expect(isCusipLike("459200101")); // IBM
try std.testing.expect(isCusipLike("06051XJ45")); // CD CUSIP
try std.testing.expect(!isCusipLike("AAPL")); // Too short
try std.testing.expect(!isCusipLike("ABCDEFGHI")); // No digits
try std.testing.expect(isCusipLike("NON40OR52")); // Looks cusip-like (has digits, 9 chars)
try std.testing.expect(!isCusipLike("12345")); // Too short
}
test "toTitleCase" {
var buf: [64]u8 = undefined;
try std.testing.expectEqualStrings("Technology", toTitleCase(&buf, "TECHNOLOGY"));
@ -1264,7 +1214,7 @@ test "computeBrailleChart insufficient data" {
}
test "fmtContractLine" {
const alloc = std.testing.allocator;
var buf: [128]u8 = undefined;
const contract = OptionContract{
.strike = 150.0,
.contract_type = .call,
@ -1276,22 +1226,20 @@ test "fmtContractLine" {
.open_interest = 5678,
.implied_volatility = 0.25,
};
const line = try fmtContractLine(alloc, "C ", contract);
defer alloc.free(line);
const line = fmtContractLine(&buf, "C ", contract);
try std.testing.expect(std.mem.indexOf(u8, line, "150.00") != null);
try std.testing.expect(std.mem.indexOf(u8, line, "5.25") != null);
try std.testing.expect(std.mem.indexOf(u8, line, "1234") != null);
}
test "fmtContractLine null fields" {
const alloc = std.testing.allocator;
var buf: [128]u8 = undefined;
const contract = OptionContract{
.strike = 200.0,
.contract_type = .put,
.expiration = Date.fromYmd(2024, 6, 21),
};
const line = try fmtContractLine(alloc, "P ", contract);
defer alloc.free(line);
const line = fmtContractLine(&buf, "P ", contract);
try std.testing.expect(std.mem.indexOf(u8, line, "200.00") != null);
// Null fields should show "--"
try std.testing.expect(std.mem.indexOf(u8, line, "--") != null);

View file

@ -81,7 +81,7 @@ pub fn main() !u8 {
for (args[1..]) |arg| {
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true;
}
const color = zfin.format.shouldUseColor(no_color_flag);
const color = @import("format.zig").shouldUseColor(no_color_flag);
var config = zfin.Config.fromEnv(allocator);
defer config.deinit();
@ -103,6 +103,11 @@ pub fn main() !u8 {
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
// Normalize symbol to uppercase (e.g. "aapl" -> "AAPL")
if (args.len >= 3) {
for (args[2]) |*c| c.* = std.ascii.toUpper(c.*);
}
if (std.mem.eql(u8, command, "perf")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");

View file

@ -346,6 +346,20 @@ pub const Portfolio = struct {
}
};
/// Check if a string looks like a CUSIP (9 alphanumeric characters).
/// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit.
/// This is a heuristic -- it won't catch all CUSIPs and may have false positives.
pub fn isCusipLike(s: []const u8) bool {
if (s.len != 9) return false;
// Must contain at least one digit (all-alpha would be a ticker)
var has_digit = false;
for (s) |c| {
if (!std.ascii.isAlphanumeric(c)) return false;
if (std.ascii.isDigit(c)) has_digit = true;
}
return has_digit;
}
test "lot basics" {
const lot = Lot{
.symbol = "AAPL",

View file

@ -1,19 +0,0 @@
pub const SecurityType = enum {
stock,
etf,
mutual_fund,
index,
crypto,
forex,
unknown,
};
/// Basic information about a ticker symbol.
pub const TickerInfo = struct {
symbol: []const u8,
name: ?[]const u8 = null,
exchange: ?[]const u8 = null,
security_type: SecurityType = .unknown,
currency: ?[]const u8 = null,
country: ?[]const u8 = null,
};

View file

@ -59,6 +59,13 @@ pub fn acquire(self: *RateLimiter) void {
}
}
/// Sleep until a token is likely available, with a minimum 2-second floor.
/// Use after receiving a server-side 429 to wait before retrying.
pub fn backoff(self: *RateLimiter) void {
const wait_ns: u64 = @max(self.estimateWaitNs(), 2 * std.time.ns_per_s);
std.Thread.sleep(wait_ns);
}
/// Returns estimated wait time in nanoseconds until a token is available.
/// Returns 0 if a token is available now.
pub fn estimateWaitNs(self: *RateLimiter) u64 {

View file

@ -51,15 +51,25 @@ pub const Client = struct {
fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
var attempt: u8 = 0;
while (true) : (attempt += 1) {
if (self.doRequest(method, url, body, extra_headers)) |response| {
return classifyResponse(response);
} else |_| {
const response = self.doRequest(method, url, body, extra_headers) catch {
if (attempt >= self.max_retries) return HttpError.RequestFailed;
self.backoffSleep(attempt);
continue;
};
return classifyResponse(response) catch |err| {
if (err == HttpError.ServerError and attempt < self.max_retries) {
self.backoffSleep(attempt);
continue;
}
return err;
};
}
}
fn backoffSleep(self: *Client, attempt: u8) void {
const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt);
std.Thread.sleep(backoff * std.time.ns_per_ms);
}
}
}
fn doRequest(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
var aw: std.Io.Writer.Allocating = .init(self.allocator);
@ -70,13 +80,8 @@ pub const Client = struct {
.payload = body,
.extra_headers = extra_headers,
.response_writer = &aw.writer,
}) catch |err| {
}) catch {
aw.deinit();
// TLS 1.2-only hosts (e.g., finnhub.io) fail with Zig's TLS 1.3-only client.
// Fall back to system curl for these cases.
if (err == error.TlsInitializationFailed) {
return curlRequest(self.allocator, method, url, body, extra_headers);
}
return HttpError.RequestFailed;
};
@ -93,82 +98,23 @@ pub const Client = struct {
}
fn classifyResponse(response: Response) HttpError!Response {
switch (response.status) {
.ok => return response,
else => {
response.allocator.free(response.body);
return switch (response.status) {
.ok => response,
.too_many_requests => HttpError.RateLimited,
.unauthorized, .forbidden => HttpError.Unauthorized,
.not_found => HttpError.NotFound,
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
else => HttpError.InvalidResponse,
};
},
}
}
};
/// Fallback HTTP request using system curl for TLS 1.2 hosts.
fn curlRequest(
allocator: std.mem.Allocator,
method: std.http.Method,
url: []const u8,
body: ?[]const u8,
extra_headers: []const std.http.Header,
) HttpError!Response {
var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(allocator);
// Heap-allocated strings that need freeing after Child.run
var to_free: std.ArrayList([]const u8) = .empty;
defer {
for (to_free.items) |s| allocator.free(s);
to_free.deinit(allocator);
}
argv.appendSlice(allocator, &.{ "curl", "-sS", "-f", "-L", "--max-time", "30" }) catch
return HttpError.OutOfMemory;
if (method != .GET) {
argv.appendSlice(allocator, &.{ "-X", @tagName(method) }) catch
return HttpError.OutOfMemory;
}
for (extra_headers) |hdr| {
const val = std.fmt.allocPrint(allocator, "{s}: {s}", .{ hdr.name, hdr.value }) catch
return HttpError.OutOfMemory;
to_free.append(allocator, val) catch return HttpError.OutOfMemory;
argv.appendSlice(allocator, &.{ "-H", val }) catch return HttpError.OutOfMemory;
}
if (body) |b| {
argv.appendSlice(allocator, &.{ "-d", b }) catch return HttpError.OutOfMemory;
}
argv.append(allocator, url) catch return HttpError.OutOfMemory;
const result = std.process.Child.run(.{
.allocator = allocator,
.argv = argv.items,
.max_output_bytes = 10 * 1024 * 1024,
}) catch return HttpError.RequestFailed;
allocator.free(result.stderr);
const success = switch (result.term) {
.Exited => |code| code == 0,
else => false,
};
if (!success) {
allocator.free(result.stdout);
return HttpError.RequestFailed;
}
return .{
.status = .ok,
.body = result.stdout,
.allocator = allocator,
};
}
/// Build a URL with query parameters.
/// Build a URL with query parameters. Values are percent-encoded per RFC 3986.
pub fn buildUrl(
allocator: std.mem.Allocator,
base: []const u8,
@ -182,20 +128,26 @@ pub fn buildUrl(
try aw.writer.writeByte(if (i == 0) '?' else '&');
try aw.writer.writeAll(param[0]);
try aw.writer.writeByte('=');
for (param[1]) |c| {
switch (c) {
' ' => try aw.writer.writeAll("%20"),
'&' => try aw.writer.writeAll("%26"),
'=' => try aw.writer.writeAll("%3D"),
'+' => try aw.writer.writeAll("%2B"),
else => try aw.writer.writeByte(c),
}
}
try std.Uri.Component.percentEncode(&aw.writer, param[1], isQueryValueChar);
}
return aw.toOwnedSlice();
}
/// RFC 3986 query-safe characters, excluding '&' and '=' which delimit
/// key=value pairs within the query string.
fn isQueryValueChar(c: u8) bool {
return switch (c) {
// Unreserved characters (RFC 3986 section 2.3)
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true,
// Sub-delimiters safe in query values (excludes '&' and '=')
'!', '$', '\'', '(', ')', '*', '+', ',', ';' => true,
// Additional query/path characters
':', '@', '/', '?' => true,
else => false,
};
}
test "buildUrl" {
const allocator = std.testing.allocator;
const url = try buildUrl(allocator, "https://api.example.com/v1/data", &.{

View file

@ -16,7 +16,7 @@ const json_utils = @import("json_utils.zig");
const optFloat = json_utils.optFloat;
const jsonStr = json_utils.jsonStr;
const base_url = "https://finnhub.io/api/v1";
const base_url = "https://api.finnhub.io/api/v1";
pub const Finnhub = struct {
api_key: []const u8,

View file

@ -18,9 +18,6 @@
//!
//! For portfolio workflows, load a Portfolio from an SRF file and pass
//! it through `risk` and `performance` for analytics.
//!
//! The `format` module contains shared rendering helpers used by both
//! the CLI commands and TUI.
// Data Models
@ -89,12 +86,6 @@ pub const analysis = @import("analytics/analysis.zig");
/// Sector/industry/country classification for enriched securities.
pub const classification = @import("models/classification.zig");
// Formatting
/// Shared rendering helpers (money formatting, charts, earnings rows, bars)
/// used by both CLI commands and TUI.
pub const format = @import("format.zig");
// Service Layer
/// High-level data service: orchestrates providers, caching, and fallback logic.

View file

@ -18,6 +18,7 @@ const Quote = @import("models/quote.zig").Quote;
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 TwelveData = @import("providers/twelvedata.zig").TwelveData;
const Polygon = @import("providers/polygon.zig").Polygon;
const Finnhub = @import("providers/finnhub.zig").Finnhub;
@ -25,6 +26,7 @@ const Cboe = @import("providers/cboe.zig").Cboe;
const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
const alphavantage = @import("providers/alphavantage.zig");
const OpenFigi = @import("providers/openfigi.zig");
const fmt = @import("format.zig");
const performance = @import("analytics/performance.zig");
pub const DataError = error{
@ -47,6 +49,15 @@ pub const Source = enum {
fetched,
};
/// Generic result type for all fetch operations: data payload + provenance metadata.
pub fn FetchResult(comptime T: type) type {
return struct {
data: cache.Store.DataFor(T),
source: Source,
timestamp: i64,
};
}
// PostProcess callbacks
// These are passed to Store.read to handle type-specific
// concerns: string duping (serialization plumbing) and domain transforms.
@ -95,40 +106,40 @@ pub const DataService = struct {
if (self.av) |*av| av.deinit();
}
// Provider accessors
// Provider accessor
fn getTwelveData(self: *DataService) DataError!*TwelveData {
if (self.td) |*td| return td;
const key = self.config.twelvedata_key orelse return DataError.NoApiKey;
self.td = TwelveData.init(self.allocator, key);
return &self.td.?;
fn getProvider(self: *DataService, comptime T: type) DataError!*T {
const field_name = comptime providerField(T);
if (@field(self, field_name)) |*p| return p;
if (T == Cboe) {
// CBOE has no key
@field(self, field_name) = T.init(self.allocator);
} else {
// All we're doing here is lower casing the type name, then
// appending _key to it, so AlphaVantage -> alphavantage_key
const config_key = comptime blk: {
const full = @typeName(T);
var start: usize = 0;
for (full, 0..) |c, i| {
if (c == '.') start = i + 1;
}
const short = full[start..];
var buf: [short.len + 4]u8 = undefined;
_ = std.ascii.lowerString(buf[0..short.len], short);
@memcpy(buf[short.len..][0..4], "_key");
break :blk buf[0 .. short.len + 4];
};
const key = @field(self.config, config_key) orelse return DataError.NoApiKey;
@field(self, field_name) = T.init(self.allocator, key);
}
return &@field(self, field_name).?;
}
fn getPolygon(self: *DataService) DataError!*Polygon {
if (self.pg) |*pg| return pg;
const key = self.config.polygon_key orelse return DataError.NoApiKey;
self.pg = Polygon.init(self.allocator, key);
return &self.pg.?;
fn providerField(comptime T: type) []const u8 {
inline for (std.meta.fields(DataService)) |f| {
if (f.type == ?T) return f.name;
}
fn getFinnhub(self: *DataService) DataError!*Finnhub {
if (self.fh) |*fh| return fh;
const key = self.config.finnhub_key orelse return DataError.NoApiKey;
self.fh = Finnhub.init(self.allocator, key);
return &self.fh.?;
}
fn getCboe(self: *DataService) *Cboe {
if (self.cboe) |*c| return c;
self.cboe = Cboe.init(self.allocator);
return &self.cboe.?;
}
fn getAlphaVantage(self: *DataService) DataError!*AlphaVantage {
if (self.av) |*av| return av;
const key = self.config.alphavantage_key orelse return DataError.NoApiKey;
self.av = AlphaVantage.init(self.allocator, key);
return &self.av.?;
@compileError("unknown provider type");
}
// Cache helper
@ -137,6 +148,59 @@ pub const DataService = struct {
return cache.Store.init(self.allocator, self.config.cache_dir);
}
/// Generic fetch-or-cache for simple data types (dividends, splits, options).
/// Checks cache first; on miss, fetches from the appropriate provider,
/// writes to cache, and returns. On permanent fetch failure, writes a negative
/// cache entry. Rate limit failures are retried once.
fn fetchCached(
self: *DataService,
comptime T: type,
symbol: []const u8,
comptime postProcess: ?*const fn (*T, std.mem.Allocator) anyerror!void,
) DataError!FetchResult(T) {
var s = self.store();
const data_type = comptime cache.Store.dataTypeFor(T);
if (s.read(T, symbol, postProcess, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
const fetched = self.fetchFromProvider(T, symbol) catch |err| {
if (err == error.RateLimited) {
// Wait and retry once
self.rateLimitBackoff();
const retried = self.fetchFromProvider(T, symbol) catch {
return DataError.FetchFailed;
};
s.write(T, symbol, retried, data_type.ttl());
return .{ .data = retried, .source = .fetched, .timestamp = std.time.timestamp() };
}
s.writeNegative(symbol, data_type);
return DataError.FetchFailed;
};
s.write(T, symbol, fetched, data_type.ttl());
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
}
/// Dispatch a fetch to the correct provider based on model type.
fn fetchFromProvider(self: *DataService, comptime T: type, symbol: []const u8) !cache.Store.DataFor(T) {
return switch (T) {
Dividend => {
var pg = try self.getProvider(Polygon);
return pg.fetchDividends(self.allocator, symbol, null, null);
},
Split => {
var pg = try self.getProvider(Polygon);
return pg.fetchSplits(self.allocator, symbol);
},
OptionsChain => {
var cboe = try self.getProvider(Cboe);
return cboe.fetchOptionsChain(self.allocator, symbol);
},
else => @compileError("unsupported type for fetchFromProvider"),
};
}
/// Invalidate cached data for a symbol so the next get* call forces a fresh fetch.
pub fn invalidate(self: *DataService, symbol: []const u8, data_type: cache.DataType) void {
var s = self.store();
@ -154,9 +218,9 @@ pub const DataService = struct {
/// Uses incremental updates: when the cache is stale, only fetches
/// candles newer than the last cached date rather than re-fetching
/// the entire history.
pub fn getCandles(self: *DataService, symbol: []const u8) DataError!struct { data: []Candle, source: Source, timestamp: i64 } {
pub fn getCandles(self: *DataService, symbol: []const u8) DataError!FetchResult(Candle) {
var s = self.store();
const today = todayDate();
const today = fmt.todayDate();
// Check candle metadata for freshness (tiny file, no candle deserialization)
const meta_result = s.readCandleMeta(symbol);
@ -178,14 +242,22 @@ pub const DataService = struct {
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
} else {
// Incremental fetch from day after last cached candle
var td = self.getTwelveData() catch {
var td = self.getProvider(TwelveData) catch {
// No API key return stale data
if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
return DataError.NoApiKey;
};
const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch {
// Fetch failed return stale data rather than erroring
const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch |err| blk: {
if (err == error.RateLimited) {
self.rateLimitBackoff();
break :blk td.fetchCandles(self.allocator, symbol, fetch_from, today) catch {
if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
return DataError.FetchFailed;
};
}
// Non-rate-limit failure return stale data
if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
return DataError.FetchFailed;
@ -212,10 +284,17 @@ pub const DataService = struct {
}
// No usable cache full fetch (~10 years, plus buffer for leap years)
var td = try self.getTwelveData();
var td = try self.getProvider(TwelveData);
const from = today.addDays(-3700);
const fetched = td.fetchCandles(self.allocator, symbol, from, today) catch {
const fetched = td.fetchCandles(self.allocator, symbol, from, today) catch |err| blk: {
if (err == error.RateLimited) {
self.rateLimitBackoff();
break :blk td.fetchCandles(self.allocator, symbol, from, today) catch {
return DataError.FetchFailed;
};
}
s.writeNegative(symbol, .candles_daily);
return DataError.FetchFailed;
};
@ -227,70 +306,27 @@ pub const DataService = struct {
}
/// Fetch dividend history for a symbol.
/// Checks cache first; fetches from Polygon if stale/missing.
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!struct { data: []Dividend, source: Source, timestamp: i64 } {
var s = self.store();
if (s.read(Dividend, symbol, dividendPostProcess, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
var pg = try self.getPolygon();
const fetched = pg.fetchDividends(self.allocator, symbol, null, null) catch {
return DataError.FetchFailed;
};
if (fetched.len > 0) {
s.write(Dividend, symbol, fetched, cache.Ttl.dividends);
}
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!FetchResult(Dividend) {
return self.fetchCached(Dividend, symbol, dividendPostProcess);
}
/// Fetch split history for a symbol.
/// Checks cache first; fetches from Polygon if stale/missing.
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!struct { data: []Split, source: Source, timestamp: i64 } {
var s = self.store();
if (s.read(Split, symbol, null, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
var pg = try self.getPolygon();
const fetched = pg.fetchSplits(self.allocator, symbol) catch {
return DataError.FetchFailed;
};
s.write(Split, symbol, fetched, cache.Ttl.splits);
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!FetchResult(Split) {
return self.fetchCached(Split, symbol, null);
}
/// Fetch options chain for a symbol (all expirations).
/// Checks cache first; fetches from CBOE if stale/missing (no API key needed).
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!struct { data: []OptionsChain, source: Source, timestamp: i64 } {
var s = self.store();
if (s.read(OptionsChain, symbol, null, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
var cboe = self.getCboe();
const fetched = cboe.fetchOptionsChain(self.allocator, symbol) catch {
return DataError.FetchFailed;
};
if (fetched.len > 0) {
s.write(OptionsChain, symbol, fetched, cache.Ttl.options);
}
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
/// Fetch options chain for a symbol (all expirations, no API key needed).
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!FetchResult(OptionsChain) {
return self.fetchCached(OptionsChain, symbol, null);
}
/// Fetch earnings history for a symbol (5 years back, 1 year forward).
/// Checks cache first; fetches from Finnhub if stale/missing.
/// Smart refresh: even if cache is fresh, re-fetches when a past earnings
/// date has no actual results yet (i.e. results just came out).
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!struct { data: []EarningsEvent, source: Source, timestamp: i64 } {
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) {
var s = self.store();
const today = todayDate();
const today = fmt.todayDate();
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
// Check if any past/today earnings event is still missing actual results.
@ -306,31 +342,43 @@ pub const DataService = struct {
self.allocator.free(cached.data);
}
var fh = try self.getFinnhub();
var fh = try self.getProvider(Finnhub);
const from = today.subtractYears(5);
const to = today.addDays(365);
const fetched = fh.fetchEarnings(self.allocator, symbol, from, to) catch {
const fetched = fh.fetchEarnings(self.allocator, symbol, from, to) catch |err| blk: {
if (err == error.RateLimited) {
self.rateLimitBackoff();
break :blk fh.fetchEarnings(self.allocator, symbol, from, to) catch {
return DataError.FetchFailed;
};
}
s.writeNegative(symbol, .earnings);
return DataError.FetchFailed;
};
if (fetched.len > 0) {
s.write(EarningsEvent, symbol, fetched, cache.Ttl.earnings);
}
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
}
/// Fetch ETF profile for a symbol.
/// Checks cache first; fetches from Alpha Vantage if stale/missing.
pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!struct { data: EtfProfile, source: Source, timestamp: i64 } {
pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!FetchResult(EtfProfile) {
var s = self.store();
if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
var av = try self.getAlphaVantage();
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch {
var av = try self.getProvider(AlphaVantage);
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch |err| blk: {
if (err == error.RateLimited) {
self.rateLimitBackoff();
break :blk av.fetchEtfProfile(self.allocator, symbol) catch {
return DataError.FetchFailed;
};
}
s.writeNegative(symbol, .etf_profile);
return DataError.FetchFailed;
};
@ -342,7 +390,7 @@ pub const DataService = struct {
/// Fetch a real-time (or 15-min delayed) quote for a symbol.
/// No cache -- always fetches fresh from TwelveData.
pub fn getQuote(self: *DataService, symbol: []const u8) DataError!Quote {
var td = try self.getTwelveData();
var td = try self.getProvider(TwelveData);
return td.fetchQuote(self.allocator, symbol) catch
return DataError.FetchFailed;
}
@ -350,7 +398,7 @@ pub const DataService = struct {
/// Fetch company overview (sector, industry, country, market cap) from Alpha Vantage.
/// No cache -- always fetches fresh. Caller must free the returned string fields.
pub fn getCompanyOverview(self: *DataService, symbol: []const u8) DataError!CompanyOverview {
var av = try self.getAlphaVantage();
var av = try self.getProvider(AlphaVantage);
return av.fetchCompanyOverview(self.allocator, symbol) catch
return DataError.FetchFailed;
}
@ -373,7 +421,7 @@ pub const DataService = struct {
const c = candle_result.data;
if (c.len == 0) return DataError.FetchFailed;
const today = todayDate();
const today = fmt.todayDate();
// As-of-date (end = last candle)
const asof_price = performance.trailingReturns(c);
@ -602,6 +650,12 @@ pub const DataService = struct {
return null;
}
/// A single CUSIP-to-ticker mapping record in the cache file.
const CusipEntry = struct {
cusip: []const u8 = "",
ticker: []const u8 = "",
};
/// Read a cached CUSIP->ticker mapping. Returns null if not cached.
/// Caller owns the returned string.
fn getCachedCusipTicker(self: *DataService, cusip: []const u8) ?[]const u8 {
@ -611,30 +665,14 @@ pub const DataService = struct {
const data = std.fs.cwd().readFileAlloc(self.allocator, path, 64 * 1024) catch return null;
defer self.allocator.free(data);
// Simple line-based format: cusip::XXXXX,ticker::YYYYY
var lines = std.mem.splitScalar(u8, data, '\n');
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0 or trimmed[0] == '#') continue;
var reader = std.Io.Reader.fixed(data);
var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null;
defer it.deinit();
// Parse cusip:: field
const cusip_prefix = "cusip::";
if (!std.mem.startsWith(u8, trimmed, cusip_prefix)) continue;
const after_cusip = trimmed[cusip_prefix.len..];
const comma_idx = std.mem.indexOfScalar(u8, after_cusip, ',') orelse continue;
const cached_cusip = after_cusip[0..comma_idx];
if (!std.mem.eql(u8, cached_cusip, cusip)) continue;
// Parse ticker:: field
const rest = after_cusip[comma_idx + 1 ..];
const ticker_prefix = "ticker::";
if (!std.mem.startsWith(u8, rest, ticker_prefix)) continue;
const ticker_val = rest[ticker_prefix.len..];
// Trim any trailing comma/fields
const ticker_end = std.mem.indexOfScalar(u8, ticker_val, ',') orelse ticker_val.len;
const ticker = ticker_val[0..ticker_end];
if (ticker.len > 0) {
return self.allocator.dupe(u8, ticker) catch null;
while (it.next() catch return null) |fields| {
const entry = fields.to(CusipEntry) catch continue;
if (std.mem.eql(u8, entry.cusip, cusip) and entry.ticker.len > 0) {
return self.allocator.dupe(u8, entry.ticker) catch null;
}
}
return null;
@ -650,24 +688,31 @@ pub const DataService = struct {
std.fs.cwd().makePath(dir) catch {};
}
// Append the mapping
// Open existing (append) or create new (with header)
var emit_directives = false;
const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch blk: {
// File doesn't exist, create it
emit_directives = true;
break :blk std.fs.cwd().createFile(path, .{}) catch return;
};
defer file.close();
file.seekFromEnd(0) catch {};
if (!emit_directives) file.seekFromEnd(0) catch {};
const entry = [_]CusipEntry{.{ .cusip = cusip, .ticker = ticker }};
var buf: [256]u8 = undefined;
const line = std.fmt.bufPrint(&buf, "cusip::{s},ticker::{s}\n", .{ cusip, ticker }) catch return;
_ = file.write(line) catch {};
var writer = file.writer(&buf);
writer.interface.print("{f}", .{srf.fmtFrom(CusipEntry, self.allocator, &entry, .{ .emit_directives = emit_directives })}) catch return;
writer.interface.flush() catch {};
}
// Utility
fn todayDate() Date {
const ts = std.time.timestamp();
const days: i32 = @intCast(@divFloor(ts, std.time.s_per_day));
return .{ .days = days };
/// Sleep before retrying after a rate limit error.
/// Uses the provider's rate limiter if available, otherwise a fixed 10s backoff.
fn rateLimitBackoff(self: *DataService) void {
if (self.td) |*td| {
td.rate_limiter.backoff();
} else {
std.Thread.sleep(10 * std.time.ns_per_s);
}
}
};

View file

@ -1,7 +1,7 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = zfin.format;
const fmt = @import("format.zig");
const cli = @import("commands/common.zig");
const keybinds = @import("tui/keybinds.zig");
const theme_mod = @import("tui/theme.zig");
@ -476,9 +476,7 @@ const App = struct {
}
if (key.codepoint == vaxis.Key.enter) {
if (self.input_len > 0) {
for (self.input_buf[0..self.input_len]) |*ch| {
if (ch.* >= 'a' and ch.* <= 'z') ch.* = ch.* - 32;
}
for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*);
@memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]);
self.symbol = self.symbol_buf[0..self.input_len];
self.symbol_owned = true;
@ -895,6 +893,7 @@ const App = struct {
fn setActiveSymbol(self: *App, sym: []const u8) void {
const len = @min(sym.len, self.symbol_buf.len);
@memcpy(self.symbol_buf[0..len], sym[0..len]);
for (self.symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*);
self.symbol = self.symbol_buf[0..len];
self.symbol_owned = true;
self.has_explicit_symbol = true;
@ -1528,7 +1527,7 @@ const App = struct {
self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
} else |_| {}
self.risk_metrics = zfin.risk.computeRisk(c);
self.risk_metrics = zfin.risk.computeRisk(c, zfin.risk.default_risk_free_rate);
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
if (!self.etf_loaded) {
@ -2035,10 +2034,10 @@ const App = struct {
var val_buf: [24]u8 = undefined;
var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined;
const val_str = fmt.fmtMoney(&val_buf, s.total_value);
const cost_str = fmt.fmtMoney(&cost_buf, s.total_cost);
const val_str = fmt.fmtMoneyAbs(&val_buf, s.total_value);
const cost_str = fmt.fmtMoneyAbs(&cost_buf, s.total_cost);
const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss;
const gl_str = fmt.fmtMoney(&gl_buf, gl_abs);
const gl_str = fmt.fmtMoneyAbs(&gl_buf, gl_abs);
const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{
val_str, cost_str, if (s.unrealized_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0,
});
@ -2060,9 +2059,9 @@ const App = struct {
var nw_buf: [24]u8 = undefined;
var il_buf: [24]u8 = undefined;
const nw_text = try std.fmt.allocPrint(arena, " Net Worth: {s} (Liquid: {s} Illiquid: {s})", .{
fmt.fmtMoney(&nw_buf, net_worth),
fmt.fmtMoneyAbs(&nw_buf, net_worth),
val_str,
fmt.fmtMoney(&il_buf, illiquid_total),
fmt.fmtMoneyAbs(&il_buf, illiquid_total),
});
try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
}
@ -2141,18 +2140,18 @@ const App = struct {
const pnl_pct = if (a.cost_basis > 0) (a.unrealized_gain_loss / a.cost_basis) * 100.0 else @as(f64, 0);
var gl_val_buf: [24]u8 = undefined;
const gl_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs);
const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs);
var pnl_buf: [20]u8 = undefined;
const pnl_str = if (a.unrealized_gain_loss >= 0)
std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
else
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
var mv_buf: [24]u8 = undefined;
const mv_str = fmt.fmtMoney(&mv_buf, a.market_value);
const mv_str = fmt.fmtMoneyAbs(&mv_buf, a.market_value);
var cost_buf2: [24]u8 = undefined;
const cost_str = fmt.fmtMoney2(&cost_buf2, a.avg_cost);
const cost_str = fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost);
var price_buf2: [24]u8 = undefined;
const price_str = fmt.fmtMoney2(&price_buf2, a.current_price);
const price_str = fmt.fmtMoneyAbs(&price_buf2, a.current_price);
// Date + ST/LT: show for single-lot, blank for multi-lot
var pos_date_buf: [10]u8 = undefined;
@ -2233,17 +2232,17 @@ const App = struct {
const gl = lot.shares * (use_price - lot.open_price);
lot_positive = gl >= 0;
var lot_gl_money_buf: [24]u8 = undefined;
const lot_gl_money = fmt.fmtMoney(&lot_gl_money_buf, if (gl >= 0) gl else -gl);
const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_money_buf, if (gl >= 0) gl else -gl);
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), lot_gl_money,
});
var lot_mv_buf: [24]u8 = undefined;
lot_mv_str = try std.fmt.allocPrint(arena, "{s}", .{fmt.fmtMoney(&lot_mv_buf, lot.shares * use_price)});
lot_mv_str = try std.fmt.allocPrint(arena, "{s}", .{fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price)});
}
}
var price_str2: [24]u8 = undefined;
const lot_price_str = fmt.fmtMoney2(&price_str2, lot.open_price);
const lot_price_str = fmt.fmtMoneyAbs(&price_str2, lot.open_price);
const status_str: []const u8 = if (lot.isOpen()) "open" else "closed";
const indicator = fmt.capitalGainsIndicator(lot.open_date);
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
@ -2265,7 +2264,7 @@ const App = struct {
.watchlist => {
var price_str3: [16]u8 = undefined;
const ps: []const u8 = if (self.watchlist_prices) |wp|
(if (wp.get(row.symbol)) |p| fmt.fmtMoney2(&price_str3, p) else "--")
(if (wp.get(row.symbol)) |p| fmt.fmtMoneyAbs(&price_str3, p) else "--")
else
"--";
const star2: []const u8 = if (is_active_sym) "* " else " ";
@ -2306,8 +2305,8 @@ const App = struct {
const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{
lot.symbol,
qty,
fmt.fmtMoney2(&cost_buf3, cost_per),
fmt.fmtMoney(&total_buf, total_cost),
fmt.fmtMoneyAbs(&cost_buf3, cost_per),
fmt.fmtMoneyAbs(&total_buf, total_cost),
acct_col2,
});
const row_style2 = if (is_cursor) th.selectStyle() else th.contentStyle();
@ -2331,7 +2330,7 @@ const App = struct {
const acct_col3: []const u8 = lot.account orelse "";
const text = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
lot.symbol,
fmt.fmtMoney(&face_buf, lot.shares),
fmt.fmtMoneyAbs(&face_buf, lot.shares),
rate_str,
mat_str,
note_display,
@ -2348,7 +2347,7 @@ const App = struct {
const arrow3: []const u8 = if (self.cash_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{
arrow3,
fmt.fmtMoney(&cash_buf, total_cash),
fmt.fmtMoneyAbs(&cash_buf, total_cash),
});
const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style4 });
@ -2370,7 +2369,7 @@ const App = struct {
const arrow4: []const u8 = if (self.illiquid_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {s:>14}", .{
arrow4,
fmt.fmtMoney(&illiquid_buf, total_illiquid),
fmt.fmtMoneyAbs(&illiquid_buf, total_illiquid),
});
const row_style6 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style6 });
@ -2396,7 +2395,7 @@ const App = struct {
label_str,
row.drip_lot_count,
row.drip_shares,
fmt.fmtMoney2(&drip_avg_buf, row.drip_avg_cost),
fmt.fmtMoneyAbs(&drip_avg_buf, row.drip_avg_cost),
drip_d1,
drip_d2,
});
@ -2697,7 +2696,7 @@ const App = struct {
if (row >= height) continue;
var lbl_buf: [16]u8 = undefined;
const lbl = fmt.fmtMoney2(&lbl_buf, price_val);
const lbl = fmt.fmtMoneyAbs(&lbl_buf, price_val);
const start_idx = row * @as(usize, width) + label_col;
for (lbl, 0..) |ch, ci| {
const idx = start_idx + ci;
@ -2794,7 +2793,7 @@ const App = struct {
if (quote_data) |q| {
// No candle data but have a quote - show it
var qclose_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoney(&qclose_buf, q.close)}), .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&qclose_buf, q.close)}), .style = th.contentStyle() });
{
var chg_buf: [64]u8 = undefined;
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
@ -2881,7 +2880,7 @@ const App = struct {
var col1 = Column.init();
col1.width = 30;
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), th.contentStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoney(&close_buf, price)}), th.contentStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&close_buf, price)}), th.contentStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
@ -3058,7 +3057,7 @@ const App = struct {
if (self.candles) |cc| {
if (cc.len > 0) {
var close_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoney(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoneyAbs(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() });
}
}
@ -3182,7 +3181,7 @@ const App = struct {
if (chains[0].underlying_price) |price| {
var price_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoney(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() });
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() });
}
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -3233,7 +3232,8 @@ const App = struct {
const atm_price = chains[0].underlying_price orelse 0;
const itm = cc.strike <= atm_price;
const prefix: []const u8 = if (itm) " |" else " ";
const text = try fmt.fmtContractLine(arena, prefix, cc);
var contract_buf: [128]u8 = undefined;
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, cc));
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style });
}
@ -3243,7 +3243,8 @@ const App = struct {
const atm_price = chains[0].underlying_price orelse 0;
const itm = p.strike >= atm_price;
const prefix: []const u8 = if (itm) " |" else " ";
const text = try fmt.fmtContractLine(arena, prefix, p);
var contract_buf: [128]u8 = undefined;
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p));
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style });
}
@ -3351,7 +3352,7 @@ const App = struct {
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{
padded_label, bar, pct, fmt.fmtMoney(&val_buf, item.value),
padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value),
});
}