Compare commits
10 commits
e0129003e6
...
5b793fea8d
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b793fea8d | |||
| c50ba0124f | |||
| 0ca05ed3b4 | |||
| 535ab7d048 | |||
| 3a6b6f1e26 | |||
| d187664494 | |||
| feb1fe21f0 | |||
| a7448525ed | |||
| 543be9733e | |||
| 7eba504ed8 |
22 changed files with 509 additions and 426 deletions
27
.forgejo/workflows/zig-build.yaml
Normal file
27
.forgejo/workflows/zig-build.yaml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: Generic zig build
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '*'
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Zig
|
||||||
|
uses: https://codeberg.org/mlugg/setup-zig@v2.2.1
|
||||||
|
- name: Build project
|
||||||
|
run: zig build --summary all
|
||||||
|
- name: Run tests
|
||||||
|
run: zig build test --summary all
|
||||||
|
- name: Notify
|
||||||
|
uses: https://git.lerch.org/lobo/action-notify-ntfy@v2
|
||||||
|
if: always() && env.GITEA_ACTIONS == 'true'
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.NTFY_HOST }}
|
||||||
|
topic: ${{ secrets.NTFY_TOPIC }}
|
||||||
|
status: ${{ job.status }}
|
||||||
|
user: ${{ secrets.NTFY_USER }}
|
||||||
|
password: ${{ secrets.NTFY_PASSWORD }}
|
||||||
86
TODO.md
Normal file
86
TODO.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Future Work
|
||||||
|
|
||||||
|
## Covered call portfolio valuation
|
||||||
|
|
||||||
|
Portfolio value should account for sold call options. Shares covered by
|
||||||
|
in-the-money calls should be valued at the strike price, not the market price.
|
||||||
|
|
||||||
|
Example: 500 shares of AMZN at $225, with 3 sold calls at $220 strike.
|
||||||
|
300 shares should be valued at $220 (covered), 200 shares at $225 (uncovered).
|
||||||
|
|
||||||
|
## Institutional share class price ratios
|
||||||
|
|
||||||
|
Vanguard target date funds (e.g. 2035/VTTHX, 2040) held through Fidelity are
|
||||||
|
institutional share classes with prices that differ from the publicly traded
|
||||||
|
fund by a fixed ratio. The price can only be sourced from Fidelity directly,
|
||||||
|
but performance data (1/3/5/10yr returns) should be identical to the public
|
||||||
|
symbol.
|
||||||
|
|
||||||
|
Investigate: can we store a static price ratio in metadata (e.g. if Fidelity
|
||||||
|
says $100 and Morningstar says $20, ratio = 5) and multiply TwelveData quote
|
||||||
|
data by that ratio? Would this hold consistently over time, or does the ratio
|
||||||
|
drift?
|
||||||
|
|
||||||
|
## Market-aware cache TTL for daily candles
|
||||||
|
|
||||||
|
Daily candle TTL is currently 24 hours, but candle data only becomes meaningful
|
||||||
|
after the market close. Investigate keying the cache freshness to ~4:30 PM
|
||||||
|
Eastern (or whenever TwelveData actually publishes the daily candle) rather
|
||||||
|
than a rolling 24-hour window. This would avoid unnecessary refetches during
|
||||||
|
the trading day and ensure a fetch shortly after close gets fresh data.
|
||||||
|
|
||||||
|
## Yahoo Finance as primary quote source
|
||||||
|
|
||||||
|
Consider adding Yahoo Finance as the primary provider for real-time quotes,
|
||||||
|
with a silent fallback to TwelveData. Yahoo is free and has no API key
|
||||||
|
requirement, but the unofficial API is brittle and can break without notice.
|
||||||
|
TwelveData would serve as the reliable backup when Yahoo is unavailable.
|
||||||
|
|
||||||
|
## Human review of analytics modules
|
||||||
|
|
||||||
|
AI review complete; human review still needed for:
|
||||||
|
- `src/analytics/performance.zig` — Morningstar-style trailing returns
|
||||||
|
- `src/analytics/risk.zig` — Sharpe, volatility, max drawdown, portfolio summary
|
||||||
|
- `src/analytics/indicators.zig` — SMA, Bollinger Bands, RSI
|
||||||
|
- `src/models/classification.zig` — Sector/geo/asset-class metadata parsing
|
||||||
|
|
||||||
|
Known issues from AI review:
|
||||||
|
- `risk.zig` uses population variance (divides by n) instead of sample
|
||||||
|
variance (n-1). Negligible with 252+ data points but technically wrong.
|
||||||
|
|
||||||
|
## Risk-free rate maintenance
|
||||||
|
|
||||||
|
`risk.zig` `default_risk_free_rate` is currently 4.5% (T-bill proxy as of
|
||||||
|
early 2026). This is now a parameter to `computeRisk` with the default
|
||||||
|
exported as a public constant. Callers currently pass the default.
|
||||||
|
|
||||||
|
**Action needed:** When the Fed moves rates significantly, update
|
||||||
|
`default_risk_free_rate` in `src/analytics/risk.zig`. Eventually consider
|
||||||
|
making this a config value (env var or .env) so it doesn't require a rebuild.
|
||||||
|
|
||||||
|
## CLI/TUI code review (lower priority)
|
||||||
|
|
||||||
|
No review has been done on these files. They are presentation-layer code
|
||||||
|
and not part of the reusable library API.
|
||||||
|
|
||||||
|
TUI:
|
||||||
|
- `src/tui.zig`
|
||||||
|
- `src/tui/chart.zig`
|
||||||
|
- `src/tui/keybinds.zig`
|
||||||
|
- `src/tui/theme.zig`
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
- `src/commands/common.zig`
|
||||||
|
- `src/commands/analysis.zig`
|
||||||
|
- `src/commands/cache.zig`
|
||||||
|
- `src/commands/divs.zig`
|
||||||
|
- `src/commands/earnings.zig`
|
||||||
|
- `src/commands/enrich.zig`
|
||||||
|
- `src/commands/etf.zig`
|
||||||
|
- `src/commands/history.zig`
|
||||||
|
- `src/commands/lookup.zig`
|
||||||
|
- `src/commands/options.zig`
|
||||||
|
- `src/commands/perf.zig`
|
||||||
|
- `src/commands/portfolio.zig`
|
||||||
|
- `src/commands/quote.zig`
|
||||||
|
- `src/commands/splits.zig`
|
||||||
|
|
@ -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 fmt = @import("../format.zig");
|
const portfolio_mod = @import("../models/portfolio.zig");
|
||||||
|
|
||||||
/// Daily return series statistics.
|
/// Daily return series statistics.
|
||||||
pub const RiskMetrics = struct {
|
pub const RiskMetrics = struct {
|
||||||
|
|
@ -19,12 +19,14 @@ pub const RiskMetrics = struct {
|
||||||
sample_size: usize,
|
sample_size: usize,
|
||||||
};
|
};
|
||||||
|
|
||||||
const risk_free_annual = 0.045; // ~4.5% annualized, current T-bill proxy
|
/// Default risk-free rate (~4.5% annualized, current T-bill proxy).
|
||||||
|
/// Override via `computeRisk`'s `risk_free_rate` parameter.
|
||||||
|
pub const default_risk_free_rate: f64 = 0.045;
|
||||||
const trading_days_per_year: f64 = 252.0;
|
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) ?RiskMetrics {
|
pub fn computeRisk(candles: []const Candle, risk_free_rate: f64) ?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
|
||||||
|
|
@ -65,7 +67,7 @@ pub fn computeRisk(candles: []const Candle) ?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_annual) / annual_vol else 0;
|
const sharpe = if (annual_vol > 0) (annual_return - risk_free_rate) / annual_vol else 0;
|
||||||
|
|
||||||
return .{
|
return .{
|
||||||
.volatility = annual_vol,
|
.volatility = annual_vol,
|
||||||
|
|
@ -164,7 +166,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 (fmt.isCusipLike(pos.symbol) and pos.note != null)
|
const display = if (portfolio_mod.isCusipLike(pos.symbol) and pos.note != null)
|
||||||
shortLabel(pos.note.?)
|
shortLabel(pos.note.?)
|
||||||
else
|
else
|
||||||
pos.symbol;
|
pos.symbol;
|
||||||
|
|
@ -413,7 +415,7 @@ test "risk metrics basic" {
|
||||||
.volume = 1000,
|
.volume = 1000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const metrics = computeRisk(&candles);
|
const metrics = computeRisk(&candles, default_risk_free_rate);
|
||||||
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
|
||||||
|
|
@ -448,7 +450,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);
|
const metrics = computeRisk(&candles, default_risk_free_rate);
|
||||||
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);
|
||||||
|
|
@ -550,7 +552,7 @@ test "computeRisk insufficient data" {
|
||||||
candles[i] = makeCandle(Date.fromYmd(2024, 1, 2).addDays(@intCast(i)), 100.0 + @as(f64, @floatFromInt(i)));
|
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) == null);
|
try std.testing.expect(computeRisk(&candles, default_risk_free_rate) == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "adjustForNonStockAssets" {
|
test "adjustForNonStockAssets" {
|
||||||
|
|
|
||||||
27
src/cache/store.zig
vendored
27
src/cache/store.zig
vendored
|
|
@ -57,6 +57,17 @@ 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.
|
||||||
|
|
@ -84,7 +95,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.
|
||||||
fn dataTypeFor(comptime T: type) DataType {
|
pub fn dataTypeFor(comptime T: type) DataType {
|
||||||
return switch (T) {
|
return switch (T) {
|
||||||
Candle => .candles_daily,
|
Candle => .candles_daily,
|
||||||
Dividend => .dividends,
|
Dividend => .dividends,
|
||||||
|
|
@ -97,7 +108,7 @@ pub const Store = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The data payload for a given type: single struct for EtfProfile, slice for everything else.
|
/// The data payload for a given type: single struct for EtfProfile, slice for everything else.
|
||||||
fn DataFor(comptime T: type) type {
|
pub fn DataFor(comptime T: type) type {
|
||||||
return if (T == EtfProfile) EtfProfile else []T;
|
return if (T == EtfProfile) EtfProfile else []T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +132,18 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -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.fmtMoney(&val_buf, item.value) });
|
try out.print(" {d:>5.1}% {s}\n", .{ pct, fmt.fmtMoneyAbs(&val_buf, item.value) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = zfin.format;
|
pub const fmt = @import("../format.zig");
|
||||||
|
|
||||||
// ── 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)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
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,
|
||||||
|
|
@ -115,7 +116,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 (cli.fmt.isCusipLike(sym)) {
|
if (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;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
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 (!cli.fmt.isCusipLike(cusip)) {
|
if (!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);
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,10 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try display(out, allocator, ch, symbol, ntm, color);
|
try display(out, ch, symbol, ntm, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn display(out: *std.Io.Writer, allocator: std.mem.Allocator, chains: []const zfin.OptionsChain, symbol: []const u8, ntm: usize, color: bool) !void {
|
pub fn display(out: *std.Io.Writer, chains: []const zfin.OptionsChain, symbol: []const u8, ntm: usize, color: bool) !void {
|
||||||
if (chains.len == 0) return;
|
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, allocator: std.mem.Allocator, chains: []cons
|
||||||
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.fmtMoney(&price_buf, price), chains.len, ntm });
|
try out.print("Underlying: {s} {d} expiration(s) +/- {d} strikes NTM\n", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, ntm });
|
||||||
} else {
|
} 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, allocator: std.mem.Allocator, chains: []cons
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
|
|
||||||
// Print calls
|
// Print calls
|
||||||
try printSection(out, allocator, "CALLS", chain.calls, atm_price, ntm, true, color);
|
try printSection(out, "CALLS", chain.calls, atm_price, ntm, true, color);
|
||||||
try out.print("\n", .{});
|
try out.print("\n", .{});
|
||||||
// Print puts
|
// Print puts
|
||||||
try printSection(out, allocator, "PUTS", chain.puts, atm_price, ntm, false, color);
|
try printSection(out, "PUTS", chain.puts, atm_price, ntm, false, color);
|
||||||
} else {
|
} 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,7 +98,6 @@ pub fn display(out: *std.Io.Writer, allocator: std.mem.Allocator, chains: []cons
|
||||||
|
|
||||||
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,
|
||||||
|
|
@ -122,8 +121,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 " ";
|
||||||
const line = try fmt.fmtContractLine(allocator, prefix, c);
|
var contract_buf: [128]u8 = undefined;
|
||||||
defer allocator.free(line);
|
const line = fmt.fmtContractLine(&contract_buf, prefix, c);
|
||||||
try out.print("{s}\n", .{line});
|
try out.print("{s}\n", .{line});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +138,7 @@ test "printSection shows header and contracts" {
|
||||||
.{ .contract_type = .call, .strike = 150.0, .expiration = .{ .days = 20100 }, .bid = 5.0, .ask = 5.50, .last_price = 5.25 },
|
.{ .contract_type = .call, .strike = 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, gpa.allocator(), "CALLS", &calls, 152.0, 8, true, false);
|
try printSection(&w, "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);
|
||||||
|
|
@ -156,7 +155,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, gpa.allocator(), &chains, "SPY", 8, false);
|
try display(&w, &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);
|
||||||
|
|
|
||||||
|
|
@ -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.fmtMoney(&close_buf, c[c.len - 1].close)});
|
try out.print(")\nLatest close: {s}\n", .{fmt.fmtMoneyAbs(&close_buf, c[c.len - 1].close)});
|
||||||
|
|
||||||
const has_divs = result.asof_total != null;
|
const has_divs = result.asof_total != null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.fmtMoney(&val_buf, summary.total_value), fmt.fmtMoney(&cost_buf, summary.total_cost) });
|
try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoneyAbs(&val_buf, summary.total_value), fmt.fmtMoneyAbs(&cost_buf, summary.total_cost) });
|
||||||
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
|
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.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
|
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
|
||||||
} else {
|
} else {
|
||||||
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoney(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
|
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
|
||||||
}
|
}
|
||||||
try cli.reset(out, color);
|
try 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.fmtMoney(&gl_val_buf, gl_abs);
|
const gl_money = fmt.fmtMoneyAbs(&gl_val_buf, gl_abs);
|
||||||
const sign: []const u8 = if (a.unrealized_gain_loss >= 0) "+" else "-";
|
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.fmtMoney2(&cost_buf2, a.avg_cost),
|
a.display_symbol, a.shares, fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost),
|
||||||
});
|
});
|
||||||
try out.print("{s:>10}", .{fmt.fmtMoney2(&price_buf2, a.current_price)});
|
try out.print("{s:>10}", .{fmt.fmtMoneyAbs(&price_buf2, a.current_price)});
|
||||||
try out.print(" {s:>16} ", .{fmt.fmtMoney(&mv_buf, a.market_value)});
|
try out.print(" {s:>16} ", .{fmt.fmtMoneyAbs(&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.fmtMoney2(&avg_buf, drip.st.avgCost()),
|
fmt.fmtMoneyAbs(&avg_buf, drip.st.avgCost()),
|
||||||
if (drip.st.first_date) |d| d.format(&d1_buf)[0..7] else "?",
|
if (drip.st.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.fmtMoney2(&avg_buf2, drip.lt.avgCost()),
|
fmt.fmtMoneyAbs(&avg_buf2, drip.lt.avgCost()),
|
||||||
if (drip.lt.first_date) |d| d.format(&d1_buf2)[0..7] else "?",
|
if (drip.lt.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.fmtMoney(&total_mv_buf, summary.total_value),
|
"", "", "", "TOTAL", fmt.fmtMoneyAbs(&total_mv_buf, summary.total_value),
|
||||||
});
|
});
|
||||||
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
|
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.fmtMoney(&total_gl_buf, gl_abs)});
|
try out.print("+{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
|
||||||
} else {
|
} else {
|
||||||
try out.print("-{s:>13}", .{fmt.fmtMoney(&total_gl_buf, gl_abs)});
|
try out.print("-{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
|
||||||
}
|
}
|
||||||
try cli.reset(out, color);
|
try 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.fmtMoney(&rpl_buf, rpl_abs)});
|
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
|
||||||
} else {
|
} else {
|
||||||
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoney(&rpl_buf, rpl_abs)});
|
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
|
||||||
}
|
}
|
||||||
try cli.reset(out, color);
|
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.fmtMoney2(&cost_per_buf, cost_per),
|
fmt.fmtMoneyAbs(&cost_per_buf, cost_per),
|
||||||
fmt.fmtMoney(&total_cost_buf, total_cost_opt),
|
fmt.fmtMoneyAbs(&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.fmtMoney(&opt_total_buf, opt_total_cost),
|
"", "", "TOTAL", fmt.fmtMoneyAbs(&opt_total_buf, opt_total_cost),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -530,7 +530,7 @@ pub fn display(
|
||||||
const note_display = if (note_str.len > 50) note_str[0..50] else note_str;
|
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.fmtMoney(&face_buf, lot.shares),
|
fmt.fmtMoneyAbs(&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.fmtMoney(&cd_total_buf, cd_section_total),
|
"TOTAL", fmt.fmtMoneyAbs(&cd_total_buf, cd_section_total),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -615,9 +615,9 @@ pub fn display(
|
||||||
try out.print("\n", .{});
|
try 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.fmtMoney(&nw_buf, net_worth),
|
fmt.fmtMoneyAbs(&nw_buf, net_worth),
|
||||||
fmt.fmtMoney(&liq_buf, summary.total_value),
|
fmt.fmtMoneyAbs(&liq_buf, summary.total_value),
|
||||||
fmt.fmtMoney(&il_buf, illiquid_total),
|
fmt.fmtMoneyAbs(&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.fmtMoney2(&price_str, close)
|
fmt.fmtMoneyAbs(&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)) |metrics| {
|
if (zfin.risk.computeRisk(candles, zfin.risk.default_risk_free_rate)) |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.fmtMoney(&lot_gl_buf, lot_gl_abs);
|
const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_buf, lot_gl_abs);
|
||||||
const lot_sign: []const u8 = if (gl >= 0) "+" else "-";
|
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.fmtMoney(&lot_mv_buf, lot.shares * use_price);
|
const lot_mv = fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price);
|
||||||
|
|
||||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
try 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.fmtMoney2(&lot_price_buf, lot.open_price), "", lot_mv,
|
status_str, lot.shares, fmt.fmtMoneyAbs(&lot_price_buf, lot.open_price), "", lot_mv,
|
||||||
});
|
});
|
||||||
try cli.reset(out, color);
|
try cli.reset(out, color);
|
||||||
try cli.setGainLoss(out, color, gl);
|
try cli.setGainLoss(out, color, gl);
|
||||||
|
|
|
||||||
|
|
@ -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.fmtMoney(&price_buf, q.price) });
|
try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoneyAbs(&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.fmtMoney(&price_buf, candles[candles.len - 1].close) });
|
try out.print("\n{s} {s} (close)\n", .{ symbol, fmt.fmtMoneyAbs(&price_buf, candles[candles.len - 1].close) });
|
||||||
} else {
|
} else {
|
||||||
try out.print("\n{s}\n", .{symbol});
|
try out.print("\n{s}\n", .{symbol});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
114
src/format.zig
114
src/format.zig
|
|
@ -1,6 +1,6 @@
|
||||||
//! Shared formatting utilities used by both CLI and TUI.
|
//! Shared formatting utilities used by both CLI and TUI.
|
||||||
//!
|
//!
|
||||||
//! Number formatting (fmtMoney, fmtIntCommas, etc.), financial helpers
|
//! Number formatting (fmtMoneyAbs, fmtIntCommas, etc.), financial helpers
|
||||||
//! (capitalGainsIndicator, filterNearMoney), and braille chart computation.
|
//! (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 = fmtMoney(&money_buf, amount);
|
const money = fmtMoneyAbs(&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 = fmtMoney(&money_buf, total);
|
const money = fmtMoneyAbs(&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,19 +157,16 @@ 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 fn fmtIlliquidSep(buf: []u8) []const u8 {
|
pub const fmtIlliquidSep = fmtCashSep;
|
||||||
return fmtCashSep(buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format the illiquid total row.
|
/// Format the illiquid total row.
|
||||||
pub fn fmtIlliquidTotal(buf: []u8, total: f64) []const u8 {
|
pub const fmtIlliquidTotal = fmtCashTotal;
|
||||||
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
|
||||||
pub fn fmtMoney(buf: []u8, amount: f64) []const u8 {
|
/// Always returns the absolute value — callers handle sign display.
|
||||||
|
pub fn fmtMoneyAbs(buf: []u8, amount: f64) []const u8 {
|
||||||
const cents = @as(i64, @intFromFloat(@round(amount * 100.0)));
|
const 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;
|
||||||
|
|
@ -213,11 +210,6 @@ pub fn fmtMoney(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;
|
||||||
|
|
@ -252,13 +244,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 < 3600) {
|
if (delta < std.time.s_per_hour) {
|
||||||
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 < 86400) {
|
if (delta < std.time.s_per_day) {
|
||||||
return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, 3600)))}) catch "?";
|
return std.fmt.bufPrint(buf, "{d}h ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_hour)))}) catch "?";
|
||||||
}
|
}
|
||||||
return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, 86400)))}) catch "?";
|
return std.fmt.bufPrint(buf, "{d}d ago", .{@as(u64, @intCast(@divFloor(delta, std.time.s_per_day)))}) catch "?";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format large numbers with T/B/M suffixes (e.g. "1.5B", "45.6M").
|
/// Format large numbers with T/B/M suffixes (e.g. "1.5B", "45.6M").
|
||||||
|
|
@ -281,7 +273,7 @@ pub fn fmtLargeNum(val: f64) [15]u8 {
|
||||||
/// Get today's date.
|
/// 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, 86400));
|
const days: i32 = @intCast(@divFloor(ts, std.time.s_per_day));
|
||||||
return .{ .days = days };
|
return .{ .days = days };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,20 +328,6 @@ pub fn isMonthlyExpiration(date: Date) bool {
|
||||||
return d >= 15 and d <= 21; // 3rd Friday is between 15th and 21st
|
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 {
|
||||||
|
|
@ -370,7 +348,7 @@ pub fn toTitleCase(buf: []u8, s: []const u8) []const u8 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format an options contract line: strike + last + bid + ask + volume + OI + IV.
|
/// Format an options contract line: strike + last + bid + ask + volume + OI + IV.
|
||||||
pub fn fmtContractLine(alloc: std.mem.Allocator, prefix: []const u8, c: OptionContract) ![]const u8 {
|
pub fn fmtContractLine(buf: []u8, prefix: []const u8, c: OptionContract) []const u8 {
|
||||||
var last_buf: [12]u8 = undefined;
|
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;
|
||||||
|
|
@ -384,9 +362,9 @@ pub fn fmtContractLine(alloc: std.mem.Allocator, prefix: []const u8, c: OptionCo
|
||||||
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.allocPrint(alloc, "{s}{d:>10.2} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}", .{
|
return std.fmt.bufPrint(buf, "{s}{d:>10.2} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8}", .{
|
||||||
prefix, c.strike, last_str, bid_str, ask_str, vol_str, oi_str, iv_str,
|
prefix, c.strike, last_str, bid_str, ask_str, vol_str, oi_str, iv_str,
|
||||||
});
|
}) catch "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Portfolio helpers ────────────────────────────────────────
|
// ── Portfolio helpers ────────────────────────────────────────
|
||||||
|
|
@ -449,8 +427,6 @@ 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.
|
||||||
|
|
@ -465,7 +441,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 = fmtMoney(&money_buf, abs_val);
|
const money = fmtMoneyAbs(&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 };
|
||||||
|
|
@ -694,18 +670,14 @@ 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" or "MMM 'YY" depending on whether it's the same year as `ref_year`.
|
/// Format a date as "MMM DD" for the braille chart x-axis.
|
||||||
|
/// The year context is already visible in the surrounding CLI/TUI interface.
|
||||||
/// Returns the number of bytes written.
|
/// 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];
|
||||||
|
|
@ -716,8 +688,6 @@ 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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -922,28 +892,19 @@ pub fn ansiReset(out: anytype) !void {
|
||||||
|
|
||||||
// ── Tests ────────────────────────────────────────────────────
|
// ── Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
test "fmtMoney" {
|
test "fmtMoneyAbs" {
|
||||||
var buf: [24]u8 = undefined;
|
var buf: [24]u8 = undefined;
|
||||||
try std.testing.expectEqualStrings("$0.00", fmtMoney(&buf, 0));
|
try std.testing.expectEqualStrings("$0.00", fmtMoneyAbs(&buf, 0));
|
||||||
try std.testing.expectEqualStrings("$1.23", fmtMoney(&buf, 1.23));
|
try std.testing.expectEqualStrings("$1.23", fmtMoneyAbs(&buf, 1.23));
|
||||||
try std.testing.expectEqualStrings("$1,234.56", fmtMoney(&buf, 1234.56));
|
try std.testing.expectEqualStrings("$1,234.56", fmtMoneyAbs(&buf, 1234.56));
|
||||||
try std.testing.expectEqualStrings("$1,234,567.89", fmtMoney(&buf, 1234567.89));
|
try std.testing.expectEqualStrings("$1,234,567.89", fmtMoneyAbs(&buf, 1234567.89));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "fmtMoney negative" {
|
test "fmtMoneyAbs negative" {
|
||||||
// Negative amounts: the function uses abs(cents) so the sign is lost
|
// Returns absolute value — callers handle sign display.
|
||||||
// (implementation detail: no minus sign is produced, result is same as positive)
|
|
||||||
var buf: [24]u8 = undefined;
|
var buf: [24]u8 = undefined;
|
||||||
// Verify it doesn't crash on negative input
|
const result = fmtMoneyAbs(&buf, -1234.56);
|
||||||
const result = fmtMoney(&buf, -1234.56);
|
try std.testing.expectEqualStrings("$1,234.56", result);
|
||||||
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" {
|
||||||
|
|
@ -1078,17 +1039,6 @@ 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"));
|
||||||
|
|
@ -1264,7 +1214,7 @@ test "computeBrailleChart insufficient data" {
|
||||||
}
|
}
|
||||||
|
|
||||||
test "fmtContractLine" {
|
test "fmtContractLine" {
|
||||||
const alloc = std.testing.allocator;
|
var buf: [128]u8 = undefined;
|
||||||
const contract = OptionContract{
|
const contract = OptionContract{
|
||||||
.strike = 150.0,
|
.strike = 150.0,
|
||||||
.contract_type = .call,
|
.contract_type = .call,
|
||||||
|
|
@ -1276,22 +1226,20 @@ test "fmtContractLine" {
|
||||||
.open_interest = 5678,
|
.open_interest = 5678,
|
||||||
.implied_volatility = 0.25,
|
.implied_volatility = 0.25,
|
||||||
};
|
};
|
||||||
const line = try fmtContractLine(alloc, "C ", contract);
|
const line = fmtContractLine(&buf, "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" {
|
||||||
const alloc = std.testing.allocator;
|
var buf: [128]u8 = undefined;
|
||||||
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 = try fmtContractLine(alloc, "P ", contract);
|
const line = fmtContractLine(&buf, "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);
|
||||||
|
|
|
||||||
|
|
@ -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 = zfin.format.shouldUseColor(no_color_flag);
|
const color = @import("format.zig").shouldUseColor(no_color_flag);
|
||||||
|
|
||||||
var config = zfin.Config.fromEnv(allocator);
|
var config = zfin.Config.fromEnv(allocator);
|
||||||
defer config.deinit();
|
defer config.deinit();
|
||||||
|
|
@ -103,6 +103,11 @@ 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");
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,20 @@ pub const Portfolio = struct {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Check if a string looks like a CUSIP (9 alphanumeric characters).
|
||||||
|
/// CUSIPs have 6 alphanumeric issuer chars + 2 issue chars + 1 check digit.
|
||||||
|
/// This is a heuristic -- it won't catch all CUSIPs and may have false positives.
|
||||||
|
pub fn isCusipLike(s: []const u8) bool {
|
||||||
|
if (s.len != 9) return false;
|
||||||
|
// Must contain at least one digit (all-alpha would be a ticker)
|
||||||
|
var has_digit = false;
|
||||||
|
for (s) |c| {
|
||||||
|
if (!std.ascii.isAlphanumeric(c)) return false;
|
||||||
|
if (std.ascii.isDigit(c)) has_digit = true;
|
||||||
|
}
|
||||||
|
return has_digit;
|
||||||
|
}
|
||||||
|
|
||||||
test "lot basics" {
|
test "lot basics" {
|
||||||
const lot = Lot{
|
const lot = Lot{
|
||||||
.symbol = "AAPL",
|
.symbol = "AAPL",
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
pub const SecurityType = enum {
|
|
||||||
stock,
|
|
||||||
etf,
|
|
||||||
mutual_fund,
|
|
||||||
index,
|
|
||||||
crypto,
|
|
||||||
forex,
|
|
||||||
unknown,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Basic information about a ticker symbol.
|
|
||||||
pub const TickerInfo = struct {
|
|
||||||
symbol: []const u8,
|
|
||||||
name: ?[]const u8 = null,
|
|
||||||
exchange: ?[]const u8 = null,
|
|
||||||
security_type: SecurityType = .unknown,
|
|
||||||
currency: ?[]const u8 = null,
|
|
||||||
country: ?[]const u8 = null,
|
|
||||||
};
|
|
||||||
|
|
@ -59,6 +59,13 @@ pub fn acquire(self: *RateLimiter) void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sleep until a token is likely available, with a minimum 2-second floor.
|
||||||
|
/// Use after receiving a server-side 429 to wait before retrying.
|
||||||
|
pub fn backoff(self: *RateLimiter) void {
|
||||||
|
const wait_ns: u64 = @max(self.estimateWaitNs(), 2 * std.time.ns_per_s);
|
||||||
|
std.Thread.sleep(wait_ns);
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns estimated wait time in nanoseconds until a token is available.
|
/// Returns 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 {
|
||||||
|
|
|
||||||
140
src/net/http.zig
140
src/net/http.zig
|
|
@ -51,16 +51,26 @@ 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) {
|
||||||
if (self.doRequest(method, url, body, extra_headers)) |response| {
|
const response = self.doRequest(method, url, body, extra_headers) catch {
|
||||||
return classifyResponse(response);
|
|
||||||
} else |_| {
|
|
||||||
if (attempt >= self.max_retries) return HttpError.RequestFailed;
|
if (attempt >= self.max_retries) return HttpError.RequestFailed;
|
||||||
const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt);
|
self.backoffSleep(attempt);
|
||||||
std.Thread.sleep(backoff * std.time.ns_per_ms);
|
continue;
|
||||||
}
|
};
|
||||||
|
return classifyResponse(response) catch |err| {
|
||||||
|
if (err == HttpError.ServerError and attempt < self.max_retries) {
|
||||||
|
self.backoffSleep(attempt);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return err;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn backoffSleep(self: *Client, attempt: u8) void {
|
||||||
|
const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt);
|
||||||
|
std.Thread.sleep(backoff * std.time.ns_per_ms);
|
||||||
|
}
|
||||||
|
|
||||||
fn doRequest(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
|
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);
|
||||||
|
|
||||||
|
|
@ -70,13 +80,8 @@ 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 |err| {
|
}) catch {
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -93,82 +98,23 @@ pub const Client = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classifyResponse(response: Response) HttpError!Response {
|
fn classifyResponse(response: Response) HttpError!Response {
|
||||||
return switch (response.status) {
|
switch (response.status) {
|
||||||
.ok => response,
|
.ok => return response,
|
||||||
.too_many_requests => HttpError.RateLimited,
|
else => {
|
||||||
.unauthorized, .forbidden => HttpError.Unauthorized,
|
response.allocator.free(response.body);
|
||||||
.not_found => HttpError.NotFound,
|
return switch (response.status) {
|
||||||
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
|
.too_many_requests => HttpError.RateLimited,
|
||||||
else => HttpError.InvalidResponse,
|
.unauthorized, .forbidden => HttpError.Unauthorized,
|
||||||
};
|
.not_found => HttpError.NotFound,
|
||||||
|
.internal_server_error, .bad_gateway, .service_unavailable, .gateway_timeout => HttpError.ServerError,
|
||||||
|
else => HttpError.InvalidResponse,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Fallback HTTP request using system curl for TLS 1.2 hosts.
|
/// Build a URL with query parameters. Values are percent-encoded per RFC 3986.
|
||||||
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,
|
||||||
|
|
@ -182,20 +128,26 @@ 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('=');
|
||||||
for (param[1]) |c| {
|
try std.Uri.Component.percentEncode(&aw.writer, param[1], isQueryValueChar);
|
||||||
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", &.{
|
||||||
|
|
|
||||||
|
|
@ -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://finnhub.io/api/v1";
|
const base_url = "https://api.finnhub.io/api/v1";
|
||||||
|
|
||||||
pub const Finnhub = struct {
|
pub const Finnhub = struct {
|
||||||
api_key: []const u8,
|
api_key: []const u8,
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@
|
||||||
//!
|
//!
|
||||||
//! 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 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -89,12 +86,6 @@ 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.
|
||||||
|
|
|
||||||
311
src/service.zig
311
src/service.zig
|
|
@ -18,6 +18,7 @@ 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;
|
||||||
|
|
@ -25,6 +26,7 @@ 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{
|
||||||
|
|
@ -47,6 +49,15 @@ 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.
|
||||||
|
|
@ -95,40 +106,40 @@ pub const DataService = struct {
|
||||||
if (self.av) |*av| av.deinit();
|
if (self.av) |*av| av.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Provider accessors ───────────────────────────────────────
|
// ── Provider accessor ──────────────────────────────────────────
|
||||||
|
|
||||||
fn getTwelveData(self: *DataService) DataError!*TwelveData {
|
fn getProvider(self: *DataService, comptime T: type) DataError!*T {
|
||||||
if (self.td) |*td| return td;
|
const field_name = comptime providerField(T);
|
||||||
const key = self.config.twelvedata_key orelse return DataError.NoApiKey;
|
if (@field(self, field_name)) |*p| return p;
|
||||||
self.td = TwelveData.init(self.allocator, key);
|
if (T == Cboe) {
|
||||||
return &self.td.?;
|
// CBOE has no key
|
||||||
|
@field(self, field_name) = T.init(self.allocator);
|
||||||
|
} else {
|
||||||
|
// All we're doing here is lower casing the type name, then
|
||||||
|
// appending _key to it, so AlphaVantage -> alphavantage_key
|
||||||
|
const config_key = comptime blk: {
|
||||||
|
const full = @typeName(T);
|
||||||
|
var start: usize = 0;
|
||||||
|
for (full, 0..) |c, i| {
|
||||||
|
if (c == '.') start = i + 1;
|
||||||
|
}
|
||||||
|
const short = full[start..];
|
||||||
|
var buf: [short.len + 4]u8 = undefined;
|
||||||
|
_ = std.ascii.lowerString(buf[0..short.len], short);
|
||||||
|
@memcpy(buf[short.len..][0..4], "_key");
|
||||||
|
break :blk buf[0 .. short.len + 4];
|
||||||
|
};
|
||||||
|
const key = @field(self.config, config_key) orelse return DataError.NoApiKey;
|
||||||
|
@field(self, field_name) = T.init(self.allocator, key);
|
||||||
|
}
|
||||||
|
return &@field(self, field_name).?;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn getPolygon(self: *DataService) DataError!*Polygon {
|
fn providerField(comptime T: type) []const u8 {
|
||||||
if (self.pg) |*pg| return pg;
|
inline for (std.meta.fields(DataService)) |f| {
|
||||||
const key = self.config.polygon_key orelse return DataError.NoApiKey;
|
if (f.type == ?T) return f.name;
|
||||||
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 ─────────────────────────────────────────────
|
||||||
|
|
@ -137,6 +148,59 @@ 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();
|
||||||
|
|
@ -154,9 +218,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!struct { data: []Candle, source: Source, timestamp: i64 } {
|
pub fn getCandles(self: *DataService, symbol: []const u8) DataError!FetchResult(Candle) {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
const today = todayDate();
|
const today = fmt.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);
|
||||||
|
|
@ -178,14 +242,22 @@ 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.getTwelveData() catch {
|
var td = self.getProvider(TwelveData) 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 {
|
const new_candles = td.fetchCandles(self.allocator, symbol, fetch_from, today) catch |err| blk: {
|
||||||
// Fetch failed — return stale data rather than erroring
|
if (err == error.RateLimited) {
|
||||||
|
self.rateLimitBackoff();
|
||||||
|
break :blk td.fetchCandles(self.allocator, symbol, fetch_from, today) catch {
|
||||||
|
if (s.read(Candle, symbol, null, .any)) |r|
|
||||||
|
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
|
||||||
|
return DataError.FetchFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Non-rate-limit failure — return stale data
|
||||||
if (s.read(Candle, symbol, null, .any)) |r|
|
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;
|
||||||
|
|
@ -212,10 +284,17 @@ 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.getTwelveData();
|
var td = try self.getProvider(TwelveData);
|
||||||
const from = today.addDays(-3700);
|
const from = today.addDays(-3700);
|
||||||
|
|
||||||
const fetched = td.fetchCandles(self.allocator, symbol, from, today) catch {
|
const fetched = td.fetchCandles(self.allocator, symbol, from, today) catch |err| blk: {
|
||||||
|
if (err == error.RateLimited) {
|
||||||
|
self.rateLimitBackoff();
|
||||||
|
break :blk td.fetchCandles(self.allocator, symbol, from, today) catch {
|
||||||
|
return DataError.FetchFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
s.writeNegative(symbol, .candles_daily);
|
||||||
return DataError.FetchFailed;
|
return DataError.FetchFailed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -227,70 +306,27 @@ pub const DataService = struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch dividend history for a symbol.
|
/// Fetch dividend history for a symbol.
|
||||||
/// Checks cache first; fetches from Polygon if stale/missing.
|
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!FetchResult(Dividend) {
|
||||||
pub fn getDividends(self: *DataService, symbol: []const u8) DataError!struct { data: []Dividend, source: Source, timestamp: i64 } {
|
return self.fetchCached(Dividend, symbol, dividendPostProcess);
|
||||||
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.
|
||||||
/// Checks cache first; fetches from Polygon if stale/missing.
|
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!FetchResult(Split) {
|
||||||
pub fn getSplits(self: *DataService, symbol: []const u8) DataError!struct { data: []Split, source: Source, timestamp: i64 } {
|
return self.fetchCached(Split, symbol, null);
|
||||||
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).
|
/// Fetch options chain for a symbol (all expirations, no API key needed).
|
||||||
/// Checks cache first; fetches from CBOE if stale/missing (no API key needed).
|
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!FetchResult(OptionsChain) {
|
||||||
pub fn getOptions(self: *DataService, symbol: []const u8) DataError!struct { data: []OptionsChain, source: Source, timestamp: i64 } {
|
return self.fetchCached(OptionsChain, symbol, null);
|
||||||
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!struct { data: []EarningsEvent, source: Source, timestamp: i64 } {
|
pub fn getEarnings(self: *DataService, symbol: []const u8) DataError!FetchResult(EarningsEvent) {
|
||||||
var s = self.store();
|
var s = self.store();
|
||||||
const today = todayDate();
|
const today = fmt.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.
|
||||||
|
|
@ -306,31 +342,43 @@ pub const DataService = struct {
|
||||||
self.allocator.free(cached.data);
|
self.allocator.free(cached.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fh = try self.getFinnhub();
|
var fh = try self.getProvider(Finnhub);
|
||||||
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 {
|
const fetched = fh.fetchEarnings(self.allocator, symbol, from, to) catch |err| blk: {
|
||||||
|
if (err == error.RateLimited) {
|
||||||
|
self.rateLimitBackoff();
|
||||||
|
break :blk fh.fetchEarnings(self.allocator, symbol, from, to) catch {
|
||||||
|
return DataError.FetchFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
s.writeNegative(symbol, .earnings);
|
||||||
return DataError.FetchFailed;
|
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!struct { data: EtfProfile, source: Source, timestamp: i64 } {
|
pub fn getEtfProfile(self: *DataService, symbol: []const u8) DataError!FetchResult(EtfProfile) {
|
||||||
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.getAlphaVantage();
|
var av = try self.getProvider(AlphaVantage);
|
||||||
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch {
|
const fetched = av.fetchEtfProfile(self.allocator, symbol) catch |err| blk: {
|
||||||
|
if (err == error.RateLimited) {
|
||||||
|
self.rateLimitBackoff();
|
||||||
|
break :blk av.fetchEtfProfile(self.allocator, symbol) catch {
|
||||||
|
return DataError.FetchFailed;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
s.writeNegative(symbol, .etf_profile);
|
||||||
return DataError.FetchFailed;
|
return DataError.FetchFailed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -342,7 +390,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.getTwelveData();
|
var td = try self.getProvider(TwelveData);
|
||||||
return td.fetchQuote(self.allocator, symbol) catch
|
return td.fetchQuote(self.allocator, symbol) catch
|
||||||
return DataError.FetchFailed;
|
return DataError.FetchFailed;
|
||||||
}
|
}
|
||||||
|
|
@ -350,7 +398,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.getAlphaVantage();
|
var av = try self.getProvider(AlphaVantage);
|
||||||
return av.fetchCompanyOverview(self.allocator, symbol) catch
|
return av.fetchCompanyOverview(self.allocator, symbol) catch
|
||||||
return DataError.FetchFailed;
|
return DataError.FetchFailed;
|
||||||
}
|
}
|
||||||
|
|
@ -373,7 +421,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 = todayDate();
|
const today = fmt.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);
|
||||||
|
|
@ -602,6 +650,12 @@ 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 {
|
||||||
|
|
@ -611,30 +665,14 @@ 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);
|
||||||
|
|
||||||
// Simple line-based format: cusip::XXXXX,ticker::YYYYY
|
var reader = std.Io.Reader.fixed(data);
|
||||||
var lines = std.mem.splitScalar(u8, data, '\n');
|
var it = srf.iterator(&reader, self.allocator, .{ .alloc_strings = false }) catch return null;
|
||||||
while (lines.next()) |line| {
|
defer it.deinit();
|
||||||
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
|
||||||
if (trimmed.len == 0 or trimmed[0] == '#') continue;
|
|
||||||
|
|
||||||
// Parse cusip:: field
|
while (it.next() catch return null) |fields| {
|
||||||
const cusip_prefix = "cusip::";
|
const entry = fields.to(CusipEntry) catch continue;
|
||||||
if (!std.mem.startsWith(u8, trimmed, cusip_prefix)) continue;
|
if (std.mem.eql(u8, entry.cusip, cusip) and entry.ticker.len > 0) {
|
||||||
const after_cusip = trimmed[cusip_prefix.len..];
|
return self.allocator.dupe(u8, entry.ticker) catch null;
|
||||||
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;
|
||||||
|
|
@ -650,24 +688,31 @@ pub const DataService = struct {
|
||||||
std.fs.cwd().makePath(dir) catch {};
|
std.fs.cwd().makePath(dir) catch {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append the mapping
|
// Open existing (append) or create new (with header)
|
||||||
|
var emit_directives = false;
|
||||||
const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch blk: {
|
const file = std.fs.cwd().openFile(path, .{ .mode = .write_only }) catch blk: {
|
||||||
// File doesn't exist, create it
|
emit_directives = true;
|
||||||
break :blk std.fs.cwd().createFile(path, .{}) catch return;
|
break :blk std.fs.cwd().createFile(path, .{}) catch return;
|
||||||
};
|
};
|
||||||
defer file.close();
|
defer file.close();
|
||||||
file.seekFromEnd(0) catch {};
|
if (!emit_directives) file.seekFromEnd(0) catch {};
|
||||||
|
|
||||||
|
const entry = [_]CusipEntry{.{ .cusip = cusip, .ticker = ticker }};
|
||||||
var buf: [256]u8 = undefined;
|
var buf: [256]u8 = undefined;
|
||||||
const line = std.fmt.bufPrint(&buf, "cusip::{s},ticker::{s}\n", .{ cusip, ticker }) catch return;
|
var writer = file.writer(&buf);
|
||||||
_ = file.write(line) catch {};
|
writer.interface.print("{f}", .{srf.fmtFrom(CusipEntry, self.allocator, &entry, .{ .emit_directives = emit_directives })}) catch return;
|
||||||
|
writer.interface.flush() catch {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Utility ──────────────────────────────────────────────────
|
// ── Utility ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fn todayDate() Date {
|
/// Sleep before retrying after a rate limit error.
|
||||||
const ts = std.time.timestamp();
|
/// Uses the provider's rate limiter if available, otherwise a fixed 10s backoff.
|
||||||
const days: i32 = @intCast(@divFloor(ts, std.time.s_per_day));
|
fn rateLimitBackoff(self: *DataService) void {
|
||||||
return .{ .days = days };
|
if (self.td) |*td| {
|
||||||
|
td.rate_limiter.backoff();
|
||||||
|
} else {
|
||||||
|
std.Thread.sleep(10 * std.time.ns_per_s);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
65
src/tui.zig
65
src/tui.zig
|
|
@ -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 = zfin.format;
|
const fmt = @import("format.zig");
|
||||||
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,9 +476,7 @@ 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| {
|
for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(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;
|
||||||
|
|
@ -895,6 +893,7 @@ 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;
|
||||||
|
|
@ -1528,7 +1527,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);
|
self.risk_metrics = zfin.risk.computeRisk(c, zfin.risk.default_risk_free_rate);
|
||||||
|
|
||||||
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
|
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
|
||||||
if (!self.etf_loaded) {
|
if (!self.etf_loaded) {
|
||||||
|
|
@ -2035,10 +2034,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.fmtMoney(&val_buf, s.total_value);
|
const val_str = fmt.fmtMoneyAbs(&val_buf, s.total_value);
|
||||||
const cost_str = fmt.fmtMoney(&cost_buf, s.total_cost);
|
const cost_str = fmt.fmtMoneyAbs(&cost_buf, s.total_cost);
|
||||||
const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss;
|
const gl_abs = if (s.unrealized_gain_loss >= 0) s.unrealized_gain_loss else -s.unrealized_gain_loss;
|
||||||
const gl_str = fmt.fmtMoney(&gl_buf, gl_abs);
|
const gl_str = fmt.fmtMoneyAbs(&gl_buf, gl_abs);
|
||||||
const summary_text = try std.fmt.allocPrint(arena, " Value: {s} Cost: {s} Gain/Loss: {s}{s} ({d:.1}%)", .{
|
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,
|
||||||
});
|
});
|
||||||
|
|
@ -2060,9 +2059,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.fmtMoney(&nw_buf, net_worth),
|
fmt.fmtMoneyAbs(&nw_buf, net_worth),
|
||||||
val_str,
|
val_str,
|
||||||
fmt.fmtMoney(&il_buf, illiquid_total),
|
fmt.fmtMoneyAbs(&il_buf, illiquid_total),
|
||||||
});
|
});
|
||||||
try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
|
try lines.append(arena, .{ .text = nw_text, .style = th.headerStyle() });
|
||||||
}
|
}
|
||||||
|
|
@ -2141,18 +2140,18 @@ const App = struct {
|
||||||
const pnl_pct = if (a.cost_basis > 0) (a.unrealized_gain_loss / a.cost_basis) * 100.0 else @as(f64, 0);
|
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.fmtMoney(&gl_val_buf, gl_abs);
|
const gl_money = fmt.fmtMoneyAbs(&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.fmtMoney(&mv_buf, a.market_value);
|
const mv_str = fmt.fmtMoneyAbs(&mv_buf, a.market_value);
|
||||||
var cost_buf2: [24]u8 = undefined;
|
var cost_buf2: [24]u8 = undefined;
|
||||||
const cost_str = fmt.fmtMoney2(&cost_buf2, a.avg_cost);
|
const cost_str = fmt.fmtMoneyAbs(&cost_buf2, a.avg_cost);
|
||||||
var price_buf2: [24]u8 = undefined;
|
var price_buf2: [24]u8 = undefined;
|
||||||
const price_str = fmt.fmtMoney2(&price_buf2, a.current_price);
|
const price_str = fmt.fmtMoneyAbs(&price_buf2, a.current_price);
|
||||||
|
|
||||||
// Date + ST/LT: show for single-lot, blank for multi-lot
|
// 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;
|
||||||
|
|
@ -2233,17 +2232,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.fmtMoney(&lot_gl_money_buf, if (gl >= 0) gl else -gl);
|
const lot_gl_money = fmt.fmtMoneyAbs(&lot_gl_money_buf, if (gl >= 0) gl else -gl);
|
||||||
lot_gl_str = try std.fmt.allocPrint(arena, "{s}{s}", .{
|
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.fmtMoney(&lot_mv_buf, lot.shares * use_price)});
|
lot_mv_str = try std.fmt.allocPrint(arena, "{s}", .{fmt.fmtMoneyAbs(&lot_mv_buf, lot.shares * use_price)});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var price_str2: [24]u8 = undefined;
|
var price_str2: [24]u8 = undefined;
|
||||||
const lot_price_str = fmt.fmtMoney2(&price_str2, lot.open_price);
|
const lot_price_str = fmt.fmtMoneyAbs(&price_str2, lot.open_price);
|
||||||
const status_str: []const u8 = if (lot.isOpen()) "open" else "closed";
|
const 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 });
|
||||||
|
|
@ -2265,7 +2264,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.fmtMoney2(&price_str3, p) else "--")
|
(if (wp.get(row.symbol)) |p| fmt.fmtMoneyAbs(&price_str3, p) else "--")
|
||||||
else
|
else
|
||||||
"--";
|
"--";
|
||||||
const star2: []const u8 = if (is_active_sym) "* " else " ";
|
const star2: []const u8 = if (is_active_sym) "* " else " ";
|
||||||
|
|
@ -2306,8 +2305,8 @@ const App = struct {
|
||||||
const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{
|
const text = try std.fmt.allocPrint(arena, " {s:<30} {d:>6.0} {s:>12} {s:>14} {s}", .{
|
||||||
lot.symbol,
|
lot.symbol,
|
||||||
qty,
|
qty,
|
||||||
fmt.fmtMoney2(&cost_buf3, cost_per),
|
fmt.fmtMoneyAbs(&cost_buf3, cost_per),
|
||||||
fmt.fmtMoney(&total_buf, total_cost),
|
fmt.fmtMoneyAbs(&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();
|
||||||
|
|
@ -2331,7 +2330,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.fmtMoney(&face_buf, lot.shares),
|
fmt.fmtMoneyAbs(&face_buf, lot.shares),
|
||||||
rate_str,
|
rate_str,
|
||||||
mat_str,
|
mat_str,
|
||||||
note_display,
|
note_display,
|
||||||
|
|
@ -2348,7 +2347,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.fmtMoney(&cash_buf, total_cash),
|
fmt.fmtMoneyAbs(&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 });
|
||||||
|
|
@ -2370,7 +2369,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.fmtMoney(&illiquid_buf, total_illiquid),
|
fmt.fmtMoneyAbs(&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 });
|
||||||
|
|
@ -2396,7 +2395,7 @@ const App = struct {
|
||||||
label_str,
|
label_str,
|
||||||
row.drip_lot_count,
|
row.drip_lot_count,
|
||||||
row.drip_shares,
|
row.drip_shares,
|
||||||
fmt.fmtMoney2(&drip_avg_buf, row.drip_avg_cost),
|
fmt.fmtMoneyAbs(&drip_avg_buf, row.drip_avg_cost),
|
||||||
drip_d1,
|
drip_d1,
|
||||||
drip_d2,
|
drip_d2,
|
||||||
});
|
});
|
||||||
|
|
@ -2697,7 +2696,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.fmtMoney2(&lbl_buf, price_val);
|
const lbl = fmt.fmtMoneyAbs(&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;
|
||||||
|
|
@ -2794,7 +2793,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.fmtMoney(&qclose_buf, q.close)}), .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&qclose_buf, q.close)}), .style = th.contentStyle() });
|
||||||
{
|
{
|
||||||
var chg_buf: [64]u8 = undefined;
|
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();
|
||||||
|
|
@ -2881,7 +2880,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.fmtMoney(&close_buf, price)}), th.contentStyle());
|
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&close_buf, price)}), th.contentStyle());
|
||||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
|
try col1.add(arena, try std.fmt.allocPrint(arena, " 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());
|
||||||
|
|
@ -3058,7 +3057,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.fmtMoney(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoneyAbs(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3182,7 +3181,7 @@ const App = struct {
|
||||||
|
|
||||||
if (chains[0].underlying_price) |price| {
|
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.fmtMoney(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, self.options_near_the_money }), .style = th.contentStyle() });
|
||||||
}
|
}
|
||||||
|
|
||||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||||
|
|
@ -3233,7 +3232,8 @@ const App = struct {
|
||||||
const atm_price = chains[0].underlying_price orelse 0;
|
const 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 " ";
|
||||||
const text = try fmt.fmtContractLine(arena, prefix, cc);
|
var contract_buf: [128]u8 = undefined;
|
||||||
|
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, cc));
|
||||||
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
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,7 +3243,8 @@ 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 " ";
|
||||||
const text = try fmt.fmtContractLine(arena, prefix, p);
|
var contract_buf: [128]u8 = undefined;
|
||||||
|
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p));
|
||||||
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
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 });
|
||||||
}
|
}
|
||||||
|
|
@ -3351,7 +3352,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.fmtMoney(&val_buf, item.value),
|
padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue