Compare commits

..

No commits in common. "5b793fea8dbefa644ebcf0d331c7f7344e4dddfe" and "e0129003e649f8e39c1834e68499fb113386ddf0" have entirely different histories.

22 changed files with 426 additions and 509 deletions

View file

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

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

27
src/cache/store.zig vendored
View file

@ -57,17 +57,6 @@ pub const DataType = enum {
.meta => "meta.srf", .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. /// Persistent SRF-backed cache with per-symbol, per-data-type files.
@ -95,7 +84,7 @@ pub const Store = struct {
// Generic typed API // Generic typed API
/// Map a model type to its cache DataType. /// Map a model type to its cache DataType.
pub fn dataTypeFor(comptime T: type) DataType { fn dataTypeFor(comptime T: type) DataType {
return switch (T) { return switch (T) {
Candle => .candles_daily, Candle => .candles_daily,
Dividend => .dividends, Dividend => .dividends,
@ -108,7 +97,7 @@ pub const Store = struct {
} }
/// The data payload for a given type: single struct for EtfProfile, slice for everything else. /// The data payload for a given type: single struct for EtfProfile, slice for everything else.
pub fn DataFor(comptime T: type) type { fn DataFor(comptime T: type) type {
return if (T == EtfProfile) EtfProfile else []T; return if (T == EtfProfile) EtfProfile else []T;
} }
@ -132,18 +121,6 @@ pub const Store = struct {
defer self.allocator.free(data); defer self.allocator.free(data);
if (T == EtfProfile or T == OptionsChain) { 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 reader = std.Io.Reader.fixed(data);
var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null; var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null;
defer it.deinit(); 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]); if (color) try fmt.ansiSetFg(out, cli.CLR_ACCENT[0], cli.CLR_ACCENT[1], cli.CLR_ACCENT[2]);
try out.writeAll(bar); try out.writeAll(bar);
if (color) try fmt.ansiReset(out); if (color) try fmt.ansiReset(out);
try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoneyAbs(&val_buf, item.value) }); try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoney(&val_buf, item.value) });
} }
} }

View file

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

View file

@ -1,7 +1,6 @@
const std = @import("std"); const std = @import("std");
const zfin = @import("../root.zig"); const zfin = @import("../root.zig");
const cli = @import("common.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. /// 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, /// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,
@ -116,7 +115,7 @@ fn enrichPortfolio(allocator: std.mem.Allocator, svc: *zfin.DataService, file_pa
for (syms, 0..) |sym, i| { for (syms, 0..) |sym, i| {
// Skip CUSIPs and known non-stock symbols // Skip CUSIPs and known non-stock symbols
if (isCusipLike(sym)) { if (cli.fmt.isCusipLike(sym)) {
// Find the display name for this CUSIP // Find the display name for this CUSIP
const display: []const u8 = sym; const display: []const u8 = sym;
var note: ?[]const u8 = null; var note: ?[]const u8 = null;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -346,20 +346,6 @@ 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" { test "lot basics" {
const lot = Lot{ const lot = Lot{
.symbol = "AAPL", .symbol = "AAPL",

View file

@ -0,0 +1,19 @@
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,13 +59,6 @@ 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 estimated wait time in nanoseconds until a token is available.
/// Returns 0 if a token is available now. /// Returns 0 if a token is available now.
pub fn estimateWaitNs(self: *RateLimiter) u64 { pub fn estimateWaitNs(self: *RateLimiter) u64 {

View file

@ -51,25 +51,15 @@ 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 { 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; var attempt: u8 = 0;
while (true) : (attempt += 1) { while (true) : (attempt += 1) {
const response = self.doRequest(method, url, body, extra_headers) catch { if (self.doRequest(method, url, body, extra_headers)) |response| {
return classifyResponse(response);
} else |_| {
if (attempt >= self.max_retries) return HttpError.RequestFailed; 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); const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt);
std.Thread.sleep(backoff * std.time.ns_per_ms); 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 { 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); var aw: std.Io.Writer.Allocating = .init(self.allocator);
@ -80,8 +70,13 @@ pub const Client = struct {
.payload = body, .payload = body,
.extra_headers = extra_headers, .extra_headers = extra_headers,
.response_writer = &aw.writer, .response_writer = &aw.writer,
}) catch { }) catch |err| {
aw.deinit(); 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; return HttpError.RequestFailed;
}; };
@ -98,23 +93,82 @@ pub const Client = struct {
} }
fn classifyResponse(response: Response) HttpError!Response { fn classifyResponse(response: Response) HttpError!Response {
switch (response.status) {
.ok => return response,
else => {
response.allocator.free(response.body);
return switch (response.status) { return switch (response.status) {
.ok => response,
.too_many_requests => HttpError.RateLimited, .too_many_requests => HttpError.RateLimited,
.unauthorized, .forbidden => HttpError.Unauthorized, .unauthorized, .forbidden => HttpError.Unauthorized,
.not_found => HttpError.NotFound, .not_found => HttpError.NotFound,
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError, .internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
else => HttpError.InvalidResponse, else => HttpError.InvalidResponse,
}; };
},
}
} }
}; };
/// Build a URL with query parameters. Values are percent-encoded per RFC 3986. /// 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.
pub fn buildUrl( pub fn buildUrl(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
base: []const u8, base: []const u8,
@ -128,26 +182,20 @@ pub fn buildUrl(
try aw.writer.writeByte(if (i == 0) '?' else '&'); try aw.writer.writeByte(if (i == 0) '?' else '&');
try aw.writer.writeAll(param[0]); try aw.writer.writeAll(param[0]);
try aw.writer.writeByte('='); try aw.writer.writeByte('=');
try std.Uri.Component.percentEncode(&aw.writer, param[1], isQueryValueChar); 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),
}
}
} }
return aw.toOwnedSlice(); 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" { test "buildUrl" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const url = try buildUrl(allocator, "https://api.example.com/v1/data", &.{ 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 optFloat = json_utils.optFloat;
const jsonStr = json_utils.jsonStr; const jsonStr = json_utils.jsonStr;
const base_url = "https://api.finnhub.io/api/v1"; const base_url = "https://finnhub.io/api/v1";
pub const Finnhub = struct { pub const Finnhub = struct {
api_key: []const u8, api_key: []const u8,

View file

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

View file

@ -18,7 +18,6 @@ const Quote = @import("models/quote.zig").Quote;
const EtfProfile = @import("models/etf_profile.zig").EtfProfile; const EtfProfile = @import("models/etf_profile.zig").EtfProfile;
const Config = @import("config.zig").Config; const Config = @import("config.zig").Config;
const cache = @import("cache/store.zig"); const cache = @import("cache/store.zig");
const srf = @import("srf");
const TwelveData = @import("providers/twelvedata.zig").TwelveData; const TwelveData = @import("providers/twelvedata.zig").TwelveData;
const Polygon = @import("providers/polygon.zig").Polygon; const Polygon = @import("providers/polygon.zig").Polygon;
const Finnhub = @import("providers/finnhub.zig").Finnhub; const Finnhub = @import("providers/finnhub.zig").Finnhub;
@ -26,7 +25,6 @@ const Cboe = @import("providers/cboe.zig").Cboe;
const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage; const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
const alphavantage = @import("providers/alphavantage.zig"); const alphavantage = @import("providers/alphavantage.zig");
const OpenFigi = @import("providers/openfigi.zig"); const OpenFigi = @import("providers/openfigi.zig");
const fmt = @import("format.zig");
const performance = @import("analytics/performance.zig"); const performance = @import("analytics/performance.zig");
pub const DataError = error{ pub const DataError = error{
@ -49,15 +47,6 @@ pub const Source = enum {
fetched, 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 // PostProcess callbacks
// These are passed to Store.read to handle type-specific // These are passed to Store.read to handle type-specific
// concerns: string duping (serialization plumbing) and domain transforms. // concerns: string duping (serialization plumbing) and domain transforms.
@ -106,40 +95,40 @@ pub const DataService = struct {
if (self.av) |*av| av.deinit(); if (self.av) |*av| av.deinit();
} }
// Provider accessor // Provider accessors
fn getProvider(self: *DataService, comptime T: type) DataError!*T { fn getTwelveData(self: *DataService) DataError!*TwelveData {
const field_name = comptime providerField(T); if (self.td) |*td| return td;
if (@field(self, field_name)) |*p| return p; const key = self.config.twelvedata_key orelse return DataError.NoApiKey;
if (T == Cboe) { self.td = TwelveData.init(self.allocator, key);
// CBOE has no key return &self.td.?;
@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 providerField(comptime T: type) []const u8 { fn getPolygon(self: *DataService) DataError!*Polygon {
inline for (std.meta.fields(DataService)) |f| { if (self.pg) |*pg| return pg;
if (f.type == ?T) return f.name; const key = self.config.polygon_key orelse return DataError.NoApiKey;
self.pg = Polygon.init(self.allocator, key);
return &self.pg.?;
} }
@compileError("unknown provider type");
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.?;
} }
// Cache helper // Cache helper
@ -148,59 +137,6 @@ pub const DataService = struct {
return cache.Store.init(self.allocator, self.config.cache_dir); 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. /// 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 { pub fn invalidate(self: *DataService, symbol: []const u8, data_type: cache.DataType) void {
var s = self.store(); var s = self.store();
@ -218,9 +154,9 @@ pub const DataService = struct {
/// Uses incremental updates: when the cache is stale, only fetches /// Uses incremental updates: when the cache is stale, only fetches
/// candles newer than the last cached date rather than re-fetching /// candles newer than the last cached date rather than re-fetching
/// the entire history. /// the entire history.
pub fn getCandles(self: *DataService, symbol: []const u8) DataError!FetchResult(Candle) { pub fn getCandles(self: *DataService, symbol: []const u8) DataError!struct { data: []Candle, source: Source, timestamp: i64 } {
var s = self.store(); var s = self.store();
const today = fmt.todayDate(); const today = todayDate();
// Check candle metadata for freshness (tiny file, no candle deserialization) // Check candle metadata for freshness (tiny file, no candle deserialization)
const meta_result = s.readCandleMeta(symbol); const meta_result = s.readCandleMeta(symbol);
@ -242,22 +178,14 @@ pub const DataService = struct {
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() }; return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
} else { } else {
// Incremental fetch from day after last cached candle // Incremental fetch from day after last cached candle
var td = self.getProvider(TwelveData) catch { var td = self.getTwelveData() catch {
// No API key return stale data // No API key return stale data
if (s.read(Candle, symbol, null, .any)) |r| if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
return DataError.NoApiKey; return DataError.NoApiKey;
}; };
const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch |err| blk: { const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch {
if (err == error.RateLimited) { // Fetch failed return stale data rather than erroring
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| if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created }; return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
return DataError.FetchFailed; return DataError.FetchFailed;
@ -284,17 +212,10 @@ pub const DataService = struct {
} }
// No usable cache full fetch (~10 years, plus buffer for leap years) // No usable cache full fetch (~10 years, plus buffer for leap years)
var td = try self.getProvider(TwelveData); var td = try self.getTwelveData();
const from = today.addDays(-3700); const from = today.addDays(-3700);
const fetched = td.fetchCandles(self.allocator, symbol, from, today) catch |err| blk: { const fetched = td.fetchCandles(self.allocator, symbol, from, today) catch {
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; return DataError.FetchFailed;
}; };
@ -306,27 +227,70 @@ pub const DataService = struct {
} }
/// Fetch dividend history for a symbol. /// Fetch dividend history for a symbol.
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!FetchResult(Dividend) { /// Checks cache first; fetches from Polygon if stale/missing.
return self.fetchCached(Dividend, symbol, dividendPostProcess); 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() };
} }
/// Fetch split history for a symbol. /// Fetch split history for a symbol.
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!FetchResult(Split) { /// Checks cache first; fetches from Polygon if stale/missing.
return self.fetchCached(Split, symbol, null); 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() };
} }
/// Fetch options chain for a symbol (all expirations, no API key needed). /// Fetch options chain for a symbol (all expirations).
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!FetchResult(OptionsChain) { /// Checks cache first; fetches from CBOE if stale/missing (no API key needed).
return self.fetchCached(OptionsChain, symbol, null); 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 earnings history for a symbol (5 years back, 1 year forward). /// Fetch earnings history for a symbol (5 years back, 1 year forward).
/// Checks cache first; fetches from Finnhub if stale/missing. /// Checks cache first; fetches from Finnhub if stale/missing.
/// Smart refresh: even if cache is fresh, re-fetches when a past earnings /// 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). /// date has no actual results yet (i.e. results just came out).
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) { pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!struct { data: []EarningsEvent, source: Source, timestamp: i64 } {
var s = self.store(); var s = self.store();
const today = fmt.todayDate(); const today = todayDate();
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| { if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached| {
// Check if any past/today earnings event is still missing actual results. // Check if any past/today earnings event is still missing actual results.
@ -342,43 +306,31 @@ pub const DataService = struct {
self.allocator.free(cached.data); self.allocator.free(cached.data);
} }
var fh = try self.getProvider(Finnhub); var fh = try self.getFinnhub();
const from = today.subtractYears(5); const from = today.subtractYears(5);
const to = today.addDays(365); const to = today.addDays(365);
const fetched = fh.fetchEarnings(self.allocator, symbol, from, to) catch |err| blk: { const fetched = fh.fetchEarnings(self.allocator, symbol, from, to) catch {
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; return DataError.FetchFailed;
}; };
if (fetched.len > 0) {
s.write(EarningsEvent, symbol, fetched, cache.Ttl.earnings); s.write(EarningsEvent, symbol, fetched, cache.Ttl.earnings);
}
return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() }; return .{ .data = fetched, .source = .fetched, .timestamp = std.time.timestamp() };
} }
/// Fetch ETF profile for a symbol. /// Fetch ETF profile for a symbol.
/// Checks cache first; fetches from Alpha Vantage if stale/missing. /// Checks cache first; fetches from Alpha Vantage if stale/missing.
pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!FetchResult(EtfProfile) { pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!struct { data: EtfProfile, source: Source, timestamp: i64 } {
var s = self.store(); var s = self.store();
if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached| if (s.read(EtfProfile, symbol, null, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp }; return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
var av = try self.getProvider(AlphaVantage); var av = try self.getAlphaVantage();
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch |err| blk: { const fetched = av.fetchEtfProfile(self.allocator, symbol) catch {
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; return DataError.FetchFailed;
}; };
@ -390,7 +342,7 @@ pub const DataService = struct {
/// Fetch a real-time (or 15-min delayed) quote for a symbol. /// Fetch a real-time (or 15-min delayed) quote for a symbol.
/// No cache -- always fetches fresh from TwelveData. /// No cache -- always fetches fresh from TwelveData.
pub fn getQuote(self: *DataService, symbol: []const u8) DataError!Quote { pub fn getQuote(self: *DataService, symbol: []const u8) DataError!Quote {
var td = try self.getProvider(TwelveData); var td = try self.getTwelveData();
return td.fetchQuote(self.allocator, symbol) catch return td.fetchQuote(self.allocator, symbol) catch
return DataError.FetchFailed; return DataError.FetchFailed;
} }
@ -398,7 +350,7 @@ pub const DataService = struct {
/// Fetch company overview (sector, industry, country, market cap) from Alpha Vantage. /// Fetch company overview (sector, industry, country, market cap) from Alpha Vantage.
/// No cache -- always fetches fresh. Caller must free the returned string fields. /// No cache -- always fetches fresh. Caller must free the returned string fields.
pub fn getCompanyOverview(self: *DataService, symbol: []const u8) DataError!CompanyOverview { pub fn getCompanyOverview(self: *DataService, symbol: []const u8) DataError!CompanyOverview {
var av = try self.getProvider(AlphaVantage); var av = try self.getAlphaVantage();
return av.fetchCompanyOverview(self.allocator, symbol) catch return av.fetchCompanyOverview(self.allocator, symbol) catch
return DataError.FetchFailed; return DataError.FetchFailed;
} }
@ -421,7 +373,7 @@ pub const DataService = struct {
const c = candle_result.data; const c = candle_result.data;
if (c.len == 0) return DataError.FetchFailed; if (c.len == 0) return DataError.FetchFailed;
const today = fmt.todayDate(); const today = todayDate();
// As-of-date (end = last candle) // As-of-date (end = last candle)
const asof_price = performance.trailingReturns(c); const asof_price = performance.trailingReturns(c);
@ -650,12 +602,6 @@ pub const DataService = struct {
return null; 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. /// Read a cached CUSIP->ticker mapping. Returns null if not cached.
/// Caller owns the returned string. /// Caller owns the returned string.
fn getCachedCusipTicker(self: *DataService, cusip: []const u8) ?[]const u8 { fn getCachedCusipTicker(self: *DataService, cusip: []const u8) ?[]const u8 {
@ -665,14 +611,30 @@ pub const DataService = struct {
const data = std.fs.cwd().readFileAlloc(self.allocator, path, 64 * 1024) catch return null; const data = std.fs.cwd().readFileAlloc(self.allocator, path, 64 * 1024) catch return null;
defer self.allocator.free(data); defer self.allocator.free(data);
var reader = std.Io.Reader.fixed(data); // Simple line-based format: cusip::XXXXX,ticker::YYYYY
var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null; var lines = std.mem.splitScalar(u8, data, '\n');
defer it.deinit(); while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
if (trimmed.len == 0 or trimmed[0] == '#') continue;
while (it.next() catch return null) |fields| { // Parse cusip:: field
const entry = fields.to(CusipEntry) catch continue; const cusip_prefix = "cusip::";
if (std.mem.eql(u8, entry.cusip, cusip) and entry.ticker.len > 0) { if (!std.mem.startsWith(u8, trimmed, cusip_prefix)) continue;
return self.allocator.dupe(u8, entry.ticker) catch null; 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;
} }
} }
return null; return null;
@ -688,31 +650,24 @@ pub const DataService = struct {
std.fs.cwd().makePath(dir) catch {}; std.fs.cwd().makePath(dir) catch {};
} }
// Open existing (append) or create new (with header) // Append the mapping
var emit_directives = false;
const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch blk: { const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch blk: {
emit_directives = true; // File doesn't exist, create it
break :blk std.fs.cwd().createFile(path, .{}) catch return; break :blk std.fs.cwd().createFile(path, .{}) catch return;
}; };
defer file.close(); defer file.close();
if (!emit_directives) file.seekFromEnd(0) catch {}; file.seekFromEnd(0) catch {};
const entry = [_]CusipEntry{.{ .cusip = cusip, .ticker = ticker }};
var buf: [256]u8 = undefined; var buf: [256]u8 = undefined;
var writer = file.writer(&buf); const line = std.fmt.bufPrint(&buf, "cusip::{s},ticker::{s}\n", .{ cusip, ticker }) catch return;
writer.interface.print("{f}", .{srf.fmtFrom(CusipEntry, self.allocator, &entry, .{ .emit_directives = emit_directives })}) catch return; _ = file.write(line) catch {};
writer.interface.flush() catch {};
} }
// Utility // Utility
/// Sleep before retrying after a rate limit error. fn todayDate() Date {
/// Uses the provider's rate limiter if available, otherwise a fixed 10s backoff. const ts = std.time.timestamp();
fn rateLimitBackoff(self: *DataService) void { const days: i32 = @intCast(@divFloor(ts, std.time.s_per_day));
if (self.td) |*td| { return .{ .days = days };
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 std = @import("std");
const vaxis = @import("vaxis"); const vaxis = @import("vaxis");
const zfin = @import("root.zig"); const zfin = @import("root.zig");
const fmt = @import("format.zig"); const fmt = zfin.format;
const cli = @import("commands/common.zig"); const cli = @import("commands/common.zig");
const keybinds = @import("tui/keybinds.zig"); const keybinds = @import("tui/keybinds.zig");
const theme_mod = @import("tui/theme.zig"); const theme_mod = @import("tui/theme.zig");
@ -476,7 +476,9 @@ const App = struct {
} }
if (key.codepoint == vaxis.Key.enter) { if (key.codepoint == vaxis.Key.enter) {
if (self.input_len > 0) { if (self.input_len > 0) {
for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*); for (self.input_buf[0..self.input_len]) |*ch| {
if (ch.* >= 'a' and ch.* <= 'z') ch.* = ch.* - 32;
}
@memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]); @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 = self.symbol_buf[0..self.input_len];
self.symbol_owned = true; self.symbol_owned = true;
@ -893,7 +895,6 @@ const App = struct {
fn setActiveSymbol(self: *App, sym: []const u8) void { fn setActiveSymbol(self: *App, sym: []const u8) void {
const len = @min(sym.len, self.symbol_buf.len); const len = @min(sym.len, self.symbol_buf.len);
@memcpy(self.symbol_buf[0..len], sym[0..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 = self.symbol_buf[0..len];
self.symbol_owned = true; self.symbol_owned = true;
self.has_explicit_symbol = true; self.has_explicit_symbol = true;
@ -1527,7 +1528,7 @@ const App = struct {
self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today); self.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
} else |_| {} } else |_| {}
self.risk_metrics = zfin.risk.computeRisk(c, zfin.risk.default_risk_free_rate); self.risk_metrics = zfin.risk.computeRisk(c);
// Try to load ETF profile (non-fatal, won't show for non-ETFs) // Try to load ETF profile (non-fatal, won't show for non-ETFs)
if (!self.etf_loaded) { if (!self.etf_loaded) {
@ -2034,10 +2035,10 @@ const App = struct {
var val_buf: [24]u8 = undefined; var val_buf: [24]u8 = undefined;
var cost_buf: [24]u8 = undefined; var cost_buf: [24]u8 = undefined;
var gl_buf: [24]u8 = undefined; var gl_buf: [24]u8 = undefined;
const val_str = fmt.fmtMoneyAbs(&val_buf, s.total_value); const val_str = fmt.fmtMoney(&val_buf, s.total_value);
const cost_str = fmt.fmtMoneyAbs(&cost_buf, s.total_cost); const cost_str = fmt.fmtMoney(&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_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss;
const gl_str = fmt.fmtMoneyAbs(&gl_buf, gl_abs); const gl_str = fmt.fmtMoney(&gl_buf, gl_abs);
const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{ 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, val_str, cost_str, if (s.unrealized_gain_loss >= 0) @as([]const u8, "+") else @as([]const u8, "-"), gl_str, s.unrealized_return * 100.0,
}); });
@ -2059,9 +2060,9 @@ const App = struct {
var nw_buf: [24]u8 = undefined; var nw_buf: [24]u8 = undefined;
var il_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})", .{ const nw_text = try std.fmt.allocPrint(arena, " Net Worth: {s} (Liquid: {s} Illiquid: {s})", .{
fmt.fmtMoneyAbs(&nw_buf, net_worth), fmt.fmtMoney(&nw_buf, net_worth),
val_str, val_str,
fmt.fmtMoneyAbs(&il_buf, illiquid_total), fmt.fmtMoney(&il_buf, illiquid_total),
}); });
try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() }); try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
} }
@ -2140,18 +2141,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); 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; 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_abs = if (a.unrealized_gain_loss >= 0) a.unrealized_gain_loss else -a.unrealized_gain_loss;
const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs); const gl_money = fmt.fmtMoney(&gl_val_buf, gl_abs);
var pnl_buf: [20]u8 = undefined; var pnl_buf: [20]u8 = undefined;
const pnl_str = if (a.unrealized_gain_loss >= 0) const pnl_str = if (a.unrealized_gain_loss >= 0)
std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?" std.fmt.bufPrint(&pnl_buf, "+{s}", .{gl_money}) catch "?"
else else
std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?"; std.fmt.bufPrint(&pnl_buf, "-{s}", .{gl_money}) catch "?";
var mv_buf: [24]u8 = undefined; var mv_buf: [24]u8 = undefined;
const mv_str = fmt.fmtMoneyAbs(&mv_buf, a.market_value); const mv_str = fmt.fmtMoney(&mv_buf, a.market_value);
var cost_buf2: [24]u8 = undefined; var cost_buf2: [24]u8 = undefined;
const cost_str = fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost); const cost_str = fmt.fmtMoney2(&cost_buf2, a.avg_cost);
var price_buf2: [24]u8 = undefined; var price_buf2: [24]u8 = undefined;
const price_str = fmt.fmtMoneyAbs(&price_buf2, a.current_price); const price_str = fmt.fmtMoney2(&price_buf2, a.current_price);
// Date + ST/LT: show for single-lot, blank for multi-lot // Date + ST/LT: show for single-lot, blank for multi-lot
var pos_date_buf: [10]u8 = undefined; var pos_date_buf: [10]u8 = undefined;
@ -2232,17 +2233,17 @@ const App = struct {
const gl = lot.shares * (use_price - lot.open_price); const gl = lot.shares * (use_price - lot.open_price);
lot_positive = gl >= 0; lot_positive = gl >= 0;
var lot_gl_money_buf: [24]u8 = undefined; var lot_gl_money_buf: [24]u8 = undefined;
const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_money_buf, if (gl >= 0) gl else -gl); const lot_gl_money = fmt.fmtMoney(&lot_gl_money_buf, if (gl >= 0) gl else -gl);
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{ lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{
if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), lot_gl_money, if (gl >= 0) @as([]const u8, "+") else @as([]const u8, "-"), lot_gl_money,
}); });
var lot_mv_buf: [24]u8 = undefined; var lot_mv_buf: [24]u8 = undefined;
lot_mv_str = try std.fmt.allocPrint(arena, "{s}", .{fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price)}); lot_mv_str = try std.fmt.allocPrint(arena, "{s}", .{fmt.fmtMoney(&lot_mv_buf, lot.shares * use_price)});
} }
} }
var price_str2: [24]u8 = undefined; var price_str2: [24]u8 = undefined;
const lot_price_str = fmt.fmtMoneyAbs(&price_str2, lot.open_price); const lot_price_str = fmt.fmtMoney2(&price_str2, lot.open_price);
const status_str: []const u8 = if (lot.isOpen()) "open" else "closed"; const status_str: []const u8 = if (lot.isOpen()) "open" else "closed";
const indicator = fmt.capitalGainsIndicator(lot.open_date); const indicator = fmt.capitalGainsIndicator(lot.open_date);
const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator }); const lot_date_col = try std.fmt.allocPrint(arena, "{s} {s}", .{ date_str, indicator });
@ -2264,7 +2265,7 @@ const App = struct {
.watchlist => { .watchlist => {
var price_str3: [16]u8 = undefined; var price_str3: [16]u8 = undefined;
const ps: []const u8 = if (self.watchlist_prices) |wp| const ps: []const u8 = if (self.watchlist_prices) |wp|
(if (wp.get(row.symbol)) |p| fmt.fmtMoneyAbs(&price_str3, p) else "--") (if (wp.get(row.symbol)) |p| fmt.fmtMoney2(&price_str3, p) else "--")
else else
"--"; "--";
const star2: []const u8 = if (is_active_sym) "* " else " "; const star2: []const u8 = if (is_active_sym) "* " else " ";
@ -2305,8 +2306,8 @@ const App = struct {
const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{ const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{
lot.symbol, lot.symbol,
qty, qty,
fmt.fmtMoneyAbs(&cost_buf3, cost_per), fmt.fmtMoney2(&cost_buf3, cost_per),
fmt.fmtMoneyAbs(&total_buf, total_cost), fmt.fmtMoney(&total_buf, total_cost),
acct_col2, acct_col2,
}); });
const row_style2 = if (is_cursor) th.selectStyle() else th.contentStyle(); const row_style2 = if (is_cursor) th.selectStyle() else th.contentStyle();
@ -2330,7 +2331,7 @@ const App = struct {
const acct_col3: []const u8 = lot.account orelse ""; 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}", .{ const text = try std.fmt.allocPrint(arena, " {s:<12} {s:>14} {s:>7} {s:>10} {s} {s}", .{
lot.symbol, lot.symbol,
fmt.fmtMoneyAbs(&face_buf, lot.shares), fmt.fmtMoney(&face_buf, lot.shares),
rate_str, rate_str,
mat_str, mat_str,
note_display, note_display,
@ -2347,7 +2348,7 @@ const App = struct {
const arrow3: []const u8 = if (self.cash_expanded) "v " else "> "; const arrow3: []const u8 = if (self.cash_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{ const text = try std.fmt.allocPrint(arena, " {s}Total Cash {s:>14}", .{
arrow3, arrow3,
fmt.fmtMoneyAbs(&cash_buf, total_cash), fmt.fmtMoney(&cash_buf, total_cash),
}); });
const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle(); const row_style4 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style4 }); try lines.append(arena, .{ .text = text, .style = row_style4 });
@ -2369,7 +2370,7 @@ const App = struct {
const arrow4: []const u8 = if (self.illiquid_expanded) "v " else "> "; const arrow4: []const u8 = if (self.illiquid_expanded) "v " else "> ";
const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {s:>14}", .{ const text = try std.fmt.allocPrint(arena, " {s}Total Illiquid {s:>14}", .{
arrow4, arrow4,
fmt.fmtMoneyAbs(&illiquid_buf, total_illiquid), fmt.fmtMoney(&illiquid_buf, total_illiquid),
}); });
const row_style6 = if (is_cursor) th.selectStyle() else th.contentStyle(); const row_style6 = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = row_style6 }); try lines.append(arena, .{ .text = text, .style = row_style6 });
@ -2395,7 +2396,7 @@ const App = struct {
label_str, label_str,
row.drip_lot_count, row.drip_lot_count,
row.drip_shares, row.drip_shares,
fmt.fmtMoneyAbs(&drip_avg_buf, row.drip_avg_cost), fmt.fmtMoney2(&drip_avg_buf, row.drip_avg_cost),
drip_d1, drip_d1,
drip_d2, drip_d2,
}); });
@ -2696,7 +2697,7 @@ const App = struct {
if (row >= height) continue; if (row >= height) continue;
var lbl_buf: [16]u8 = undefined; var lbl_buf: [16]u8 = undefined;
const lbl = fmt.fmtMoneyAbs(&lbl_buf, price_val); const lbl = fmt.fmtMoney2(&lbl_buf, price_val);
const start_idx = row * @as(usize, width) + label_col; const start_idx = row * @as(usize, width) + label_col;
for (lbl, 0..) |ch, ci| { for (lbl, 0..) |ch, ci| {
const idx = start_idx + ci; const idx = start_idx + ci;
@ -2793,7 +2794,7 @@ const App = struct {
if (quote_data) |q| { if (quote_data) |q| {
// No candle data but have a quote - show it // No candle data but have a quote - show it
var qclose_buf: [24]u8 = undefined; var qclose_buf: [24]u8 = undefined;
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&qclose_buf, q.close)}), .style = th.contentStyle() }); try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoney(&qclose_buf, q.close)}), .style = th.contentStyle() });
{ {
var chg_buf: [64]u8 = undefined; var chg_buf: [64]u8 = undefined;
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle(); const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
@ -2880,7 +2881,7 @@ const App = struct {
var col1 = Column.init(); var col1 = Column.init();
col1.width = 30; 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, " Date: {s}", .{latest.date.format(&date_buf)}), 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, " Price: {s}", .{fmt.fmtMoney(&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, " 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, " 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()); try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
@ -3057,7 +3058,7 @@ const App = struct {
if (self.candles) |cc| { if (self.candles) |cc| {
if (cc.len > 0) { if (cc.len > 0) {
var close_buf: [24]u8 = undefined; var close_buf: [24]u8 = undefined;
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() }); 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() });
} }
} }
@ -3181,7 +3182,7 @@ const App = struct {
if (chains[0].underlying_price) |price| { if (chains[0].underlying_price) |price| {
var price_buf: [24]u8 = undefined; 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.fmtMoneyAbs(&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.fmtMoney(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() });
} }
try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
@ -3232,8 +3233,7 @@ const App = struct {
const atm_price = chains[0].underlying_price orelse 0; const atm_price = chains[0].underlying_price orelse 0;
const itm = cc.strike <= atm_price; const itm = cc.strike <= atm_price;
const prefix: []const u8 = if (itm) " |" else " "; const prefix: []const u8 = if (itm) " |" else " ";
var contract_buf: [128]u8 = undefined; const text = try fmt.fmtContractLine(arena, prefix, cc);
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, cc));
const style = if (is_cursor) th.selectStyle() else th.contentStyle(); const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style }); try lines.append(arena, .{ .text = text, .style = style });
} }
@ -3243,8 +3243,7 @@ const App = struct {
const atm_price = chains[0].underlying_price orelse 0; const atm_price = chains[0].underlying_price orelse 0;
const itm = p.strike >= atm_price; const itm = p.strike >= atm_price;
const prefix: []const u8 = if (itm) " |" else " "; const prefix: []const u8 = if (itm) " |" else " ";
var contract_buf: [128]u8 = undefined; const text = try fmt.fmtContractLine(arena, prefix, p);
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p));
const style = if (is_cursor) th.selectStyle() else th.contentStyle(); const style = if (is_cursor) th.selectStyle() else th.contentStyle();
try lines.append(arena, .{ .text = text, .style = style }); try lines.append(arena, .{ .text = text, .style = style });
} }
@ -3352,7 +3351,7 @@ const App = struct {
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]); @memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' '); if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{ return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{
padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value), padded_label, bar, pct, fmt.fmtMoney(&val_buf, item.value),
}); });
} }