Compare commits
30 commits
7a907fcc8d
...
7144f60d10
| Author | SHA1 | Date | |
|---|---|---|---|
| 7144f60d10 | |||
| 6d99349b62 | |||
| 04882a4ff8 | |||
| be42f9e15a | |||
| 1cdf228fc1 | |||
| e9a757cece | |||
| 35fc9101fd | |||
| ce72985054 | |||
| b718c1ae39 | |||
| 621a8db0df | |||
| 2bcb84dafa | |||
| 8ae9089975 | |||
| 31d49d4432 | |||
| 88241b2c7b | |||
| b4f3857cef | |||
| b162708055 | |||
| e29fb5b743 | |||
| 46bf34fd1c | |||
| 38d9065f4f | |||
| 8124ca0e88 | |||
| 4a3df7a05b | |||
| ff87505771 | |||
| b66b9391a5 | |||
| d442119d70 | |||
| 43ab8d1957 | |||
| 863111d801 | |||
| 21a45d5309 | |||
| c5c7f13400 | |||
| ea7da4ad5d | |||
| 71f328b329 |
26 changed files with 3794 additions and 2997 deletions
16
TODO.md
16
TODO.md
|
|
@ -1,9 +1,11 @@
|
|||
# Future Work
|
||||
|
||||
## TUI issues
|
||||
## CLI options command UX
|
||||
|
||||
Display artifacts that don't go away when switching tabs (need specific steps
|
||||
to reproduce). Lower priority now that ^L has been introduced to re-paint
|
||||
The `options` command auto-expands only the nearest monthly expiration and
|
||||
lists others collapsed. Reconsider the interaction model — e.g. allow
|
||||
specifying an expiration date, showing all monthlies expanded by default,
|
||||
or filtering by strategy (covered calls, spreads).
|
||||
|
||||
## Risk-free rate maintenance
|
||||
|
||||
|
|
@ -42,6 +44,14 @@ Commands:
|
|||
- `src/commands/quote.zig`
|
||||
- `src/commands/splits.zig`
|
||||
|
||||
## TUI: toggle to last symbol keybind
|
||||
|
||||
Add a single-key toggle that flips between the current symbol and the
|
||||
previously selected one (like `cd -` in bash or `Ctrl+^` in vim). Store
|
||||
`last_symbol` on `App`; on symbol change, stash the previous. The toggle
|
||||
key swaps current and last. Works on any tab — particularly useful for
|
||||
eyeball-comparing performance/risk data between two symbols.
|
||||
|
||||
## Market-aware cache TTL for daily candles
|
||||
|
||||
Daily candle TTL is currently 23h45m, but candle data only becomes meaningful
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ pub const BollingerBand = struct {
|
|||
|
||||
/// Compute Bollinger Bands (SMA ± k * stddev) for the full series.
|
||||
/// Returns a slice of optional BollingerBand — null where period hasn't been reached.
|
||||
///
|
||||
/// Uses O(n) sliding window algorithm instead of O(n * period):
|
||||
/// - Maintains running sum for SMA
|
||||
/// - Maintains running sum of squares for variance: Var(X) = E[X²] - E[X]²
|
||||
pub fn bollingerBands(
|
||||
alloc: std.mem.Allocator,
|
||||
closes: []const f64,
|
||||
|
|
@ -30,25 +34,61 @@ pub fn bollingerBands(
|
|||
k: f64,
|
||||
) ![]?BollingerBand {
|
||||
const result = try alloc.alloc(?BollingerBand, closes.len);
|
||||
for (result, 0..) |*r, i| {
|
||||
const mean = sma(closes, i, period) orelse {
|
||||
r.* = null;
|
||||
continue;
|
||||
};
|
||||
// Standard deviation
|
||||
const start = i + 1 - period;
|
||||
var sq_sum: f64 = 0;
|
||||
for (closes[start .. i + 1]) |v| {
|
||||
const diff = v - mean;
|
||||
sq_sum += diff * diff;
|
||||
}
|
||||
const stddev = @sqrt(sq_sum / @as(f64, @floatFromInt(period)));
|
||||
r.* = .{
|
||||
|
||||
if (closes.len < period or period == 0) {
|
||||
@memset(result, null);
|
||||
return result;
|
||||
}
|
||||
|
||||
const p_f: f64 = @floatFromInt(period);
|
||||
|
||||
// Initialize running sums for the first window [0..period)
|
||||
var sum: f64 = 0;
|
||||
var sum_sq: f64 = 0;
|
||||
for (0..period) |i| {
|
||||
sum += closes[i];
|
||||
sum_sq += closes[i] * closes[i];
|
||||
}
|
||||
|
||||
// First period-1 values are null (not enough data points)
|
||||
for (0..period - 1) |i| {
|
||||
result[i] = null;
|
||||
}
|
||||
|
||||
// Compute for index period-1 (first valid point)
|
||||
{
|
||||
const mean = sum / p_f;
|
||||
// Variance via E[X²] - E[X]² formula
|
||||
const variance = (sum_sq / p_f) - (mean * mean);
|
||||
// Use @max to guard against tiny negative values from floating point error
|
||||
const stddev = @sqrt(@max(variance, 0.0));
|
||||
result[period - 1] = .{
|
||||
.upper = mean + k * stddev,
|
||||
.middle = mean,
|
||||
.lower = mean - k * stddev,
|
||||
};
|
||||
}
|
||||
|
||||
// Slide the window for remaining points: O(n - period) iterations, O(1) each
|
||||
for (period..closes.len) |i| {
|
||||
const old_val = closes[i - period];
|
||||
const new_val = closes[i];
|
||||
|
||||
// Update running sums in O(1)
|
||||
sum = sum - old_val + new_val;
|
||||
sum_sq = sum_sq - (old_val * old_val) + (new_val * new_val);
|
||||
|
||||
const mean = sum / p_f;
|
||||
const variance = (sum_sq / p_f) - (mean * mean);
|
||||
const stddev = @sqrt(@max(variance, 0.0));
|
||||
|
||||
result[i] = .{
|
||||
.upper = mean + k * stddev,
|
||||
.middle = mean,
|
||||
.lower = mean - k * stddev,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -208,3 +248,89 @@ test "rsi insufficient data" {
|
|||
// All should be null since len < period + 1
|
||||
for (result) |r| try std.testing.expect(r == null);
|
||||
}
|
||||
|
||||
test "bollingerBands sliding window correctness" {
|
||||
const alloc = std.testing.allocator;
|
||||
// Test with realistic price data
|
||||
const closes = [_]f64{
|
||||
100.0, 101.5, 99.8, 102.3, 103.1, 101.9, 104.2, 105.0,
|
||||
103.8, 106.2, 107.1, 105.5, 108.0, 109.2, 107.8, 110.5,
|
||||
111.3, 109.8, 112.4, 113.0,
|
||||
};
|
||||
const bands = try bollingerBands(alloc, &closes, 5, 2.0);
|
||||
defer alloc.free(bands);
|
||||
|
||||
// First 4 should be null
|
||||
for (0..4) |i| {
|
||||
try std.testing.expect(bands[i] == null);
|
||||
}
|
||||
|
||||
// Verify a few points manually
|
||||
// Index 4: window is [100.0, 101.5, 99.8, 102.3, 103.1]
|
||||
// Mean = 101.34, manually computed
|
||||
const b4 = bands[4].?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 101.34), b4.middle, 0.01);
|
||||
try std.testing.expect(b4.upper > b4.middle);
|
||||
try std.testing.expect(b4.lower < b4.middle);
|
||||
|
||||
// Index 19: window is [109.8, 112.4, 113.0, 111.3, 110.5] — wait, let me recalculate
|
||||
// Window at i=19 is closes[15..20] = [110.5, 111.3, 109.8, 112.4, 113.0]
|
||||
// Mean = (110.5 + 111.3 + 109.8 + 112.4 + 113.0) / 5 = 557.0 / 5 = 111.4
|
||||
const b19 = bands[19].?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 111.4), b19.middle, 0.01);
|
||||
|
||||
// Verify bands are properly ordered
|
||||
for (bands) |b_opt| {
|
||||
if (b_opt) |b| {
|
||||
try std.testing.expect(b.upper >= b.middle);
|
||||
try std.testing.expect(b.middle >= b.lower);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "bollingerBands edge cases" {
|
||||
const alloc = std.testing.allocator;
|
||||
|
||||
// Empty data
|
||||
{
|
||||
const empty: []const f64 = &.{};
|
||||
const bands = try bollingerBands(alloc, empty, 5, 2.0);
|
||||
defer alloc.free(bands);
|
||||
try std.testing.expectEqual(@as(usize, 0), bands.len);
|
||||
}
|
||||
|
||||
// Data shorter than period
|
||||
{
|
||||
const short = [_]f64{ 1, 2, 3 };
|
||||
const bands = try bollingerBands(alloc, &short, 5, 2.0);
|
||||
defer alloc.free(bands);
|
||||
for (bands) |b| try std.testing.expect(b == null);
|
||||
}
|
||||
|
||||
// Period = 1 (each point is its own window, stddev = 0)
|
||||
{
|
||||
const data = [_]f64{ 10, 20, 30 };
|
||||
const bands = try bollingerBands(alloc, &data, 1, 2.0);
|
||||
defer alloc.free(bands);
|
||||
// With period=1, stddev=0, so upper=middle=lower
|
||||
for (bands, 0..) |b_opt, i| {
|
||||
const b = b_opt.?;
|
||||
try std.testing.expectApproxEqAbs(data[i], b.middle, 0.001);
|
||||
try std.testing.expectApproxEqAbs(data[i], b.upper, 0.001);
|
||||
try std.testing.expectApproxEqAbs(data[i], b.lower, 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
// Constant data (stddev = 0)
|
||||
{
|
||||
const constant = [_]f64{ 50, 50, 50, 50, 50 };
|
||||
const bands = try bollingerBands(alloc, &constant, 3, 2.0);
|
||||
defer alloc.free(bands);
|
||||
for (bands[2..]) |b_opt| {
|
||||
const b = b_opt.?;
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 50), b.middle, 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 50), b.upper, 0.001);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 50), b.lower, 0.001);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,7 @@ const cli = @import("common.zig");
|
|||
const fmt = cli.fmt;
|
||||
|
||||
/// CLI `analysis` command: show portfolio analysis breakdowns.
|
||||
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {
|
||||
_ = config;
|
||||
|
||||
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {
|
||||
// Load portfolio
|
||||
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
|
||||
try cli.stderrPrint("Error: Cannot read portfolio file\n");
|
||||
|
|
@ -23,11 +21,12 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
|
|||
const positions = try portfolio.positions(allocator);
|
||||
defer allocator.free(positions);
|
||||
|
||||
// Build prices map from cache
|
||||
const syms = try portfolio.stockSymbols(allocator);
|
||||
defer allocator.free(syms);
|
||||
|
||||
// Build prices from cache
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
|
||||
// First pass: try cached candle prices
|
||||
for (positions) |pos| {
|
||||
if (pos.shares <= 0) continue;
|
||||
if (svc.getCachedCandles(pos.symbol)) |cs| {
|
||||
|
|
@ -37,15 +36,16 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
|
|||
}
|
||||
}
|
||||
}
|
||||
// Build fallback prices for symbols without cached candle data
|
||||
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, &prices);
|
||||
defer manual_price_set.deinit();
|
||||
|
||||
var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices, manual_price_set) catch {
|
||||
try cli.stderrPrint("Error computing portfolio summary.\n");
|
||||
return;
|
||||
// Build summary via shared pipeline
|
||||
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc) catch |err| switch (err) {
|
||||
error.NoAllocations, error.SummaryFailed => {
|
||||
try cli.stderrPrint("Error computing portfolio summary.\n");
|
||||
return;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
defer summary.deinit(allocator);
|
||||
defer pf_data.deinit(allocator);
|
||||
|
||||
// Load classification metadata
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, '/')) |idx| idx + 1 else 0;
|
||||
|
|
@ -78,10 +78,10 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
|
|||
|
||||
var result = zfin.analysis.analyzePortfolio(
|
||||
allocator,
|
||||
summary.allocations,
|
||||
pf_data.summary.allocations,
|
||||
cm,
|
||||
portfolio,
|
||||
summary.total_value,
|
||||
pf_data.summary.total_value,
|
||||
acct_map_opt,
|
||||
) catch {
|
||||
try cli.stderrPrint("Error computing analysis.\n");
|
||||
|
|
@ -101,51 +101,22 @@ pub fn display(result: zfin.analysis.AnalysisResult, file_path: []const u8, colo
|
|||
try cli.reset(out, color);
|
||||
try out.print("========================================\n\n", .{});
|
||||
|
||||
// Asset Class
|
||||
try cli.setBold(out, color);
|
||||
try cli.setFg(out, color, cli.CLR_HEADER);
|
||||
try out.print(" Asset Class\n", .{});
|
||||
try cli.reset(out, color);
|
||||
try printBreakdownSection(out, result.asset_class, label_width, bar_width, color);
|
||||
const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{
|
||||
.{ .items = result.asset_class, .title = " Asset Class" },
|
||||
.{ .items = result.sector, .title = " Sector (Equities)" },
|
||||
.{ .items = result.geo, .title = " Geographic" },
|
||||
.{ .items = result.account, .title = " By Account" },
|
||||
.{ .items = result.tax_type, .title = " By Tax Type" },
|
||||
};
|
||||
|
||||
// Sector
|
||||
if (result.sector.len > 0) {
|
||||
try out.print("\n", .{});
|
||||
for (sections, 0..) |sec, si| {
|
||||
if (si > 0 and sec.items.len == 0) continue;
|
||||
if (si > 0) try out.print("\n", .{});
|
||||
try cli.setBold(out, color);
|
||||
try cli.setFg(out, color, cli.CLR_HEADER);
|
||||
try out.print(" Sector (Equities)\n", .{});
|
||||
try out.print("{s}\n", .{sec.title});
|
||||
try cli.reset(out, color);
|
||||
try printBreakdownSection(out, result.sector, label_width, bar_width, color);
|
||||
}
|
||||
|
||||
// Geographic
|
||||
if (result.geo.len > 0) {
|
||||
try out.print("\n", .{});
|
||||
try cli.setBold(out, color);
|
||||
try cli.setFg(out, color, cli.CLR_HEADER);
|
||||
try out.print(" Geographic\n", .{});
|
||||
try cli.reset(out, color);
|
||||
try printBreakdownSection(out, result.geo, label_width, bar_width, color);
|
||||
}
|
||||
|
||||
// By Account
|
||||
if (result.account.len > 0) {
|
||||
try out.print("\n", .{});
|
||||
try cli.setBold(out, color);
|
||||
try cli.setFg(out, color, cli.CLR_HEADER);
|
||||
try out.print(" By Account\n", .{});
|
||||
try cli.reset(out, color);
|
||||
try printBreakdownSection(out, result.account, label_width, bar_width, color);
|
||||
}
|
||||
|
||||
// Tax Type
|
||||
if (result.tax_type.len > 0) {
|
||||
try out.print("\n", .{});
|
||||
try cli.setBold(out, color);
|
||||
try cli.setFg(out, color, cli.CLR_HEADER);
|
||||
try out.print(" By Tax Type\n", .{});
|
||||
try cli.reset(out, color);
|
||||
try printBreakdownSection(out, result.tax_type, label_width, bar_width, color);
|
||||
try printBreakdownSection(out, sec.items, label_width, bar_width, color);
|
||||
}
|
||||
|
||||
// Unclassified
|
||||
|
|
|
|||
|
|
@ -123,6 +123,235 @@ pub const LoadProgress = struct {
|
|||
}
|
||||
};
|
||||
|
||||
/// Aggregate progress callback for parallel loading operations.
|
||||
/// Displays a single updating line with progress bar.
|
||||
pub const AggregateProgress = struct {
|
||||
color: bool,
|
||||
last_phase: ?zfin.DataService.AggregateProgressCallback.Phase = null,
|
||||
|
||||
fn onProgress(ctx: *anyopaque, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase) void {
|
||||
const self: *AggregateProgress = @ptrCast(@alignCast(ctx));
|
||||
|
||||
// Track phase transitions for newlines
|
||||
const phase_changed = self.last_phase == null or self.last_phase.? != phase;
|
||||
self.last_phase = phase;
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.fs.File.stderr().writer(&buf);
|
||||
const out = &writer.interface;
|
||||
|
||||
switch (phase) {
|
||||
.cache_check => {
|
||||
// Brief phase, no output needed
|
||||
},
|
||||
.server_sync => {
|
||||
// Single updating line with carriage return
|
||||
if (self.color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
out.print("\r Syncing from server... [{d}/{d}]", .{ completed, total }) catch {};
|
||||
if (self.color) fmt.ansiReset(out) catch {};
|
||||
out.flush() catch {};
|
||||
},
|
||||
.provider_fetch => {
|
||||
if (phase_changed) {
|
||||
// Clear the server sync line and print newline
|
||||
out.print("\r\x1b[K", .{}) catch {}; // clear line
|
||||
if (self.color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
out.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed}) catch {};
|
||||
if (self.color) fmt.ansiReset(out) catch {};
|
||||
out.flush() catch {};
|
||||
}
|
||||
},
|
||||
.complete => {
|
||||
// Final newline if we were on server_sync line
|
||||
if (self.last_phase != null and
|
||||
(self.last_phase.? == .server_sync or self.last_phase.? == .cache_check))
|
||||
{
|
||||
out.print("\r\x1b[K", .{}) catch {}; // clear line
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn callback(self: *AggregateProgress) zfin.DataService.AggregateProgressCallback {
|
||||
return .{
|
||||
.context = @ptrCast(self),
|
||||
.on_progress = onProgress,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Unified price loading for both CLI and TUI.
|
||||
/// Handles parallel server sync when ZFIN_SERVER is configured,
|
||||
/// with sequential provider fallback for failures.
|
||||
pub fn loadPortfolioPrices(
|
||||
svc: *zfin.DataService,
|
||||
portfolio_syms: ?[]const []const u8,
|
||||
watch_syms: []const []const u8,
|
||||
force_refresh: bool,
|
||||
color: bool,
|
||||
) zfin.DataService.LoadAllResult {
|
||||
var aggregate = AggregateProgress{ .color = color };
|
||||
var symbol_progress = LoadProgress{
|
||||
.svc = svc,
|
||||
.color = color,
|
||||
.index_offset = 0,
|
||||
.grand_total = (if (portfolio_syms) |ps| ps.len else 0) + watch_syms.len,
|
||||
};
|
||||
|
||||
const result = svc.loadAllPrices(
|
||||
portfolio_syms,
|
||||
watch_syms,
|
||||
.{ .force_refresh = force_refresh, .color = color },
|
||||
aggregate.callback(),
|
||||
symbol_progress.callback(),
|
||||
);
|
||||
|
||||
// Print summary
|
||||
const total = symbol_progress.grand_total;
|
||||
const from_cache = result.cached_count;
|
||||
const from_server = result.server_synced_count;
|
||||
const from_provider = result.provider_fetched_count;
|
||||
const failed = result.failed_count;
|
||||
const stale = result.stale_count;
|
||||
|
||||
var buf: [256]u8 = undefined;
|
||||
var writer = std.fs.File.stderr().writer(&buf);
|
||||
const out = &writer.interface;
|
||||
|
||||
if (from_cache == total) {
|
||||
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
out.print(" Loaded {d} symbols from cache\n", .{total}) catch {};
|
||||
if (color) fmt.ansiReset(out) catch {};
|
||||
} else if (failed > 0) {
|
||||
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
if (stale > 0) {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ total, from_cache, from_server, from_provider, failed, stale }) catch {};
|
||||
} else {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ total, from_cache, from_server, from_provider, failed }) catch {};
|
||||
}
|
||||
if (color) fmt.ansiReset(out) catch {};
|
||||
} else {
|
||||
if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {};
|
||||
if (from_server > 0 and from_provider > 0) {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ total, from_cache, from_server, from_provider }) catch {};
|
||||
} else if (from_server > 0) {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ total, from_cache, from_server }) catch {};
|
||||
} else {
|
||||
out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ total, from_cache, from_provider }) catch {};
|
||||
}
|
||||
if (color) fmt.ansiReset(out) catch {};
|
||||
}
|
||||
out.flush() catch {};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Portfolio data pipeline ──────────────────────────────────
|
||||
|
||||
/// Result of the shared portfolio data pipeline. Caller must call deinit().
|
||||
pub const PortfolioData = struct {
|
||||
summary: zfin.valuation.PortfolioSummary,
|
||||
candle_map: std.StringHashMap([]const zfin.Candle),
|
||||
snapshots: ?[6]zfin.valuation.HistoricalSnapshot,
|
||||
|
||||
pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void {
|
||||
self.summary.deinit(allocator);
|
||||
var it = self.candle_map.valueIterator();
|
||||
while (it.next()) |v| allocator.free(v.*);
|
||||
self.candle_map.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
/// Build portfolio summary, candle map, and historical snapshots from
|
||||
/// pre-populated prices. Shared between CLI `portfolio` command, TUI
|
||||
/// `loadPortfolioData`, and TUI `reloadPortfolioFile`.
|
||||
///
|
||||
/// Callers are responsible for populating `prices` (via network fetch,
|
||||
/// cache read, or pre-fetched map) before calling this.
|
||||
///
|
||||
/// Returns error.NoAllocations if the summary produces no positions
|
||||
/// (e.g. no cached prices available).
|
||||
pub fn buildPortfolioData(
|
||||
allocator: std.mem.Allocator,
|
||||
portfolio: zfin.Portfolio,
|
||||
positions: []const zfin.Position,
|
||||
syms: []const []const u8,
|
||||
prices: *std.StringHashMap(f64),
|
||||
svc: *zfin.DataService,
|
||||
) !PortfolioData {
|
||||
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices);
|
||||
defer manual_price_set.deinit();
|
||||
|
||||
var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices.*, manual_price_set) catch
|
||||
return error.SummaryFailed;
|
||||
errdefer summary.deinit(allocator);
|
||||
|
||||
if (summary.allocations.len == 0) {
|
||||
summary.deinit(allocator);
|
||||
return error.NoAllocations;
|
||||
}
|
||||
|
||||
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
|
||||
errdefer {
|
||||
var it = candle_map.valueIterator();
|
||||
while (it.next()) |v| allocator.free(v.*);
|
||||
candle_map.deinit();
|
||||
}
|
||||
for (syms) |sym| {
|
||||
if (svc.getCachedCandles(sym)) |cs| {
|
||||
candle_map.put(sym, cs) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
const snapshots = zfin.valuation.computeHistoricalSnapshots(
|
||||
fmt.todayDate(),
|
||||
positions,
|
||||
prices.*,
|
||||
candle_map,
|
||||
);
|
||||
|
||||
return .{
|
||||
.summary = summary,
|
||||
.candle_map = candle_map,
|
||||
.snapshots = snapshots,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Watchlist loading ────────────────────────────────────────
|
||||
|
||||
/// Load a watchlist file using the library's SRF deserializer.
|
||||
/// Returns owned symbol strings. Returns null if file missing or empty.
|
||||
pub fn loadWatchlist(allocator: std.mem.Allocator, path: []const u8) ?[][]const u8 {
|
||||
const file_data = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch return null;
|
||||
defer allocator.free(file_data);
|
||||
|
||||
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch return null;
|
||||
defer portfolio.deinit();
|
||||
|
||||
if (portfolio.lots.len == 0) return null;
|
||||
|
||||
var syms: std.ArrayList([]const u8) = .empty;
|
||||
for (portfolio.lots) |lot| {
|
||||
const duped = allocator.dupe(u8, lot.symbol) catch continue;
|
||||
syms.append(allocator, duped) catch {
|
||||
allocator.free(duped);
|
||||
continue;
|
||||
};
|
||||
}
|
||||
if (syms.items.len == 0) {
|
||||
syms.deinit(allocator);
|
||||
return null;
|
||||
}
|
||||
return syms.toOwnedSlice(allocator) catch null;
|
||||
}
|
||||
|
||||
pub fn freeWatchlist(allocator: std.mem.Allocator, watchlist: ?[][]const u8) void {
|
||||
if (watchlist) |wl| {
|
||||
for (wl) |sym| allocator.free(sym);
|
||||
allocator.free(wl);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "setFg emits ANSI when color enabled" {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
|
|||
};
|
||||
defer allocator.free(result.data);
|
||||
|
||||
// Sort chronologically (oldest first) — providers may return in any order
|
||||
if (result.data.len > 1) {
|
||||
std.mem.sort(zfin.EarningsEvent, result.data, {}, struct {
|
||||
fn f(_: void, a: zfin.EarningsEvent, b: zfin.EarningsEvent) bool {
|
||||
return a.date.days < b.date.days;
|
||||
}
|
||||
}.f);
|
||||
}
|
||||
|
||||
if (result.source == .cached) try cli.stderrPrint("(using cached earnings data)\n");
|
||||
|
||||
try display(result.data, symbol, color, out);
|
||||
|
|
@ -64,32 +73,8 @@ pub fn display(events: []const zfin.EarningsEvent, symbol: []const u8, color: bo
|
|||
try out.print("\n{d} earnings event(s)\n\n", .{events.len});
|
||||
}
|
||||
|
||||
pub fn fmtEps(val: f64) [12]u8 {
|
||||
var buf: [12]u8 = .{' '} ** 12;
|
||||
_ = std.fmt.bufPrint(&buf, "${d:.2}", .{val}) catch {};
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────
|
||||
|
||||
test "fmtEps formats positive value" {
|
||||
const result = fmtEps(1.25);
|
||||
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
|
||||
try std.testing.expectEqualStrings("$1.25", trimmed);
|
||||
}
|
||||
|
||||
test "fmtEps formats negative value" {
|
||||
const result = fmtEps(-0.50);
|
||||
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
|
||||
try std.testing.expect(std.mem.indexOf(u8, trimmed, "0.5") != null);
|
||||
}
|
||||
|
||||
test "fmtEps formats zero" {
|
||||
const result = fmtEps(0.0);
|
||||
const trimmed = std.mem.trimRight(u8, &result, &.{' '});
|
||||
try std.testing.expectEqualStrings("$0.00", trimmed);
|
||||
}
|
||||
|
||||
test "display shows earnings with beat" {
|
||||
var buf: [4096]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,36 @@ const zfin = @import("../root.zig");
|
|||
const cli = @import("common.zig");
|
||||
const isCusipLike = @import("../models/portfolio.zig").isCusipLike;
|
||||
|
||||
const OverviewMeta = struct {
|
||||
sector: []const u8,
|
||||
geo: []const u8,
|
||||
asset_class: []const u8,
|
||||
};
|
||||
|
||||
/// Derive sector, geo, and asset_class from an Alpha Vantage company overview.
|
||||
fn deriveMetadata(overview: zfin.CompanyOverview, sector_buf: []u8) OverviewMeta {
|
||||
const sector_raw = overview.sector orelse "Unknown";
|
||||
const sector_str = cli.fmt.toTitleCase(sector_buf, sector_raw);
|
||||
const country_str = overview.country orelse "US";
|
||||
const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str;
|
||||
|
||||
const asset_class_str = blk: {
|
||||
if (overview.asset_type) |at| {
|
||||
if (std.mem.eql(u8, at, "ETF")) break :blk "ETF";
|
||||
if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund";
|
||||
}
|
||||
if (overview.market_cap) |mc_str| {
|
||||
const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0;
|
||||
if (mc >= 10_000_000_000) break :blk "US Large Cap";
|
||||
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
|
||||
break :blk "US Small Cap";
|
||||
}
|
||||
break :blk "US Large Cap";
|
||||
};
|
||||
|
||||
return .{ .sector = sector_str, .geo = geo_str, .asset_class = asset_class_str };
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// and outputs a metadata SRF file to stdout.
|
||||
|
|
@ -50,31 +80,14 @@ fn enrichSymbol(allocator: std.mem.Allocator, svc: *zfin.DataService, sym: []con
|
|||
if (overview.asset_type) |at| allocator.free(at);
|
||||
}
|
||||
|
||||
const sector_raw = overview.sector orelse "Unknown";
|
||||
var sector_buf: [64]u8 = undefined;
|
||||
const sector_str = cli.fmt.toTitleCase(§or_buf, sector_raw);
|
||||
const country_str = overview.country orelse "US";
|
||||
const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str;
|
||||
|
||||
const asset_class_str = blk: {
|
||||
if (overview.asset_type) |at| {
|
||||
if (std.mem.eql(u8, at, "ETF")) break :blk "ETF";
|
||||
if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund";
|
||||
}
|
||||
if (overview.market_cap) |mc_str| {
|
||||
const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0;
|
||||
if (mc >= 10_000_000_000) break :blk "US Large Cap";
|
||||
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
|
||||
break :blk "US Small Cap";
|
||||
}
|
||||
break :blk "US Large Cap";
|
||||
};
|
||||
const meta = deriveMetadata(overview, §or_buf);
|
||||
|
||||
if (overview.name) |name| {
|
||||
try out.print("# {s}\n", .{name});
|
||||
}
|
||||
try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n", .{
|
||||
sym, sector_str, geo_str, asset_class_str,
|
||||
sym, meta.sector, meta.geo, meta.asset_class,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -159,34 +172,15 @@ fn enrichPortfolio(allocator: std.mem.Allocator, svc: *zfin.DataService, file_pa
|
|||
if (overview.asset_type) |at| allocator.free(at);
|
||||
}
|
||||
|
||||
const sector_raw = overview.sector orelse "Unknown";
|
||||
var sector_buf: [64]u8 = undefined;
|
||||
const sector_str = cli.fmt.toTitleCase(§or_buf, sector_raw);
|
||||
const country_str = overview.country orelse "US";
|
||||
const geo_str = if (std.mem.eql(u8, country_str, "USA")) "US" else country_str;
|
||||
|
||||
// Determine asset_class from asset type + market cap
|
||||
const asset_class_str = blk: {
|
||||
if (overview.asset_type) |at| {
|
||||
if (std.mem.eql(u8, at, "ETF")) break :blk "ETF";
|
||||
if (std.mem.eql(u8, at, "Mutual Fund")) break :blk "Mutual Fund";
|
||||
}
|
||||
// For common stocks, infer from market cap
|
||||
if (overview.market_cap) |mc_str| {
|
||||
const mc = std.fmt.parseInt(u64, mc_str, 10) catch 0;
|
||||
if (mc >= 10_000_000_000) break :blk "US Large Cap";
|
||||
if (mc >= 2_000_000_000) break :blk "US Mid Cap";
|
||||
break :blk "US Small Cap";
|
||||
}
|
||||
break :blk "US Large Cap";
|
||||
};
|
||||
const meta = deriveMetadata(overview, §or_buf);
|
||||
|
||||
// Comment with the name for readability
|
||||
if (overview.name) |name| {
|
||||
try out.print("# {s}\n", .{name});
|
||||
}
|
||||
try out.print("symbol::{s},sector::{s},geo::{s},asset_class::{s}\n\n", .{
|
||||
sym, sector_str, geo_str, asset_class_str,
|
||||
sym, meta.sector, meta.geo, meta.asset_class,
|
||||
});
|
||||
success += 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const fmt = cli.fmt;
|
|||
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
|
||||
const result = svc.getCandles(symbol) catch |err| switch (err) {
|
||||
zfin.DataError.NoApiKey => {
|
||||
try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set.\n");
|
||||
try cli.stderrPrint("Error: No API key configured for candle data.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
|
|
@ -46,7 +46,7 @@ pub fn display(candles: []const zfin.Candle, symbol: []const u8, color: bool, ou
|
|||
for (candles) |candle| {
|
||||
var db: [10]u8 = undefined;
|
||||
var vb: [32]u8 = undefined;
|
||||
try cli.setGainLoss(out, color, if (candle.close >= candle.open) @as(f64, 1) else @as(f64, -1));
|
||||
try cli.setGainLoss(out, color, if (candle.close >= candle.open) 1.0 else -1.0);
|
||||
try out.print("{s:>12} {d:>10.2} {d:>10.2} {d:>10.2} {d:>10.2} {s:>12}\n", .{
|
||||
candle.date.format(&db), candle.open, candle.high, candle.low, candle.close, fmt.fmtIntCommas(&vb, candle.volume),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -132,8 +132,6 @@ pub fn printSection(
|
|||
test "printSection shows header and contracts" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
const calls = [_]zfin.OptionContract{
|
||||
.{ .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 },
|
||||
|
|
@ -148,8 +146,6 @@ test "printSection shows header and contracts" {
|
|||
test "display shows chain header no color" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||
defer _ = gpa.deinit();
|
||||
const calls = [_]zfin.OptionContract{};
|
||||
const puts = [_]zfin.OptionContract{};
|
||||
const chains = [_]zfin.OptionsChain{
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ pub fn printReturnsTable(
|
|||
);
|
||||
|
||||
if (price_arr[i] != null) {
|
||||
try cli.setGainLoss(out, color, if (row.price_positive) @as(f64, 1) else @as(f64, -1));
|
||||
try cli.setGainLoss(out, color, if (row.price_positive) 1.0 else -1.0);
|
||||
} else {
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
}
|
||||
|
|
@ -133,7 +133,7 @@ pub fn printReturnsTable(
|
|||
|
||||
if (has_total) {
|
||||
if (row.total_str) |ts| {
|
||||
try cli.setGainLoss(out, color, if (row.price_positive) @as(f64, 1) else @as(f64, -1));
|
||||
try cli.setGainLoss(out, color, if (row.price_positive) 1.0 else -1.0);
|
||||
try out.print(" {s:>13}", .{ts});
|
||||
try cli.reset(out, color);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const zfin = @import("../root.zig");
|
|||
const cli = @import("common.zig");
|
||||
const fmt = cli.fmt;
|
||||
|
||||
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {
|
||||
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {
|
||||
// Load portfolio from SRF file
|
||||
const data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch |err| {
|
||||
try cli.stderrPrint("Error reading portfolio file: ");
|
||||
|
|
@ -56,86 +56,41 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
|
|||
const all_syms_count = syms.len + watch_syms.items.len;
|
||||
|
||||
if (all_syms_count > 0) {
|
||||
if (config.twelvedata_key == null) {
|
||||
try cli.stderrPrint("Warning: TWELVEDATA_API_KEY not set. Cannot fetch current prices.\n");
|
||||
}
|
||||
|
||||
// Progress callback for per-symbol output
|
||||
var progress_ctx = cli.LoadProgress{
|
||||
.svc = svc,
|
||||
.color = color,
|
||||
.index_offset = 0,
|
||||
.grand_total = all_syms_count,
|
||||
};
|
||||
|
||||
// Load prices for stock/ETF positions
|
||||
const load_result = svc.loadPrices(syms, &prices, force_refresh, progress_ctx.callback());
|
||||
fail_count = load_result.fail_count;
|
||||
|
||||
// Fetch watch symbol candles (for watchlist display, not portfolio value)
|
||||
progress_ctx.index_offset = syms.len;
|
||||
_ = svc.loadPrices(watch_syms.items, &prices, force_refresh, progress_ctx.callback());
|
||||
|
||||
// Summary line
|
||||
{
|
||||
const cached_count = load_result.cached_count;
|
||||
const fetched_count = load_result.fetched_count;
|
||||
var msg_buf: [256]u8 = undefined;
|
||||
if (cached_count == all_syms_count) {
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "All {d} symbols loaded from cache\n", .{all_syms_count}) catch "Loaded from cache\n";
|
||||
try cli.stderrPrint(msg);
|
||||
} else if (fail_count > 0) {
|
||||
const stale = load_result.stale_count;
|
||||
if (stale > 0) {
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed — {d} using stale cache)\n", .{ all_syms_count, cached_count, fetched_count, fail_count, stale }) catch "Done loading\n";
|
||||
try cli.stderrPrint(msg);
|
||||
} else {
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched, {d} failed)\n", .{ all_syms_count, cached_count, fetched_count, fail_count }) catch "Done loading\n";
|
||||
try cli.stderrPrint(msg);
|
||||
}
|
||||
} else {
|
||||
const msg = std.fmt.bufPrint(&msg_buf, "Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ all_syms_count, cached_count, fetched_count }) catch "Done loading\n";
|
||||
try cli.stderrPrint(msg);
|
||||
}
|
||||
// Use consolidated parallel loader
|
||||
var load_result = cli.loadPortfolioPrices(
|
||||
svc,
|
||||
syms,
|
||||
watch_syms.items,
|
||||
force_refresh,
|
||||
color,
|
||||
);
|
||||
defer load_result.deinit(); // Free the prices hashmap after we copy
|
||||
|
||||
// Transfer prices to our local map
|
||||
var it = load_result.prices.iterator();
|
||||
while (it.next()) |entry| {
|
||||
prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {};
|
||||
}
|
||||
fail_count = load_result.failed_count;
|
||||
}
|
||||
|
||||
// Compute summary
|
||||
// Build fallback prices for symbols that failed API fetch
|
||||
var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, &prices);
|
||||
defer manual_price_set.deinit();
|
||||
|
||||
var summary = zfin.valuation.portfolioSummary(allocator, portfolio, positions, prices, manual_price_set) catch {
|
||||
try cli.stderrPrint("Error computing portfolio summary.\n");
|
||||
return;
|
||||
// Build portfolio summary, candle map, and historical snapshots
|
||||
var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc) catch |err| switch (err) {
|
||||
error.NoAllocations, error.SummaryFailed => {
|
||||
try cli.stderrPrint("Error computing portfolio summary.\n");
|
||||
return;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
defer summary.deinit(allocator);
|
||||
defer pf_data.deinit(allocator);
|
||||
|
||||
// Sort allocations alphabetically by symbol
|
||||
std.mem.sort(zfin.valuation.Allocation, summary.allocations, {}, struct {
|
||||
std.mem.sort(zfin.valuation.Allocation, pf_data.summary.allocations, {}, struct {
|
||||
fn f(_: void, a: zfin.valuation.Allocation, b: zfin.valuation.Allocation) bool {
|
||||
return std.mem.lessThan(u8, a.display_symbol, b.display_symbol);
|
||||
}
|
||||
}.f);
|
||||
|
||||
// Build candle map once for historical snapshots and risk metrics.
|
||||
// This avoids parsing the full candle history multiple times.
|
||||
var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator);
|
||||
defer {
|
||||
var it = candle_map.valueIterator();
|
||||
while (it.next()) |v| allocator.free(v.*);
|
||||
candle_map.deinit();
|
||||
}
|
||||
{
|
||||
const stock_syms = try portfolio.stockSymbols(allocator);
|
||||
defer allocator.free(stock_syms);
|
||||
for (stock_syms) |sym| {
|
||||
if (svc.getCachedCandles(sym)) |cs| {
|
||||
try candle_map.put(sym, cs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect watch symbols and their prices for display.
|
||||
// Includes watch lots from portfolio + symbols from separate watchlist file.
|
||||
var watch_list: std.ArrayList([]const u8) = .empty;
|
||||
|
|
@ -146,7 +101,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
|
|||
var watch_seen = std.StringHashMap(void).init(allocator);
|
||||
defer watch_seen.deinit();
|
||||
// Exclude portfolio position symbols from watchlist
|
||||
for (summary.allocations) |a| {
|
||||
for (pf_data.summary.allocations) |a| {
|
||||
try watch_seen.put(a.symbol, {});
|
||||
}
|
||||
|
||||
|
|
@ -165,25 +120,15 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
|
|||
|
||||
// Separate watchlist file (backward compat)
|
||||
if (watchlist_path) |wl_path| {
|
||||
const wl_data = std.fs.cwd().readFileAlloc(allocator, wl_path, 1024 * 1024) catch null;
|
||||
if (wl_data) |wd| {
|
||||
defer allocator.free(wd);
|
||||
var wl_lines = std.mem.splitScalar(u8, wd, '\n');
|
||||
while (wl_lines.next()) |line| {
|
||||
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
||||
if (trimmed.len == 0 or trimmed[0] == '#') continue;
|
||||
if (std.mem.indexOf(u8, trimmed, "symbol::")) |idx| {
|
||||
const rest = trimmed[idx + "symbol::".len ..];
|
||||
const end = std.mem.indexOfScalar(u8, rest, ',') orelse rest.len;
|
||||
const sym = std.mem.trim(u8, rest[0..end], &std.ascii.whitespace);
|
||||
if (sym.len > 0 and sym.len <= 10) {
|
||||
if (watch_seen.contains(sym)) continue;
|
||||
try watch_seen.put(sym, {});
|
||||
try watch_list.append(allocator, sym);
|
||||
if (svc.getCachedLastClose(sym)) |close| {
|
||||
try watch_prices.put(sym, close);
|
||||
}
|
||||
}
|
||||
const wl_syms = cli.loadWatchlist(allocator, wl_path);
|
||||
defer cli.freeWatchlist(allocator, wl_syms);
|
||||
if (wl_syms) |syms_list| {
|
||||
for (syms_list) |sym| {
|
||||
if (watch_seen.contains(sym)) continue;
|
||||
try watch_seen.put(sym, {});
|
||||
try watch_list.append(allocator, sym);
|
||||
if (svc.getCachedLastClose(sym)) |close| {
|
||||
try watch_prices.put(sym, close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -197,9 +142,7 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
|
|||
file_path,
|
||||
&portfolio,
|
||||
positions,
|
||||
&summary,
|
||||
prices,
|
||||
candle_map,
|
||||
&pf_data,
|
||||
watch_list.items,
|
||||
watch_prices,
|
||||
);
|
||||
|
|
@ -213,12 +156,11 @@ pub fn display(
|
|||
file_path: []const u8,
|
||||
portfolio: *const zfin.Portfolio,
|
||||
positions: []const zfin.Position,
|
||||
summary: *const zfin.valuation.PortfolioSummary,
|
||||
prices: std.StringHashMap(f64),
|
||||
candle_map: std.StringHashMap([]const zfin.Candle),
|
||||
pf_data: *const cli.PortfolioData,
|
||||
watch_symbols: []const []const u8,
|
||||
watch_prices: std.StringHashMap(f64),
|
||||
) !void {
|
||||
const summary = &pf_data.summary;
|
||||
// Header with summary
|
||||
try cli.setBold(out, color);
|
||||
try out.print("\nPortfolio Summary ({s})\n", .{file_path});
|
||||
|
|
@ -233,11 +175,11 @@ pub fn display(
|
|||
const gl_abs = if (summary.unrealized_gain_loss >= 0) summary.unrealized_gain_loss else -summary.unrealized_gain_loss;
|
||||
try out.print(" Value: {s} Cost: {s} ", .{ fmt.fmtMoneyAbs(&val_buf, summary.total_value), fmt.fmtMoneyAbs(&cost_buf, summary.total_cost) });
|
||||
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
|
||||
if (summary.unrealized_gain_loss >= 0) {
|
||||
try out.print("Gain/Loss: +{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
|
||||
} else {
|
||||
try out.print("Gain/Loss: -{s} ({d:.1}%)", .{ fmt.fmtMoneyAbs(&gl_buf, gl_abs), summary.unrealized_return * 100.0 });
|
||||
}
|
||||
try out.print("Gain/Loss: {c}{s} ({d:.1}%)", .{
|
||||
@as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
|
||||
fmt.fmtMoneyAbs(&gl_buf, gl_abs),
|
||||
summary.unrealized_return * 100.0,
|
||||
});
|
||||
try cli.reset(out, color);
|
||||
try out.print("\n", .{});
|
||||
}
|
||||
|
|
@ -255,13 +197,7 @@ pub fn display(
|
|||
|
||||
// Historical portfolio value snapshots
|
||||
{
|
||||
if (candle_map.count() > 0) {
|
||||
const snapshots = zfin.valuation.computeHistoricalSnapshots(
|
||||
fmt.todayDate(),
|
||||
positions,
|
||||
prices,
|
||||
candle_map,
|
||||
);
|
||||
if (pf_data.snapshots) |snapshots| {
|
||||
try out.print(" Historical: ", .{});
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
for (zfin.valuation.HistoricalPeriod.all, 0..) |period, pi| {
|
||||
|
|
@ -378,31 +314,15 @@ pub fn display(
|
|||
const drip = fmt.aggregateDripLots(lots_for_sym.items);
|
||||
|
||||
if (!drip.st.isEmpty()) {
|
||||
var avg_buf: [24]u8 = undefined;
|
||||
var d1_buf: [10]u8 = undefined;
|
||||
var d2_buf: [10]u8 = undefined;
|
||||
var drip_buf: [128]u8 = undefined;
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.print(" ST: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
|
||||
drip.st.lot_count,
|
||||
drip.st.shares,
|
||||
fmt.fmtMoneyAbs(&avg_buf, drip.st.avgCost()),
|
||||
if (drip.st.first_date) |d| d.format(&d1_buf)[0..7] else "?",
|
||||
if (drip.st.last_date) |d| d.format(&d2_buf)[0..7] else "?",
|
||||
});
|
||||
try out.print(" {s}\n", .{fmt.fmtDripSummary(&drip_buf, "ST", drip.st)});
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
if (!drip.lt.isEmpty()) {
|
||||
var avg_buf2: [24]u8 = undefined;
|
||||
var d1_buf2: [10]u8 = undefined;
|
||||
var d2_buf2: [10]u8 = undefined;
|
||||
var drip_buf2: [128]u8 = undefined;
|
||||
try cli.setFg(out, color, cli.CLR_MUTED);
|
||||
try out.print(" LT: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})\n", .{
|
||||
drip.lt.lot_count,
|
||||
drip.lt.shares,
|
||||
fmt.fmtMoneyAbs(&avg_buf2, drip.lt.avgCost()),
|
||||
if (drip.lt.first_date) |d| d.format(&d1_buf2)[0..7] else "?",
|
||||
if (drip.lt.last_date) |d| d.format(&d2_buf2)[0..7] else "?",
|
||||
});
|
||||
try out.print(" {s}\n", .{fmt.fmtDripSummary(&drip_buf2, "LT", drip.lt)});
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
}
|
||||
|
|
@ -423,11 +343,10 @@ pub fn display(
|
|||
"", "", "", "TOTAL", fmt.fmtMoneyAbs(&total_mv_buf, summary.total_value),
|
||||
});
|
||||
try cli.setGainLoss(out, color, summary.unrealized_gain_loss);
|
||||
if (summary.unrealized_gain_loss >= 0) {
|
||||
try out.print("+{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
|
||||
} else {
|
||||
try out.print("-{s:>13}", .{fmt.fmtMoneyAbs(&total_gl_buf, gl_abs)});
|
||||
}
|
||||
try out.print("{c}{s:>13}", .{
|
||||
@as(u8, if (summary.unrealized_gain_loss >= 0) '+' else '-'),
|
||||
fmt.fmtMoneyAbs(&total_gl_buf, gl_abs),
|
||||
});
|
||||
try cli.reset(out, color);
|
||||
try out.print(" {s:>7}\n", .{"100.0%"});
|
||||
}
|
||||
|
|
@ -436,11 +355,10 @@ pub fn display(
|
|||
var rpl_buf: [24]u8 = undefined;
|
||||
const rpl_abs = if (summary.realized_gain_loss >= 0) summary.realized_gain_loss else -summary.realized_gain_loss;
|
||||
try cli.setGainLoss(out, color, summary.realized_gain_loss);
|
||||
if (summary.realized_gain_loss >= 0) {
|
||||
try out.print("\n Realized P&L: +{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
|
||||
} else {
|
||||
try out.print("\n Realized P&L: -{s}\n", .{fmt.fmtMoneyAbs(&rpl_buf, rpl_abs)});
|
||||
}
|
||||
try out.print("\n Realized P&L: {c}{s}\n", .{
|
||||
@as(u8, if (summary.realized_gain_loss >= 0) '+' else '-'),
|
||||
fmt.fmtMoneyAbs(&rpl_buf, rpl_abs),
|
||||
});
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
|
||||
|
|
@ -640,7 +558,7 @@ pub fn display(
|
|||
var any_risk = false;
|
||||
|
||||
for (summary.allocations) |a| {
|
||||
if (candle_map.get(a.symbol)) |candles| {
|
||||
if (pf_data.candle_map.get(a.symbol)) |candles| {
|
||||
const tr = zfin.risk.trailingRisk(candles);
|
||||
if (tr.three_year) |metrics| {
|
||||
if (!any_risk) {
|
||||
|
|
@ -737,6 +655,14 @@ fn testSummary(allocations: []zfin.valuation.Allocation) zfin.valuation.Portfoli
|
|||
};
|
||||
}
|
||||
|
||||
fn testPortfolioData(summary: zfin.valuation.PortfolioSummary, candle_map: std.StringHashMap([]const zfin.Candle)) cli.PortfolioData {
|
||||
return .{
|
||||
.summary = summary,
|
||||
.candle_map = candle_map,
|
||||
.snapshots = null,
|
||||
};
|
||||
}
|
||||
|
||||
test "display shows header and summary" {
|
||||
var buf: [8192]u8 = undefined;
|
||||
var w: std.Io.Writer = .fixed(&buf);
|
||||
|
|
@ -756,7 +682,7 @@ test "display shows header and summary" {
|
|||
.{ .symbol = "AAPL", .display_symbol = "AAPL", .shares = 10, .avg_cost = 150.0, .current_price = 175.0, .market_value = 1750.0, .cost_basis = 1500.0, .weight = 0.745, .unrealized_gain_loss = 250.0, .unrealized_return = 0.167 },
|
||||
.{ .symbol = "GOOG", .display_symbol = "GOOG", .shares = 5, .avg_cost = 120.0, .current_price = 140.0, .market_value = 700.0, .cost_basis = 600.0, .weight = 0.255, .unrealized_gain_loss = 100.0, .unrealized_return = 0.167 },
|
||||
};
|
||||
var summary = testSummary(&allocs);
|
||||
const summary = testSummary(&allocs);
|
||||
|
||||
var prices = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer prices.deinit();
|
||||
|
|
@ -765,13 +691,14 @@ test "display shows header and summary" {
|
|||
|
||||
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
||||
defer candle_map.deinit();
|
||||
const pf_data = testPortfolioData(summary, candle_map);
|
||||
|
||||
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer watch_prices.deinit();
|
||||
|
||||
const watch_syms: []const []const u8 = &.{};
|
||||
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
|
||||
const out = w.buffered();
|
||||
|
||||
// Header present
|
||||
|
|
@ -806,7 +733,7 @@ test "display with watchlist" {
|
|||
var allocs = [_]zfin.valuation.Allocation{
|
||||
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 20, .avg_cost = 200.0, .current_price = 220.0, .market_value = 4400.0, .cost_basis = 4000.0, .weight = 1.0, .unrealized_gain_loss = 400.0, .unrealized_return = 0.1 },
|
||||
};
|
||||
var summary = testSummary(&allocs);
|
||||
const summary = testSummary(&allocs);
|
||||
|
||||
var prices = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer prices.deinit();
|
||||
|
|
@ -814,6 +741,7 @@ test "display with watchlist" {
|
|||
|
||||
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
||||
defer candle_map.deinit();
|
||||
const pf_data = testPortfolioData(summary, candle_map);
|
||||
|
||||
// Watchlist with prices
|
||||
const watch_syms: []const []const u8 = &.{ "TSLA", "NVDA" };
|
||||
|
|
@ -822,7 +750,7 @@ test "display with watchlist" {
|
|||
try watch_prices.put("TSLA", 250.50);
|
||||
try watch_prices.put("NVDA", 800.25);
|
||||
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
|
||||
const out = w.buffered();
|
||||
|
||||
// Watchlist header and symbols
|
||||
|
|
@ -859,11 +787,12 @@ test "display with options section" {
|
|||
|
||||
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
||||
defer candle_map.deinit();
|
||||
const pf_data = testPortfolioData(summary, candle_map);
|
||||
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer watch_prices.deinit();
|
||||
const watch_syms: []const []const u8 = &.{};
|
||||
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
|
||||
const out = w.buffered();
|
||||
|
||||
// Options section present
|
||||
|
|
@ -900,11 +829,12 @@ test "display with CDs and cash" {
|
|||
|
||||
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
||||
defer candle_map.deinit();
|
||||
const pf_data = testPortfolioData(summary, candle_map);
|
||||
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer watch_prices.deinit();
|
||||
const watch_syms: []const []const u8 = &.{};
|
||||
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
|
||||
const out = w.buffered();
|
||||
|
||||
// CDs section present
|
||||
|
|
@ -943,11 +873,12 @@ test "display realized PnL shown when nonzero" {
|
|||
|
||||
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
||||
defer candle_map.deinit();
|
||||
const pf_data = testPortfolioData(summary, candle_map);
|
||||
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer watch_prices.deinit();
|
||||
const watch_syms: []const []const u8 = &.{};
|
||||
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
|
||||
const out = w.buffered();
|
||||
|
||||
try testing.expect(std.mem.indexOf(u8, out, "Realized P&L") != null);
|
||||
|
|
@ -969,7 +900,7 @@ test "display empty watchlist not shown" {
|
|||
var allocs = [_]zfin.valuation.Allocation{
|
||||
.{ .symbol = "VTI", .display_symbol = "VTI", .shares = 10, .avg_cost = 200.0, .current_price = 220.0, .market_value = 2200.0, .cost_basis = 2000.0, .weight = 1.0, .unrealized_gain_loss = 200.0, .unrealized_return = 0.1 },
|
||||
};
|
||||
var summary = testSummary(&allocs);
|
||||
const summary = testSummary(&allocs);
|
||||
|
||||
var prices = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer prices.deinit();
|
||||
|
|
@ -977,11 +908,12 @@ test "display empty watchlist not shown" {
|
|||
|
||||
var candle_map = std.StringHashMap([]const zfin.Candle).init(testing.allocator);
|
||||
defer candle_map.deinit();
|
||||
const pf_data = testPortfolioData(summary, candle_map);
|
||||
var watch_prices = std.StringHashMap(f64).init(testing.allocator);
|
||||
defer watch_prices.deinit();
|
||||
const watch_syms: []const []const u8 = &.{};
|
||||
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &summary, prices, candle_map, watch_syms, watch_prices);
|
||||
try display(testing.allocator, &w, false, "test.srf", &portfolio, &positions, &pf_data, watch_syms, watch_prices);
|
||||
const out = w.buffered();
|
||||
|
||||
// Watchlist header should NOT appear when there are no watch symbols
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const
|
|||
// Fetch candle data for chart and history
|
||||
const candle_result = svc.getCandles(symbol) catch |err| switch (err) {
|
||||
zfin.DataError.NoApiKey => {
|
||||
try cli.stderrPrint("Error: TWELVEDATA_API_KEY not set.\n");
|
||||
try cli.stderrPrint("Error: No API key configured for candle data.\n");
|
||||
return;
|
||||
},
|
||||
else => {
|
||||
|
|
@ -120,7 +120,7 @@ pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote
|
|||
for (candles[start_idx..]) |candle| {
|
||||
var row_buf: [128]u8 = undefined;
|
||||
const day_gain = candle.close >= candle.open;
|
||||
try cli.setGainLoss(out, color, if (day_gain) @as(f64, 1) else @as(f64, -1));
|
||||
try cli.setGainLoss(out, color, if (day_gain) 1.0 else -1.0);
|
||||
try out.print("{s}\n", .{fmt.fmtCandleRow(&row_buf, candle)});
|
||||
try cli.reset(out, color);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@ pub const Config = struct {
|
|||
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
|
||||
server_url: ?[]const u8 = null,
|
||||
cache_dir: []const u8,
|
||||
cache_dir_owned: bool = false, // true when cache_dir was allocated via path.join
|
||||
allocator: ?std.mem.Allocator = null,
|
||||
/// Raw .env file contents (keys/values in env_map point into this).
|
||||
env_buf: ?[]const u8 = null,
|
||||
/// Parsed KEY=VALUE pairs from .env file.
|
||||
env_map: ?EnvMap = null,
|
||||
/// Strings allocated by resolve() from process environment variables.
|
||||
env_owned: std.ArrayList([]const u8) = .empty,
|
||||
|
||||
pub fn fromEnv(allocator: std.mem.Allocator) Config {
|
||||
var self = Config{
|
||||
|
|
@ -40,12 +43,14 @@ pub const Config = struct {
|
|||
|
||||
const env_cache = self.resolve("ZFIN_CACHE_DIR");
|
||||
self.cache_dir = env_cache orelse blk: {
|
||||
self.cache_dir_owned = true;
|
||||
// XDG Base Directory: $XDG_CACHE_HOME/zfin, falling back to $HOME/.cache/zfin
|
||||
const base = std.posix.getenv("XDG_CACHE_HOME") orelse fallback: {
|
||||
const home = std.posix.getenv("HOME") orelse "/tmp";
|
||||
const xdg = self.resolve("XDG_CACHE_HOME");
|
||||
const base = xdg orelse fallback: {
|
||||
const home = self.resolve("HOME") orelse "/tmp";
|
||||
break :fallback std.fs.path.join(allocator, &.{ home, ".cache" }) catch @panic("OOM");
|
||||
};
|
||||
const base_allocated = std.posix.getenv("XDG_CACHE_HOME") == null;
|
||||
const base_allocated = xdg == null;
|
||||
defer if (base_allocated) allocator.free(base);
|
||||
break :blk std.fs.path.join(allocator, &.{ base, "zfin" }) catch @panic("OOM");
|
||||
};
|
||||
|
|
@ -55,15 +60,14 @@ pub const Config = struct {
|
|||
|
||||
pub fn deinit(self: *Config) void {
|
||||
if (self.allocator) |a| {
|
||||
// cache_dir is allocated (via path.join) unless ZFIN_CACHE_DIR was set directly.
|
||||
// Check BEFORE freeing env_map/env_buf, since resolve() reads from them.
|
||||
const cache_dir_from_env = self.resolve("ZFIN_CACHE_DIR") != null;
|
||||
if (self.env_map) |*m| {
|
||||
var map = m.*;
|
||||
map.deinit();
|
||||
}
|
||||
if (self.env_buf) |buf| a.free(buf);
|
||||
if (!cache_dir_from_env) {
|
||||
for (self.env_owned.items) |s| a.free(s);
|
||||
self.env_owned.deinit(a);
|
||||
if (self.cache_dir_owned) {
|
||||
a.free(self.cache_dir);
|
||||
}
|
||||
}
|
||||
|
|
@ -77,9 +81,14 @@ pub const Config = struct {
|
|||
self.tiingo_key != null;
|
||||
}
|
||||
|
||||
/// Look up a key: environment variable first, then .env file fallback.
|
||||
fn resolve(self: Config, key: []const u8) ?[]const u8 {
|
||||
if (std.posix.getenv(key)) |v| return v;
|
||||
/// Look up a key: process environment first, then .env file fallback.
|
||||
fn resolve(self: *Config, key: []const u8) ?[]const u8 {
|
||||
if (self.allocator) |a| {
|
||||
if (std.process.getEnvVarOwned(a, key)) |v| {
|
||||
self.env_owned.append(a, v) catch {};
|
||||
return v;
|
||||
} else |_| {}
|
||||
}
|
||||
if (self.env_map) |m| return m.get(key);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -427,6 +427,23 @@ pub fn aggregateDripLots(lots: []const Lot) DripAggregation {
|
|||
return result;
|
||||
}
|
||||
|
||||
/// Format a DRIP summary line: "ST: 12 DRIP lots, 3.5 shares, avg $45.67 (2023-01 to 2024-06)"
|
||||
pub fn fmtDripSummary(buf: []u8, label: []const u8, summary: DripSummary) []const u8 {
|
||||
var avg_buf: [24]u8 = undefined;
|
||||
var d1_buf: [10]u8 = undefined;
|
||||
var d2_buf: [10]u8 = undefined;
|
||||
const d1: []const u8 = if (summary.first_date) |d| d.format(&d1_buf)[0..7] else "?";
|
||||
const d2: []const u8 = if (summary.last_date) |d| d.format(&d2_buf)[0..7] else "?";
|
||||
return std.fmt.bufPrint(buf, "{s}: {d} DRIP lots, {d:.1} shares, avg {s} ({s} to {s})", .{
|
||||
label,
|
||||
summary.lot_count,
|
||||
summary.shares,
|
||||
fmtMoneyAbs(&avg_buf, summary.avgCost()),
|
||||
d1,
|
||||
d2,
|
||||
}) catch "?";
|
||||
}
|
||||
|
||||
// ── Shared rendering helpers (CLI + TUI) ─────────────────────
|
||||
|
||||
/// Layout constants for analysis breakdown views.
|
||||
|
|
@ -863,11 +880,11 @@ pub fn writeBrailleAnsi(
|
|||
// ── ANSI color helpers (for CLI) ─────────────────────────────
|
||||
|
||||
/// Determine whether to use ANSI color output.
|
||||
/// Uses std.Io.tty.Config.detect which handles TTY detection, NO_COLOR,
|
||||
/// CLICOLOR_FORCE, and Windows console API cross-platform.
|
||||
pub fn shouldUseColor(no_color_flag: bool) bool {
|
||||
if (no_color_flag) return false;
|
||||
if (std.posix.getenv("NO_COLOR")) |_| return false;
|
||||
// Check if stdout is a TTY
|
||||
return std.posix.isatty(std.fs.File.stdout().handle);
|
||||
return std.Io.tty.Config.detect(std.fs.File.stdout()) != .no_color;
|
||||
}
|
||||
|
||||
/// Write an ANSI 24-bit foreground color escape.
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ pub fn main() !u8 {
|
|||
file_path = args[pi];
|
||||
}
|
||||
}
|
||||
try commands.portfolio.run(allocator, config, &svc, file_path, watchlist_path, force_refresh, color, out);
|
||||
try commands.portfolio.run(allocator, &svc, file_path, watchlist_path, force_refresh, color, out);
|
||||
} else if (std.mem.eql(u8, command, "lookup")) {
|
||||
if (args.len < 3) {
|
||||
try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
|
||||
|
|
@ -211,7 +211,7 @@ pub fn main() !u8 {
|
|||
break;
|
||||
}
|
||||
}
|
||||
try commands.analysis.run(allocator, config, &svc, analysis_file, color, out);
|
||||
try commands.analysis.run(allocator, &svc, analysis_file, color, out);
|
||||
} else {
|
||||
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ pub const Client = struct {
|
|||
return self.request(.POST, url, body, extra_headers);
|
||||
}
|
||||
|
||||
fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
|
||||
pub fn request(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response {
|
||||
var attempt: u8 = 0;
|
||||
while (true) : (attempt += 1) {
|
||||
const response = self.doRequest(method, url, body, extra_headers) catch {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ pub const Cboe = struct {
|
|||
const url = try buildCboeUrl(allocator, symbol);
|
||||
defer allocator.free(url);
|
||||
|
||||
var response = try self.client.get(url);
|
||||
// Request with Accept-Encoding: identity to avoid Zig 0.15 stdlib deflate panic
|
||||
// on malformed compressed responses from CBOE's CDN.
|
||||
var response = try self.client.request(.GET, url, null, &.{.{ .name = "Accept-Encoding", .value = "identity" }});
|
||||
defer response.deinit();
|
||||
|
||||
return parseResponse(allocator, response.body, symbol);
|
||||
|
|
|
|||
350
src/service.zig
350
src/service.zig
|
|
@ -748,6 +748,356 @@ pub const DataService = struct {
|
|||
return result;
|
||||
}
|
||||
|
||||
// ── Consolidated Price Loading (Parallel Server + Sequential Provider) ──
|
||||
|
||||
/// Configuration for loadAllPrices.
|
||||
pub const LoadAllConfig = struct {
|
||||
force_refresh: bool = false,
|
||||
color: bool = true,
|
||||
/// Maximum concurrent server sync requests. 0 = auto (8).
|
||||
max_concurrent: usize = 0,
|
||||
};
|
||||
|
||||
/// Result of loadAllPrices operation.
|
||||
pub const LoadAllResult = struct {
|
||||
prices: std.StringHashMap(f64),
|
||||
/// Number of symbols resolved from fresh local cache.
|
||||
cached_count: usize,
|
||||
/// Number of symbols synced from server.
|
||||
server_synced_count: usize,
|
||||
/// Number of symbols fetched from providers (rate-limited APIs).
|
||||
provider_fetched_count: usize,
|
||||
/// Number of symbols that failed all sources but used stale cache.
|
||||
stale_count: usize,
|
||||
/// Number of symbols that failed completely (no data).
|
||||
failed_count: usize,
|
||||
/// Latest candle date seen.
|
||||
latest_date: ?Date,
|
||||
|
||||
/// Free the prices hashmap. Call this if you don't transfer ownership.
|
||||
pub fn deinit(self: *LoadAllResult) void {
|
||||
self.prices.deinit();
|
||||
}
|
||||
};
|
||||
|
||||
/// Progress callback for aggregate (parallel) progress reporting.
|
||||
/// Called periodically during parallel operations with current counts.
|
||||
pub const AggregateProgressCallback = struct {
|
||||
context: *anyopaque,
|
||||
on_progress: *const fn (ctx: *anyopaque, completed: usize, total: usize, phase: Phase) void,
|
||||
|
||||
pub const Phase = enum {
|
||||
/// Checking local cache
|
||||
cache_check,
|
||||
/// Syncing from ZFIN_SERVER
|
||||
server_sync,
|
||||
/// Fetching from rate-limited providers
|
||||
provider_fetch,
|
||||
/// Done
|
||||
complete,
|
||||
};
|
||||
|
||||
fn emit(self: AggregateProgressCallback, completed: usize, total: usize, phase: Phase) void {
|
||||
self.on_progress(self.context, completed, total, phase);
|
||||
}
|
||||
};
|
||||
|
||||
/// Thread-safe counter for parallel progress tracking.
|
||||
const AtomicCounter = struct {
|
||||
value: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
|
||||
|
||||
fn increment(self: *AtomicCounter) usize {
|
||||
return self.value.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
fn load(self: *const AtomicCounter) usize {
|
||||
return self.value.load(.monotonic);
|
||||
}
|
||||
};
|
||||
|
||||
/// Per-symbol result from parallel server sync.
|
||||
const ServerSyncResult = struct {
|
||||
symbol: []const u8,
|
||||
success: bool,
|
||||
};
|
||||
|
||||
/// Load prices for portfolio and watchlist symbols with automatic parallelization.
|
||||
///
|
||||
/// When ZFIN_SERVER is configured:
|
||||
/// 1. Check local cache (fast, parallel-safe)
|
||||
/// 2. Parallel sync from server for cache misses
|
||||
/// 3. Sequential provider fallback for server failures
|
||||
///
|
||||
/// When ZFIN_SERVER is not configured:
|
||||
/// Falls back to sequential loading with per-symbol progress.
|
||||
///
|
||||
/// Progress is reported via `aggregate_progress` for parallel phases
|
||||
/// and `symbol_progress` for sequential provider fallback.
|
||||
pub fn loadAllPrices(
|
||||
self: *DataService,
|
||||
portfolio_syms: ?[]const []const u8,
|
||||
watch_syms: []const []const u8,
|
||||
config: LoadAllConfig,
|
||||
aggregate_progress: ?AggregateProgressCallback,
|
||||
symbol_progress: ?ProgressCallback,
|
||||
) LoadAllResult {
|
||||
var result = LoadAllResult{
|
||||
.prices = std.StringHashMap(f64).init(self.allocator),
|
||||
.cached_count = 0,
|
||||
.server_synced_count = 0,
|
||||
.provider_fetched_count = 0,
|
||||
.stale_count = 0,
|
||||
.failed_count = 0,
|
||||
.latest_date = null,
|
||||
};
|
||||
|
||||
// Combine all symbols
|
||||
const portfolio_count = if (portfolio_syms) |ps| ps.len else 0;
|
||||
const watch_count = watch_syms.len;
|
||||
const total_count = portfolio_count + watch_count;
|
||||
|
||||
if (total_count == 0) return result;
|
||||
|
||||
// Build combined symbol list
|
||||
var all_symbols = std.ArrayList([]const u8).initCapacity(self.allocator, total_count) catch return result;
|
||||
defer all_symbols.deinit(self.allocator);
|
||||
|
||||
if (portfolio_syms) |ps| {
|
||||
for (ps) |sym| all_symbols.append(self.allocator, sym) catch {};
|
||||
}
|
||||
for (watch_syms) |sym| all_symbols.append(self.allocator, sym) catch {};
|
||||
|
||||
// Invalidate cache if force refresh
|
||||
if (config.force_refresh) {
|
||||
for (all_symbols.items) |sym| {
|
||||
self.invalidate(sym, .candles_daily);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Check local cache (fast path)
|
||||
var needs_fetch: std.ArrayList([]const u8) = .empty;
|
||||
defer needs_fetch.deinit(self.allocator);
|
||||
|
||||
if (aggregate_progress) |p| p.emit(0, total_count, .cache_check);
|
||||
|
||||
for (all_symbols.items) |sym| {
|
||||
if (!config.force_refresh and self.isCandleCacheFresh(sym)) {
|
||||
if (self.getCachedLastClose(sym)) |close| {
|
||||
result.prices.put(sym, close) catch {};
|
||||
self.updateLatestDate(&result, sym);
|
||||
}
|
||||
result.cached_count += 1;
|
||||
} else {
|
||||
needs_fetch.append(self.allocator, sym) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
if (aggregate_progress) |p| p.emit(result.cached_count, total_count, .cache_check);
|
||||
|
||||
if (needs_fetch.items.len == 0) {
|
||||
if (aggregate_progress) |p| p.emit(total_count, total_count, .complete);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Phase 2: Server sync (parallel if server configured)
|
||||
var server_failures: std.ArrayList([]const u8) = .empty;
|
||||
defer server_failures.deinit(self.allocator);
|
||||
|
||||
if (self.config.server_url != null) {
|
||||
self.parallelServerSync(
|
||||
needs_fetch.items,
|
||||
&result,
|
||||
&server_failures,
|
||||
config,
|
||||
aggregate_progress,
|
||||
total_count,
|
||||
);
|
||||
} else {
|
||||
// No server — all need provider fetch
|
||||
for (needs_fetch.items) |sym| {
|
||||
server_failures.append(self.allocator, sym) catch {};
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Sequential provider fallback for server failures
|
||||
if (server_failures.items.len > 0) {
|
||||
if (aggregate_progress) |p| p.emit(
|
||||
result.cached_count + result.server_synced_count,
|
||||
total_count,
|
||||
.provider_fetch,
|
||||
);
|
||||
|
||||
self.sequentialProviderFetch(
|
||||
server_failures.items,
|
||||
&result,
|
||||
symbol_progress,
|
||||
total_count - server_failures.items.len, // offset for progress display
|
||||
);
|
||||
}
|
||||
|
||||
if (aggregate_progress) |p| p.emit(total_count, total_count, .complete);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Parallel server sync using thread pool.
|
||||
fn parallelServerSync(
|
||||
self: *DataService,
|
||||
symbols: []const []const u8,
|
||||
result: *LoadAllResult,
|
||||
failures: *std.ArrayList([]const u8),
|
||||
config: LoadAllConfig,
|
||||
aggregate_progress: ?AggregateProgressCallback,
|
||||
total_count: usize,
|
||||
) void {
|
||||
const max_threads = if (config.max_concurrent > 0) config.max_concurrent else 8;
|
||||
const thread_count = @min(symbols.len, max_threads);
|
||||
|
||||
if (aggregate_progress) |p| p.emit(result.cached_count, total_count, .server_sync);
|
||||
|
||||
// Shared state for worker threads
|
||||
var completed = AtomicCounter{};
|
||||
var next_index = AtomicCounter{};
|
||||
const sync_results = self.allocator.alloc(ServerSyncResult, symbols.len) catch {
|
||||
// Allocation failed — fall back to marking all as failures
|
||||
for (symbols) |sym| failures.append(self.allocator, sym) catch {};
|
||||
return;
|
||||
};
|
||||
defer self.allocator.free(sync_results);
|
||||
|
||||
// Initialize results
|
||||
for (sync_results, 0..) |*sr, i| {
|
||||
sr.* = .{ .symbol = symbols[i], .success = false };
|
||||
}
|
||||
|
||||
// Spawn worker threads
|
||||
var threads = self.allocator.alloc(std.Thread, thread_count) catch {
|
||||
for (symbols) |sym| failures.append(self.allocator, sym) catch {};
|
||||
return;
|
||||
};
|
||||
defer self.allocator.free(threads);
|
||||
|
||||
const WorkerContext = struct {
|
||||
svc: *DataService,
|
||||
symbols: []const []const u8,
|
||||
results: []ServerSyncResult,
|
||||
next_index: *AtomicCounter,
|
||||
completed: *AtomicCounter,
|
||||
};
|
||||
|
||||
var ctx = WorkerContext{
|
||||
.svc = self,
|
||||
.symbols = symbols,
|
||||
.results = sync_results,
|
||||
.next_index = &next_index,
|
||||
.completed = &completed,
|
||||
};
|
||||
|
||||
const worker = struct {
|
||||
fn run(wctx: *WorkerContext) void {
|
||||
while (true) {
|
||||
const idx = wctx.next_index.increment();
|
||||
if (idx >= wctx.symbols.len) break;
|
||||
|
||||
const sym = wctx.symbols[idx];
|
||||
const success = wctx.svc.syncCandlesFromServer(sym);
|
||||
wctx.results[idx].success = success;
|
||||
_ = wctx.completed.increment();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start threads
|
||||
var spawned: usize = 0;
|
||||
for (threads) |*t| {
|
||||
t.* = std.Thread.spawn(.{}, worker.run, .{&ctx}) catch continue;
|
||||
spawned += 1;
|
||||
}
|
||||
|
||||
// Progress reporting while waiting
|
||||
if (aggregate_progress) |p| {
|
||||
while (completed.load() < symbols.len) {
|
||||
std.Thread.sleep(50 * std.time.ns_per_ms);
|
||||
p.emit(result.cached_count + completed.load(), total_count, .server_sync);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all threads
|
||||
for (threads[0..spawned]) |t| {
|
||||
t.join();
|
||||
}
|
||||
|
||||
// Process results
|
||||
for (sync_results) |sr| {
|
||||
if (sr.success) {
|
||||
// Server sync succeeded — read from cache
|
||||
if (self.getCachedLastClose(sr.symbol)) |close| {
|
||||
result.prices.put(sr.symbol, close) catch {};
|
||||
self.updateLatestDate(result, sr.symbol);
|
||||
result.server_synced_count += 1;
|
||||
} else {
|
||||
// Sync said success but can't read cache — treat as failure
|
||||
failures.append(self.allocator, sr.symbol) catch {};
|
||||
}
|
||||
} else {
|
||||
failures.append(self.allocator, sr.symbol) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequential provider fetch for symbols that failed server sync.
|
||||
fn sequentialProviderFetch(
|
||||
self: *DataService,
|
||||
symbols: []const []const u8,
|
||||
result: *LoadAllResult,
|
||||
progress: ?ProgressCallback,
|
||||
index_offset: usize,
|
||||
) void {
|
||||
const total = index_offset + symbols.len;
|
||||
|
||||
for (symbols, 0..) |sym, i| {
|
||||
const display_idx = index_offset + i;
|
||||
|
||||
// Notify: about to fetch
|
||||
if (progress) |p| p.emit(display_idx, total, sym, .fetching);
|
||||
|
||||
// Try provider fetch
|
||||
if (self.getCandles(sym)) |candle_result| {
|
||||
defer self.allocator.free(candle_result.data);
|
||||
if (candle_result.data.len > 0) {
|
||||
const last = candle_result.data[candle_result.data.len - 1];
|
||||
result.prices.put(sym, last.close) catch {};
|
||||
if (result.latest_date == null or last.date.days > result.latest_date.?.days) {
|
||||
result.latest_date = last.date;
|
||||
}
|
||||
}
|
||||
result.provider_fetched_count += 1;
|
||||
if (progress) |p| p.emit(display_idx, total, sym, .fetched);
|
||||
continue;
|
||||
} else |_| {}
|
||||
|
||||
// Provider failed — try stale cache
|
||||
result.failed_count += 1;
|
||||
if (self.getCachedLastClose(sym)) |close| {
|
||||
result.prices.put(sym, close) catch {};
|
||||
result.stale_count += 1;
|
||||
if (progress) |p| p.emit(display_idx, total, sym, .failed_used_stale);
|
||||
} else {
|
||||
if (progress) |p| p.emit(display_idx, total, sym, .failed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update latest_date in result from cached candle metadata.
|
||||
fn updateLatestDate(self: *DataService, result: *LoadAllResult, symbol: []const u8) void {
|
||||
var s = self.store();
|
||||
if (s.readCandleMeta(symbol)) |cm| {
|
||||
const d = cm.meta.last_date;
|
||||
if (result.latest_date == null or d.days > result.latest_date.?.days) {
|
||||
result.latest_date = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── CUSIP Resolution ──────────────────────────────────────────
|
||||
|
||||
/// Look up multiple CUSIPs in a single batch request via OpenFIGI.
|
||||
|
|
|
|||
2994
src/tui.zig
2994
src/tui.zig
File diff suppressed because it is too large
Load diff
258
src/tui/analysis_tab.zig
Normal file
258
src/tui/analysis_tab.zig
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("../root.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const theme_mod = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const App = tui.App;
|
||||
const StyledLine = tui.StyledLine;
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
||||
pub fn loadData(app: *App) void {
|
||||
app.analysis_loaded = true;
|
||||
|
||||
// Ensure portfolio is loaded first
|
||||
if (!app.portfolio_loaded) app.loadPortfolioData();
|
||||
const pf = app.portfolio orelse return;
|
||||
const summary = app.portfolio_summary orelse return;
|
||||
|
||||
// Load classification metadata file
|
||||
if (app.classification_map == null) {
|
||||
// Look for metadata.srf next to the portfolio file
|
||||
if (app.portfolio_path) |ppath| {
|
||||
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
|
||||
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
||||
defer app.allocator.free(meta_path);
|
||||
|
||||
const file_data = std.fs.cwd().readFileAlloc(app.allocator, meta_path, 1024 * 1024) catch {
|
||||
app.setStatus("No metadata.srf found. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||
return;
|
||||
};
|
||||
defer app.allocator.free(file_data);
|
||||
|
||||
app.classification_map = zfin.classification.parseClassificationFile(app.allocator, file_data) catch {
|
||||
app.setStatus("Error parsing metadata.srf");
|
||||
return;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Load account tax type metadata file (optional)
|
||||
if (app.account_map == null) {
|
||||
if (app.portfolio_path) |ppath| {
|
||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, '/')) |idx| idx + 1 else 0;
|
||||
const acct_path = std.fmt.allocPrint(app.allocator, "{s}accounts.srf", .{ppath[0..dir_end]}) catch {
|
||||
loadDataFinish(app, pf, summary);
|
||||
return;
|
||||
};
|
||||
defer app.allocator.free(acct_path);
|
||||
|
||||
if (std.fs.cwd().readFileAlloc(app.allocator, acct_path, 1024 * 1024)) |acct_data| {
|
||||
defer app.allocator.free(acct_data);
|
||||
app.account_map = zfin.analysis.parseAccountsFile(app.allocator, acct_data) catch null;
|
||||
} else |_| {
|
||||
// accounts.srf is optional -- analysis works without it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadDataFinish(app, pf, summary);
|
||||
}
|
||||
|
||||
fn loadDataFinish(app: *App, pf: zfin.Portfolio, summary: zfin.valuation.PortfolioSummary) void {
|
||||
const cm = app.classification_map orelse {
|
||||
app.setStatus("No classification data. Run: zfin enrich <portfolio.srf> > metadata.srf");
|
||||
return;
|
||||
};
|
||||
|
||||
// Free previous result
|
||||
if (app.analysis_result) |*ar| ar.deinit(app.allocator);
|
||||
|
||||
app.analysis_result = zfin.analysis.analyzePortfolio(
|
||||
app.allocator,
|
||||
summary.allocations,
|
||||
cm,
|
||||
pf,
|
||||
summary.total_value,
|
||||
app.account_map,
|
||||
) catch {
|
||||
app.setStatus("Error computing analysis");
|
||||
return;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────
|
||||
|
||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
return renderAnalysisLines(arena, app.theme, app.analysis_result);
|
||||
}
|
||||
|
||||
/// Render analysis tab content. Pure function — no App dependency.
|
||||
pub fn renderAnalysisLines(
|
||||
arena: std.mem.Allocator,
|
||||
th: theme_mod.Theme,
|
||||
analysis_result: ?zfin.analysis.AnalysisResult,
|
||||
) ![]const StyledLine {
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = " Portfolio Analysis", .style = th.headerStyle() });
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
const result = analysis_result orelse {
|
||||
try lines.append(arena, .{ .text = " No analysis data. Ensure metadata.srf exists alongside portfolio.", .style = th.mutedStyle() });
|
||||
try lines.append(arena, .{ .text = " Run: zfin enrich <portfolio.srf> > metadata.srf", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
};
|
||||
|
||||
const bar_width: usize = 30;
|
||||
const label_width: usize = 24;
|
||||
|
||||
const sections = [_]struct { items: []const zfin.analysis.BreakdownItem, title: []const u8 }{
|
||||
.{ .items = result.asset_class, .title = " Asset Class" },
|
||||
.{ .items = result.sector, .title = " Sector (Equities)" },
|
||||
.{ .items = result.geo, .title = " Geographic" },
|
||||
.{ .items = result.account, .title = " By Account" },
|
||||
.{ .items = result.tax_type, .title = " By Tax Type" },
|
||||
};
|
||||
|
||||
for (sections, 0..) |sec, si| {
|
||||
if (si > 0 and sec.items.len == 0) continue;
|
||||
if (si > 0) try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = sec.title, .style = th.headerStyle() });
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
for (sec.items) |item| {
|
||||
const text = try fmtBreakdownLine(arena, item, bar_width, label_width);
|
||||
try lines.append(arena, .{ .text = text, .style = th.contentStyle(), .alt_text = null, .alt_style = th.barFillStyle(), .alt_start = 2 + label_width + 1, .alt_end = 2 + label_width + 1 + bar_width });
|
||||
}
|
||||
}
|
||||
|
||||
if (result.unclassified.len > 0) {
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = " Unclassified (not in metadata.srf)", .style = th.warningStyle() });
|
||||
for (result.unclassified) |sym| {
|
||||
const text = try std.fmt.allocPrint(arena, " {s}", .{sym});
|
||||
try lines.append(arena, .{ .text = text, .style = th.mutedStyle() });
|
||||
}
|
||||
}
|
||||
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
pub fn fmtBreakdownLine(arena: std.mem.Allocator, item: zfin.analysis.BreakdownItem, bar_width: usize, label_width: usize) ![]const u8 {
|
||||
var val_buf: [24]u8 = undefined;
|
||||
const pct = item.weight * 100.0;
|
||||
const bar = try buildBlockBar(arena, item.weight, bar_width);
|
||||
// Build label padded to label_width
|
||||
const lbl = item.label;
|
||||
const lbl_len = @min(lbl.len, label_width);
|
||||
const padded_label = try arena.alloc(u8, label_width);
|
||||
@memcpy(padded_label[0..lbl_len], lbl[0..lbl_len]);
|
||||
if (lbl_len < label_width) @memset(padded_label[lbl_len..], ' ');
|
||||
return std.fmt.allocPrint(arena, " {s} {s} {d:>5.1}% {s}", .{
|
||||
padded_label, bar, pct, fmt.fmtMoneyAbs(&val_buf, item.value),
|
||||
});
|
||||
}
|
||||
|
||||
/// Build a bar using Unicode block elements for sub-character precision.
|
||||
/// Wraps fmt.buildBlockBar into arena-allocated memory.
|
||||
pub fn buildBlockBar(arena: std.mem.Allocator, weight: f64, total_chars: usize) ![]const u8 {
|
||||
var buf: [256]u8 = undefined;
|
||||
const result = fmt.buildBlockBar(&buf, weight, total_chars);
|
||||
return arena.dupe(u8, result);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "buildBlockBar empty" {
|
||||
const bar = try buildBlockBar(testing.allocator, 0, 10);
|
||||
defer testing.allocator.free(bar);
|
||||
// All spaces
|
||||
try testing.expectEqual(@as(usize, 10), bar.len);
|
||||
try testing.expectEqualStrings(" ", bar);
|
||||
}
|
||||
|
||||
test "buildBlockBar full" {
|
||||
const bar = try buildBlockBar(testing.allocator, 1.0, 5);
|
||||
defer testing.allocator.free(bar);
|
||||
// 5 full blocks, each 3 bytes UTF-8 (█ = E2 96 88)
|
||||
try testing.expectEqual(@as(usize, 15), bar.len);
|
||||
// Verify first block is █
|
||||
try testing.expectEqualStrings("\xe2\x96\x88", bar[0..3]);
|
||||
}
|
||||
|
||||
test "buildBlockBar partial" {
|
||||
const bar = try buildBlockBar(testing.allocator, 0.5, 10);
|
||||
defer testing.allocator.free(bar);
|
||||
// 50% of 10 chars = 5 full blocks (no partial)
|
||||
// 5 full blocks (15 bytes) + 5 spaces = 20 bytes
|
||||
try testing.expectEqual(@as(usize, 20), bar.len);
|
||||
}
|
||||
|
||||
test "fmtBreakdownLine formats correctly" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const item = zfin.analysis.BreakdownItem{
|
||||
.label = "US Stock",
|
||||
.weight = 0.65,
|
||||
.value = 130000,
|
||||
};
|
||||
const line = try fmtBreakdownLine(arena, item, 10, 12);
|
||||
// Should contain the label, percentage, and dollar amount
|
||||
try testing.expect(std.mem.indexOf(u8, line, "US Stock") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, line, "65.0%") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, line, "$130,000") != null);
|
||||
}
|
||||
|
||||
test "renderAnalysisLines with data" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const th = theme_mod.default_theme;
|
||||
|
||||
var asset_class = [_]zfin.analysis.BreakdownItem{
|
||||
.{ .label = "US Stock", .weight = 0.60, .value = 120000 },
|
||||
.{ .label = "Int'l Stock", .weight = 0.40, .value = 80000 },
|
||||
};
|
||||
const result = zfin.analysis.AnalysisResult{
|
||||
.asset_class = &asset_class,
|
||||
.sector = &.{},
|
||||
.geo = &.{},
|
||||
.account = &.{},
|
||||
.tax_type = &.{},
|
||||
.unclassified = &.{},
|
||||
.total_value = 200000,
|
||||
};
|
||||
const lines = try renderAnalysisLines(arena, th, result);
|
||||
// Should have header section + asset class items
|
||||
try testing.expect(lines.len >= 5);
|
||||
// Find "Portfolio Analysis" header
|
||||
var found_header = false;
|
||||
for (lines) |l| {
|
||||
if (std.mem.indexOf(u8, l.text, "Portfolio Analysis") != null) found_header = true;
|
||||
}
|
||||
try testing.expect(found_header);
|
||||
// Find asset class data
|
||||
var found_us = false;
|
||||
for (lines) |l| {
|
||||
if (std.mem.indexOf(u8, l.text, "US Stock") != null) found_us = true;
|
||||
}
|
||||
try testing.expect(found_us);
|
||||
}
|
||||
|
||||
test "renderAnalysisLines no data" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const th = theme_mod.default_theme;
|
||||
|
||||
const lines = try renderAnalysisLines(arena, th, null);
|
||||
try testing.expectEqual(@as(usize, 5), lines.len);
|
||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No analysis data") != null);
|
||||
}
|
||||
|
|
@ -122,16 +122,31 @@ pub const ChartResult = struct {
|
|||
rsi_latest: ?f64,
|
||||
};
|
||||
|
||||
/// Render a complete financial chart to raw RGB pixel data.
|
||||
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
|
||||
pub fn renderChart(
|
||||
/// Cached indicator data to avoid recomputation across frames.
|
||||
/// All slices are owned and must be freed with deinit().
|
||||
pub const CachedIndicators = struct {
|
||||
closes: []f64,
|
||||
volumes: []f64,
|
||||
bb: []?zfin.indicators.BollingerBand,
|
||||
rsi_vals: []?f64,
|
||||
|
||||
/// Free all allocated memory.
|
||||
pub fn deinit(self: *CachedIndicators, alloc: std.mem.Allocator) void {
|
||||
alloc.free(self.closes);
|
||||
alloc.free(self.volumes);
|
||||
alloc.free(self.bb);
|
||||
alloc.free(self.rsi_vals);
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/// Compute indicators for the given candle data.
|
||||
/// Returns owned CachedIndicators that must be freed with deinit().
|
||||
pub fn computeIndicators(
|
||||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
timeframe: Timeframe,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme_mod.Theme,
|
||||
) !ChartResult {
|
||||
) !CachedIndicators {
|
||||
if (candles.len < 20) return error.InsufficientData;
|
||||
|
||||
// Slice candles to timeframe
|
||||
|
|
@ -141,15 +156,72 @@ pub fn renderChart(
|
|||
|
||||
// Extract data series
|
||||
const closes = try zfin.indicators.closePrices(alloc, data);
|
||||
defer alloc.free(closes);
|
||||
errdefer alloc.free(closes);
|
||||
|
||||
const vols = try zfin.indicators.volumes(alloc, data);
|
||||
defer alloc.free(vols);
|
||||
errdefer alloc.free(vols);
|
||||
|
||||
// Compute indicators
|
||||
const bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0);
|
||||
defer alloc.free(bb);
|
||||
errdefer alloc.free(bb);
|
||||
|
||||
const rsi_vals = try zfin.indicators.rsi(alloc, closes, 14);
|
||||
defer alloc.free(rsi_vals);
|
||||
|
||||
return .{
|
||||
.closes = closes,
|
||||
.volumes = vols,
|
||||
.bb = bb,
|
||||
.rsi_vals = rsi_vals,
|
||||
};
|
||||
}
|
||||
|
||||
/// Render a complete financial chart to raw RGB pixel data.
|
||||
/// The returned rgb_data is allocated with `alloc` and must be freed by caller.
|
||||
/// If `cached` is provided, uses pre-computed indicators instead of recomputing.
|
||||
pub fn renderChart(
|
||||
alloc: std.mem.Allocator,
|
||||
candles: []const zfin.Candle,
|
||||
timeframe: Timeframe,
|
||||
width_px: u32,
|
||||
height_px: u32,
|
||||
th: theme_mod.Theme,
|
||||
cached: ?*const CachedIndicators,
|
||||
) !ChartResult {
|
||||
if (candles.len < 20) return error.InsufficientData;
|
||||
|
||||
// Slice candles to timeframe
|
||||
const max_days = timeframe.tradingDays();
|
||||
const n = @min(candles.len, max_days);
|
||||
const data = candles[candles.len - n ..];
|
||||
|
||||
// Use cached indicators or compute fresh ones
|
||||
var local_closes: ?[]f64 = null;
|
||||
var local_vols: ?[]f64 = null;
|
||||
var local_bb: ?[]?zfin.indicators.BollingerBand = null;
|
||||
var local_rsi: ?[]?f64 = null;
|
||||
defer {
|
||||
if (local_closes) |c| alloc.free(c);
|
||||
if (local_vols) |v| alloc.free(v);
|
||||
if (local_bb) |b| alloc.free(b);
|
||||
if (local_rsi) |r| alloc.free(r);
|
||||
}
|
||||
|
||||
const closes: []const f64 = if (cached) |c| c.closes else blk: {
|
||||
local_closes = try zfin.indicators.closePrices(alloc, data);
|
||||
break :blk local_closes.?;
|
||||
};
|
||||
const vols: []const f64 = if (cached) |c| c.volumes else blk: {
|
||||
local_vols = try zfin.indicators.volumes(alloc, data);
|
||||
break :blk local_vols.?;
|
||||
};
|
||||
const bb: []const ?zfin.indicators.BollingerBand = if (cached) |c| c.bb else blk: {
|
||||
local_bb = try zfin.indicators.bollingerBands(alloc, closes, 20, 2.0);
|
||||
break :blk local_bb.?;
|
||||
};
|
||||
const rsi_vals: []const ?f64 = if (cached) |c| c.rsi_vals else blk: {
|
||||
local_rsi = try zfin.indicators.rsi(alloc, closes, 14);
|
||||
break :blk local_rsi.?;
|
||||
};
|
||||
|
||||
// Create z2d surface — use RGB (not RGBA) since we're rendering onto a solid
|
||||
// background. This avoids integer overflow in z2d's RGBA compositor when
|
||||
|
|
@ -230,8 +302,8 @@ pub fn renderChart(
|
|||
|
||||
// ── Grid lines ────────────────────────────────────────────────────
|
||||
const grid_color = blendColor(th.text_muted, 60, bg);
|
||||
try drawHorizontalGridLines(&ctx, alloc, chart_left, chart_right, price_top, price_bottom, price_min, price_max, 5, grid_color);
|
||||
try drawHorizontalGridLines(&ctx, alloc, chart_left, chart_right, rsi_top, rsi_bottom, 0, 100, 4, grid_color);
|
||||
try drawHorizontalGridLines(&ctx, chart_left, chart_right, price_top, price_bottom, 5, grid_color);
|
||||
try drawHorizontalGridLines(&ctx, chart_left, chart_right, rsi_top, rsi_bottom, 4, grid_color);
|
||||
|
||||
// ── Volume bars (overlaid on price panel bottom 25%) ─────────────
|
||||
{
|
||||
|
|
@ -313,10 +385,10 @@ pub fn renderChart(
|
|||
// ── Bollinger Band boundary lines + SMA (on top of fills) ──────────
|
||||
{
|
||||
const band_line_color = blendColor(th.text_muted, 100, bg);
|
||||
try drawLineSeries(&ctx, alloc, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .upper);
|
||||
try drawLineSeries(&ctx, alloc, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .lower);
|
||||
try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .upper);
|
||||
try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, band_line_color, 1.0, .lower);
|
||||
// SMA (middle)
|
||||
try drawLineSeries(&ctx, alloc, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, blendColor(th.text_muted, 160, bg), 1.0, .middle);
|
||||
try drawLineSeries(&ctx, bb, data.len, price_min, price_max, price_top, price_bottom, chart_left, x_step, blendColor(th.text_muted, 160, bg), 1.0, .middle);
|
||||
}
|
||||
|
||||
// ── Price line (on top of everything) ─────────────────────────────
|
||||
|
|
@ -339,9 +411,9 @@ pub fn renderChart(
|
|||
// ── RSI panel ─────────────────────────────────────────────────────
|
||||
{
|
||||
const ref_color = blendColor(th.text_muted, 100, bg);
|
||||
try drawHLine(&ctx, alloc, chart_left, chart_right, mapY(70, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
|
||||
try drawHLine(&ctx, alloc, chart_left, chart_right, mapY(30, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
|
||||
try drawHLine(&ctx, alloc, chart_left, chart_right, mapY(50, 0, 100, rsi_top, rsi_bottom), blendColor(th.text_muted, 50, bg), 1.0);
|
||||
try drawHLine(&ctx, chart_left, chart_right, mapY(70, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
|
||||
try drawHLine(&ctx, chart_left, chart_right, mapY(30, 0, 100, rsi_top, rsi_bottom), ref_color, 1.0);
|
||||
try drawHLine(&ctx, chart_left, chart_right, mapY(50, 0, 100, rsi_top, rsi_bottom), blendColor(th.text_muted, 50, bg), 1.0);
|
||||
|
||||
const rsi_color = blendColor(th.info, 220, bg);
|
||||
ctx.setSourceToPixel(rsi_color);
|
||||
|
|
@ -366,8 +438,8 @@ pub fn renderChart(
|
|||
// ── Panel borders ─────────────────────────────────────────────────
|
||||
{
|
||||
const border_color = blendColor(th.border, 80, bg);
|
||||
try drawRect(&ctx, alloc, chart_left, price_top, chart_right, price_bottom, border_color, 1.0);
|
||||
try drawRect(&ctx, alloc, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0);
|
||||
try drawRect(&ctx, chart_left, price_top, chart_right, price_bottom, border_color, 1.0);
|
||||
try drawRect(&ctx, chart_left, rsi_top, chart_right, rsi_bottom, border_color, 1.0);
|
||||
}
|
||||
|
||||
// Get latest RSI
|
||||
|
|
@ -436,7 +508,6 @@ const BandField = enum { upper, middle, lower };
|
|||
|
||||
fn drawLineSeries(
|
||||
ctx: *Context,
|
||||
alloc: std.mem.Allocator,
|
||||
bb: []const ?zfin.indicators.BollingerBand,
|
||||
len: usize,
|
||||
price_min: f64,
|
||||
|
|
@ -449,7 +520,6 @@ fn drawLineSeries(
|
|||
line_w: f64,
|
||||
field: BandField,
|
||||
) !void {
|
||||
_ = alloc;
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(line_w);
|
||||
ctx.resetPath();
|
||||
|
|
@ -477,23 +547,17 @@ fn drawLineSeries(
|
|||
|
||||
fn drawHorizontalGridLines(
|
||||
ctx: *Context,
|
||||
alloc: std.mem.Allocator,
|
||||
left: f64,
|
||||
right: f64,
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
min_val: f64,
|
||||
max_val: f64,
|
||||
n_lines: usize,
|
||||
col: Pixel,
|
||||
) !void {
|
||||
_ = alloc;
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(0.5);
|
||||
for (1..n_lines) |i| {
|
||||
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_lines));
|
||||
_ = min_val;
|
||||
_ = max_val;
|
||||
const y = top + frac * (bottom - top);
|
||||
ctx.resetPath();
|
||||
try ctx.moveTo(left, y);
|
||||
|
|
@ -503,8 +567,7 @@ fn drawHorizontalGridLines(
|
|||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawHLine(ctx: *Context, alloc: std.mem.Allocator, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void {
|
||||
_ = alloc;
|
||||
fn drawHLine(ctx: *Context, x1: f64, x2: f64, y: f64, col: Pixel, w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(w);
|
||||
ctx.resetPath();
|
||||
|
|
@ -514,8 +577,7 @@ fn drawHLine(ctx: *Context, alloc: std.mem.Allocator, x1: f64, x2: f64, y: f64,
|
|||
ctx.setLineWidth(2.0);
|
||||
}
|
||||
|
||||
fn drawRect(ctx: *Context, alloc: std.mem.Allocator, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, w: f64) !void {
|
||||
_ = alloc;
|
||||
fn drawRect(ctx: *Context, x1: f64, y1: f64, x2: f64, y2: f64, col: Pixel, w: f64) !void {
|
||||
ctx.setSourceToPixel(col);
|
||||
ctx.setLineWidth(w);
|
||||
ctx.resetPath();
|
||||
|
|
|
|||
170
src/tui/earnings_tab.zig
Normal file
170
src/tui/earnings_tab.zig
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("../root.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const theme_mod = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
const App = tui.App;
|
||||
const StyledLine = tui.StyledLine;
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
||||
pub fn loadData(app: *App) void {
|
||||
app.earnings_loaded = true;
|
||||
app.freeEarnings();
|
||||
|
||||
const result = app.svc.getEarnings(app.symbol) catch |err| {
|
||||
switch (err) {
|
||||
zfin.DataError.NoApiKey => app.setStatus("No API key. Set FINNHUB_API_KEY"),
|
||||
zfin.DataError.FetchFailed => {
|
||||
app.earnings_disabled = true;
|
||||
app.setStatus("No earnings data (ETF/index?)");
|
||||
},
|
||||
else => app.setStatus("Error loading earnings"),
|
||||
}
|
||||
return;
|
||||
};
|
||||
app.earnings_data = result.data;
|
||||
app.earnings_timestamp = result.timestamp;
|
||||
|
||||
// Sort chronologically (oldest first) — providers may return in any order
|
||||
if (result.data.len > 1) {
|
||||
std.mem.sort(zfin.EarningsEvent, result.data, {}, struct {
|
||||
fn f(_: void, a: zfin.EarningsEvent, b: zfin.EarningsEvent) bool {
|
||||
return a.date.days < b.date.days;
|
||||
}
|
||||
}.f);
|
||||
}
|
||||
|
||||
if (result.data.len == 0) {
|
||||
app.earnings_disabled = true;
|
||||
app.setStatus("No earnings data available (ETF/index?)");
|
||||
return;
|
||||
}
|
||||
app.setStatus(if (result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────
|
||||
|
||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
return renderEarningsLines(arena, app.theme, app.symbol, app.earnings_disabled, app.earnings_data, app.earnings_timestamp);
|
||||
}
|
||||
|
||||
/// Render earnings tab content. Pure function — no App dependency.
|
||||
pub fn renderEarningsLines(
|
||||
arena: std.mem.Allocator,
|
||||
th: theme_mod.Theme,
|
||||
symbol: []const u8,
|
||||
earnings_disabled: bool,
|
||||
earnings_data: ?[]const zfin.EarningsEvent,
|
||||
earnings_timestamp: i64,
|
||||
) ![]const StyledLine {
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
if (symbol.len == 0) {
|
||||
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
if (earnings_disabled) {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings not available for {s} (ETF/index)", .{symbol}), .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
var earn_ago_buf: [16]u8 = undefined;
|
||||
const earn_ago = fmt.fmtTimeAgo(&earn_ago_buf, earnings_timestamp);
|
||||
if (earn_ago.len > 0) {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s} (data {s})", .{ symbol, earn_ago }), .style = th.headerStyle() });
|
||||
} else {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Earnings: {s}", .{symbol}), .style = th.headerStyle() });
|
||||
}
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
const ev = earnings_data orelse {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin earnings {s}", .{symbol}), .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
};
|
||||
if (ev.len == 0) {
|
||||
try lines.append(arena, .{ .text = " No earnings events found.", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>4} {s:>12} {s:>12} {s:>12} {s:>10} {s:>5}", .{
|
||||
"Date", "Q", "EPS Est", "EPS Act", "Surprise", "Surprise %", "When",
|
||||
}), .style = th.mutedStyle() });
|
||||
|
||||
for (ev) |e| {
|
||||
var row_buf: [128]u8 = undefined;
|
||||
const row = fmt.fmtEarningsRow(&row_buf, e);
|
||||
|
||||
const text = try std.fmt.allocPrint(arena, " {s} {s:>5}", .{ row.text, @tagName(e.report_time) });
|
||||
const row_style = if (row.is_future) th.mutedStyle() else if (row.is_positive) th.positiveStyle() else th.negativeStyle();
|
||||
|
||||
try lines.append(arena, .{ .text = text, .style = row_style });
|
||||
}
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {d} earnings event(s)", .{ev.len}), .style = th.mutedStyle() });
|
||||
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "renderEarningsLines with earnings data" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const th = theme_mod.default_theme;
|
||||
|
||||
const events = [_]zfin.EarningsEvent{.{
|
||||
.symbol = "AAPL",
|
||||
.date = try zfin.Date.parse("2025-01-15"),
|
||||
.quarter = 4,
|
||||
.estimate = 1.50,
|
||||
.actual = 1.65,
|
||||
}};
|
||||
const lines = try renderEarningsLines(arena, th, "AAPL", false, &events, 0);
|
||||
// blank + header + blank + col_header + data_row + blank + count = 7
|
||||
try testing.expectEqual(@as(usize, 7), lines.len);
|
||||
try testing.expect(std.mem.indexOf(u8, lines[1].text, "AAPL") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "EPS Est") != null);
|
||||
// Data row should contain the date
|
||||
try testing.expect(std.mem.indexOf(u8, lines[4].text, "2025-01-15") != null);
|
||||
}
|
||||
|
||||
test "renderEarningsLines no symbol" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const th = theme_mod.default_theme;
|
||||
|
||||
const lines = try renderEarningsLines(arena, th, "", false, null, 0);
|
||||
try testing.expectEqual(@as(usize, 2), lines.len);
|
||||
try testing.expect(std.mem.indexOf(u8, lines[1].text, "No symbol") != null);
|
||||
}
|
||||
|
||||
test "renderEarningsLines disabled" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const th = theme_mod.default_theme;
|
||||
|
||||
const lines = try renderEarningsLines(arena, th, "VTI", true, null, 0);
|
||||
try testing.expectEqual(@as(usize, 2), lines.len);
|
||||
try testing.expect(std.mem.indexOf(u8, lines[1].text, "ETF/index") != null);
|
||||
}
|
||||
|
||||
test "renderEarningsLines no data" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
const th = theme_mod.default_theme;
|
||||
|
||||
const lines = try renderEarningsLines(arena, th, "AAPL", false, null, 0);
|
||||
try testing.expectEqual(@as(usize, 4), lines.len);
|
||||
try testing.expect(std.mem.indexOf(u8, lines[3].text, "No data") != null);
|
||||
}
|
||||
|
|
@ -25,7 +25,6 @@ pub const Action = enum {
|
|||
select_symbol,
|
||||
symbol_input,
|
||||
help,
|
||||
edit,
|
||||
reload_portfolio,
|
||||
collapse_all_calls,
|
||||
collapse_all_puts,
|
||||
|
|
@ -108,7 +107,6 @@ const default_bindings = [_]Binding{
|
|||
.{ .action = .select_symbol, .key = .{ .codepoint = 's' } },
|
||||
.{ .action = .symbol_input, .key = .{ .codepoint = '/' } },
|
||||
.{ .action = .help, .key = .{ .codepoint = '?' } },
|
||||
.{ .action = .edit, .key = .{ .codepoint = 'e' } },
|
||||
.{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } },
|
||||
.{ .action = .collapse_all_calls, .key = .{ .codepoint = 'c' } },
|
||||
.{ .action = .collapse_all_puts, .key = .{ .codepoint = 'p' } },
|
||||
|
|
|
|||
139
src/tui/options_tab.zig
Normal file
139
src/tui/options_tab.zig
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("../root.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const theme_mod = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
|
||||
const App = tui.App;
|
||||
const StyledLine = tui.StyledLine;
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
||||
pub fn loadData(app: *App) void {
|
||||
app.options_loaded = true;
|
||||
app.freeOptions();
|
||||
|
||||
const result = app.svc.getOptions(app.symbol) catch |err| {
|
||||
switch (err) {
|
||||
zfin.DataError.FetchFailed => app.setStatus("CBOE fetch failed (network error)"),
|
||||
else => app.setStatus("Error loading options"),
|
||||
}
|
||||
return;
|
||||
};
|
||||
app.options_data = result.data;
|
||||
app.options_timestamp = result.timestamp;
|
||||
app.options_cursor = 0;
|
||||
app.options_expanded = [_]bool{false} ** 64;
|
||||
app.options_calls_collapsed = [_]bool{false} ** 64;
|
||||
app.options_puts_collapsed = [_]bool{false} ** 64;
|
||||
app.rebuildOptionsRows();
|
||||
app.setStatus(if (result.source == .cached) "Cached (1hr TTL) | r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────
|
||||
|
||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = app.theme;
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
if (app.symbol.len == 0) {
|
||||
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
const chains = app.options_data orelse {
|
||||
try lines.append(arena, .{ .text = " Loading options data...", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
};
|
||||
|
||||
if (chains.len == 0) {
|
||||
try lines.append(arena, .{ .text = " No options data found.", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
var opt_ago_buf: [16]u8 = undefined;
|
||||
const opt_ago = fmt.fmtTimeAgo(&opt_ago_buf, app.options_timestamp);
|
||||
if (opt_ago.len > 0) {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s} (data {s}, 15 min delay)", .{ app.symbol, opt_ago }), .style = th.headerStyle() });
|
||||
} else {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Options: {s}", .{app.symbol}), .style = th.headerStyle() });
|
||||
}
|
||||
|
||||
if (chains[0].underlying_price) |price| {
|
||||
var price_buf: [24]u8 = undefined;
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Underlying: {s} {d} expiration(s) +/- {d} strikes NTM (Ctrl+1-9 to change)", .{ fmt.fmtMoneyAbs(&price_buf, price), chains.len, app.options_near_the_money }), .style = th.contentStyle() });
|
||||
}
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
// Track header line count for mouse click mapping (after all non-data lines)
|
||||
app.options_header_lines = lines.items.len;
|
||||
|
||||
// Flat list of options rows with inline expand/collapse
|
||||
for (app.options_rows.items, 0..) |row, ri| {
|
||||
const is_cursor = ri == app.options_cursor;
|
||||
switch (row.kind) {
|
||||
.expiration => {
|
||||
if (row.exp_idx < chains.len) {
|
||||
const chain = chains[row.exp_idx];
|
||||
var db: [10]u8 = undefined;
|
||||
const is_expanded = row.exp_idx < app.options_expanded.len and app.options_expanded[row.exp_idx];
|
||||
const is_monthly = fmt.isMonthlyExpiration(chain.expiration);
|
||||
const arrow: []const u8 = if (is_expanded) "v " else "> ";
|
||||
const text = try std.fmt.allocPrint(arena, " {s}{s} ({d} calls, {d} puts)", .{
|
||||
arrow,
|
||||
chain.expiration.format(&db),
|
||||
chain.calls.len,
|
||||
chain.puts.len,
|
||||
});
|
||||
const style = if (is_cursor) th.selectStyle() else if (is_monthly) th.contentStyle() else th.mutedStyle();
|
||||
try lines.append(arena, .{ .text = text, .style = style });
|
||||
}
|
||||
},
|
||||
.calls_header => {
|
||||
const calls_collapsed = row.exp_idx < app.options_calls_collapsed.len and app.options_calls_collapsed[row.exp_idx];
|
||||
const arrow: []const u8 = if (calls_collapsed) " > " else " v ";
|
||||
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Calls", .{
|
||||
arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
|
||||
}), .style = style });
|
||||
},
|
||||
.puts_header => {
|
||||
const puts_collapsed = row.exp_idx < app.options_puts_collapsed.len and app.options_puts_collapsed[row.exp_idx];
|
||||
const arrow: []const u8 = if (puts_collapsed) " > " else " v ";
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
const style = if (is_cursor) th.selectStyle() else th.headerStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, "{s}{s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>8} {s:>8} Puts", .{
|
||||
arrow, "Strike", "Last", "Bid", "Ask", "Volume", "OI", "IV",
|
||||
}), .style = style });
|
||||
},
|
||||
.call => {
|
||||
if (row.contract) |cc| {
|
||||
const atm_price = chains[0].underlying_price orelse 0;
|
||||
const itm = cc.strike <= atm_price;
|
||||
const prefix: []const u8 = if (itm) " |" else " ";
|
||||
var contract_buf: [128]u8 = undefined;
|
||||
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, cc));
|
||||
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||
try lines.append(arena, .{ .text = text, .style = style });
|
||||
}
|
||||
},
|
||||
.put => {
|
||||
if (row.contract) |p| {
|
||||
const atm_price = chains[0].underlying_price orelse 0;
|
||||
const itm = p.strike >= atm_price;
|
||||
const prefix: []const u8 = if (itm) " |" else " ";
|
||||
var contract_buf: [128]u8 = undefined;
|
||||
const text = try arena.dupe(u8, fmt.fmtContractLine(&contract_buf, prefix, p));
|
||||
const style = if (is_cursor) th.selectStyle() else th.contentStyle();
|
||||
try lines.append(arena, .{ .text = text, .style = style });
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
208
src/tui/perf_tab.zig
Normal file
208
src/tui/perf_tab.zig
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("../root.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const theme_mod = @import("theme.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
|
||||
const App = tui.App;
|
||||
const StyledLine = tui.StyledLine;
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
||||
pub fn loadData(app: *App) void {
|
||||
app.perf_loaded = true;
|
||||
app.freeCandles();
|
||||
app.freeDividends();
|
||||
app.trailing_price = null;
|
||||
app.trailing_total = null;
|
||||
app.trailing_me_price = null;
|
||||
app.trailing_me_total = null;
|
||||
app.candle_count = 0;
|
||||
app.candle_first_date = null;
|
||||
app.candle_last_date = null;
|
||||
|
||||
const candle_result = app.svc.getCandles(app.symbol) catch |err| {
|
||||
switch (err) {
|
||||
zfin.DataError.NoApiKey => app.setStatus("No API key. Set TWELVEDATA_API_KEY"),
|
||||
zfin.DataError.FetchFailed => app.setStatus("Fetch failed (network error or rate limit)"),
|
||||
else => app.setStatus("Error loading data"),
|
||||
}
|
||||
return;
|
||||
};
|
||||
app.candles = candle_result.data;
|
||||
app.candle_timestamp = candle_result.timestamp;
|
||||
|
||||
const c = app.candles.?;
|
||||
if (c.len == 0) {
|
||||
app.setStatus("No data available for symbol");
|
||||
return;
|
||||
}
|
||||
app.candle_count = c.len;
|
||||
app.candle_first_date = c[0].date;
|
||||
app.candle_last_date = c[c.len - 1].date;
|
||||
|
||||
const today = fmt.todayDate();
|
||||
app.trailing_price = zfin.performance.trailingReturns(c);
|
||||
app.trailing_me_price = zfin.performance.trailingReturnsMonthEnd(c, today);
|
||||
|
||||
if (app.svc.getDividends(app.symbol)) |div_result| {
|
||||
app.dividends = div_result.data;
|
||||
app.trailing_total = zfin.performance.trailingReturnsWithDividends(c, div_result.data);
|
||||
app.trailing_me_total = zfin.performance.trailingReturnsMonthEndWithDividends(c, div_result.data, today);
|
||||
} else |_| {}
|
||||
|
||||
app.risk_metrics = zfin.risk.trailingRisk(c);
|
||||
|
||||
// Try to load ETF profile (non-fatal, won't show for non-ETFs)
|
||||
if (!app.etf_loaded) {
|
||||
app.etf_loaded = true;
|
||||
if (app.svc.getEtfProfile(app.symbol)) |etf_result| {
|
||||
if (etf_result.data.isEtf()) {
|
||||
app.etf_profile = etf_result.data;
|
||||
}
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
app.setStatus(if (candle_result.source == .cached) "r/F5 to refresh" else "Fetched | r/F5 to refresh");
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────
|
||||
|
||||
pub fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = app.theme;
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
if (app.symbol.len == 0) {
|
||||
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
if (app.candle_last_date) |d| {
|
||||
var pdate_buf: [10]u8 = undefined;
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s} (as of close on {s})", .{ app.symbol, d.format(&pdate_buf) }), .style = th.headerStyle() });
|
||||
} else {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Trailing Returns: {s}", .{app.symbol}), .style = th.headerStyle() });
|
||||
}
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
if (app.trailing_price == null) {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
if (app.candle_count > 0) {
|
||||
if (app.candle_first_date) |first| {
|
||||
if (app.candle_last_date) |last| {
|
||||
var fb: [10]u8 = undefined;
|
||||
var lb: [10]u8 = undefined;
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Data: {d} points ({s} to {s})", .{
|
||||
app.candle_count, first.format(&fb), last.format(&lb),
|
||||
}), .style = th.mutedStyle() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (app.candles) |cc| {
|
||||
if (cc.len > 0) {
|
||||
var close_buf: [24]u8 = undefined;
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Latest close: {s}", .{fmt.fmtMoneyAbs(&close_buf, cc[cc.len - 1].close)}), .style = th.contentStyle() });
|
||||
}
|
||||
}
|
||||
|
||||
const has_total = app.trailing_total != null;
|
||||
|
||||
if (app.candle_last_date) |last| {
|
||||
var db: [10]u8 = undefined;
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " As-of {s}:", .{last.format(&db)}), .style = th.headerStyle() });
|
||||
}
|
||||
try appendStyledReturnsTable(arena, &lines, app.trailing_price.?, if (has_total) app.trailing_total else null, th);
|
||||
|
||||
{
|
||||
const today = fmt.todayDate();
|
||||
const month_end = today.lastDayOfPriorMonth();
|
||||
var db: [10]u8 = undefined;
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Month-end ({s}):", .{month_end.format(&db)}), .style = th.headerStyle() });
|
||||
}
|
||||
if (app.trailing_me_price) |me_price| {
|
||||
try appendStyledReturnsTable(arena, &lines, me_price, if (has_total) app.trailing_me_total else null, th);
|
||||
}
|
||||
|
||||
if (!has_total) {
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = " (Set POLYGON_API_KEY for total returns with dividends)", .style = th.dimStyle() });
|
||||
}
|
||||
|
||||
if (app.risk_metrics) |tr| {
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = " Risk Metrics (monthly returns):", .style = th.headerStyle() });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{ "", "Volatility", "Sharpe", "Max DD" }), .style = th.mutedStyle() });
|
||||
|
||||
const risk_arr = [4]?zfin.risk.RiskMetrics{ tr.one_year, tr.three_year, tr.five_year, tr.ten_year };
|
||||
const risk_labels = [4][]const u8{ "1-Year:", "3-Year:", "5-Year:", "10-Year:" };
|
||||
|
||||
for (0..4) |i| {
|
||||
if (risk_arr[i]) |rm| {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {d:>13.1}% {d:>14.2} {d:>13.1}%", .{
|
||||
risk_labels[i], rm.volatility * 100.0, rm.sharpe, rm.max_drawdown * 100.0,
|
||||
}), .style = th.contentStyle() });
|
||||
} else {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14} {s:>14}", .{
|
||||
risk_labels[i], "—", "—", "—",
|
||||
}), .style = th.mutedStyle() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
fn appendStyledReturnsTable(
|
||||
arena: std.mem.Allocator,
|
||||
lines: *std.ArrayList(StyledLine),
|
||||
price: zfin.performance.TrailingReturns,
|
||||
total: ?zfin.performance.TrailingReturns,
|
||||
th: theme_mod.Theme,
|
||||
) !void {
|
||||
const has_total = total != null;
|
||||
if (has_total) {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}", .{ "", "Price Only", "Total Return" }), .style = th.mutedStyle() });
|
||||
} else {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}", .{ "", "Price Only" }), .style = th.mutedStyle() });
|
||||
}
|
||||
|
||||
const price_arr = [4]?zfin.performance.PerformanceResult{ price.one_year, price.three_year, price.five_year, price.ten_year };
|
||||
const total_arr_vals: [4]?zfin.performance.PerformanceResult = if (total) |t|
|
||||
.{ t.one_year, t.three_year, t.five_year, t.ten_year }
|
||||
else
|
||||
.{ null, null, null, null };
|
||||
const labels = [4][]const u8{ "1-Year Return:", "3-Year Return:", "5-Year Return:", "10-Year Return:" };
|
||||
const annualize = [4]bool{ false, true, true, true };
|
||||
|
||||
for (0..4) |i| {
|
||||
var price_buf: [32]u8 = undefined;
|
||||
var total_buf: [32]u8 = undefined;
|
||||
const row = fmt.fmtReturnsRow(
|
||||
&price_buf,
|
||||
&total_buf,
|
||||
price_arr[i],
|
||||
if (has_total) total_arr_vals[i] else null,
|
||||
annualize[i],
|
||||
);
|
||||
|
||||
const row_style = if (price_arr[i] != null)
|
||||
(if (row.price_positive) th.positiveStyle() else th.negativeStyle())
|
||||
else
|
||||
th.mutedStyle();
|
||||
|
||||
if (has_total) {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14} {s:>14}{s}", .{ labels[i], row.price_str, row.total_str orelse "N/A", row.suffix }), .style = row_style });
|
||||
} else {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:<20} {s:>14}{s}", .{ labels[i], row.price_str, row.suffix }), .style = row_style });
|
||||
}
|
||||
}
|
||||
}
|
||||
1042
src/tui/portfolio_tab.zig
Normal file
1042
src/tui/portfolio_tab.zig
Normal file
File diff suppressed because it is too large
Load diff
605
src/tui/quote_tab.zig
Normal file
605
src/tui/quote_tab.zig
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
const std = @import("std");
|
||||
const vaxis = @import("vaxis");
|
||||
const zfin = @import("../root.zig");
|
||||
const fmt = @import("../format.zig");
|
||||
const theme_mod = @import("theme.zig");
|
||||
const chart_mod = @import("chart.zig");
|
||||
const tui = @import("../tui.zig");
|
||||
|
||||
const App = tui.App;
|
||||
const StyledLine = tui.StyledLine;
|
||||
const glyph = tui.glyph;
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────
|
||||
|
||||
/// Draw the quote tab content. Uses Kitty graphics for the chart when available,
|
||||
/// falling back to braille sparkline otherwise.
|
||||
pub fn drawContent(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
const arena = ctx.arena;
|
||||
|
||||
// Determine whether to use Kitty graphics
|
||||
const use_kitty = switch (app.chart.config.mode) {
|
||||
.braille => false,
|
||||
.kitty => true,
|
||||
.auto => if (app.vx_app) |va| va.vx.caps.kitty_graphics else false,
|
||||
};
|
||||
|
||||
if (use_kitty and app.candles != null and app.candles.?.len >= 40) {
|
||||
drawWithKittyChart(app, ctx, buf, width, height) catch {
|
||||
// On any failure, fall back to braille
|
||||
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
|
||||
};
|
||||
} else {
|
||||
// Fallback to styled lines with braille chart
|
||||
try app.drawStyledContent(arena, buf, width, height, try buildStyledLines(app, arena));
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw quote tab using Kitty graphics protocol for the chart.
|
||||
fn drawWithKittyChart(app: *App, ctx: vaxis.vxfw.DrawContext, buf: []vaxis.Cell, width: u16, height: u16) !void {
|
||||
const arena = ctx.arena;
|
||||
const th = app.theme;
|
||||
const c = app.candles orelse return;
|
||||
|
||||
// Build text header (symbol, price, change) — first few lines
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
// Symbol + price header
|
||||
if (app.quote) |q| {
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2}", .{ app.symbol, q.close });
|
||||
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||
if (q.previous_close > 0) {
|
||||
const change = q.close - q.previous_close;
|
||||
const pct = (change / q.previous_close) * 100.0;
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||
}
|
||||
} else if (c.len > 0) {
|
||||
const last = c[c.len - 1];
|
||||
const price_str = try std.fmt.allocPrint(arena, " {s} ${d:.2} (close)", .{ app.symbol, last.close });
|
||||
try lines.append(arena, .{ .text = price_str, .style = th.headerStyle() });
|
||||
if (c.len >= 2) {
|
||||
const prev_close = c[c.len - 2].close;
|
||||
const change = last.close - prev_close;
|
||||
const pct = (change / prev_close) * 100.0;
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), .style = change_style });
|
||||
}
|
||||
}
|
||||
|
||||
// Timeframe selector line
|
||||
{
|
||||
var tf_buf: [80]u8 = undefined;
|
||||
var tf_pos: usize = 0;
|
||||
const prefix = " Chart: ";
|
||||
@memcpy(tf_buf[tf_pos..][0..prefix.len], prefix);
|
||||
tf_pos += prefix.len;
|
||||
const timeframes = [_]chart_mod.Timeframe{ .@"6M", .ytd, .@"1Y", .@"3Y", .@"5Y" };
|
||||
for (timeframes) |tf| {
|
||||
const lbl = tf.label();
|
||||
if (tf == app.chart.timeframe) {
|
||||
tf_buf[tf_pos] = '[';
|
||||
tf_pos += 1;
|
||||
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
|
||||
tf_pos += lbl.len;
|
||||
tf_buf[tf_pos] = ']';
|
||||
tf_pos += 1;
|
||||
} else {
|
||||
tf_buf[tf_pos] = ' ';
|
||||
tf_pos += 1;
|
||||
@memcpy(tf_buf[tf_pos..][0..lbl.len], lbl);
|
||||
tf_pos += lbl.len;
|
||||
tf_buf[tf_pos] = ' ';
|
||||
tf_pos += 1;
|
||||
}
|
||||
tf_buf[tf_pos] = ' ';
|
||||
tf_pos += 1;
|
||||
}
|
||||
const hint = " ([ ] to change)";
|
||||
@memcpy(tf_buf[tf_pos..][0..hint.len], hint);
|
||||
tf_pos += hint.len;
|
||||
app.chart.timeframe_row = lines.items.len; // track which row the timeframe line is on
|
||||
try lines.append(arena, .{ .text = try arena.dupe(u8, tf_buf[0..tf_pos]), .style = th.mutedStyle() });
|
||||
}
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
// Draw the text header
|
||||
const header_lines = try lines.toOwnedSlice(arena);
|
||||
try app.drawStyledContent(arena, buf, width, height, header_lines);
|
||||
|
||||
// Calculate chart area (below the header, leaving room for details below)
|
||||
const header_rows: u16 = @intCast(@min(header_lines.len, height));
|
||||
const detail_rows: u16 = 10; // reserve rows for quote details below chart
|
||||
const chart_rows = height -| header_rows -| detail_rows;
|
||||
if (chart_rows < 8) return; // not enough space
|
||||
|
||||
// Compute pixel dimensions from cell size
|
||||
// cell_size may be 0 if terminal hasn't reported pixel dimensions yet
|
||||
const cell_w: u32 = if (ctx.cell_size.width > 0) ctx.cell_size.width else 8;
|
||||
const cell_h: u32 = if (ctx.cell_size.height > 0) ctx.cell_size.height else 16;
|
||||
const label_cols: u16 = 10; // columns reserved for axis labels on the right
|
||||
const chart_cols = width -| 2 -| label_cols; // 1 col left margin + label area on right
|
||||
if (chart_cols == 0) return;
|
||||
const px_w: u32 = @as(u32, chart_cols) * cell_w;
|
||||
const px_h: u32 = @as(u32, chart_rows) * cell_h;
|
||||
|
||||
if (px_w < 100 or px_h < 100) return;
|
||||
// Apply resolution cap from chart config
|
||||
const capped_w = @min(px_w, app.chart.config.max_width);
|
||||
const capped_h = @min(px_h, app.chart.config.max_height);
|
||||
|
||||
// Check if we need to re-render the chart image
|
||||
const symbol_changed = app.chart.symbol_len != app.symbol.len or
|
||||
!std.mem.eql(u8, app.chart.symbol[0..app.chart.symbol_len], app.symbol);
|
||||
const tf_changed = app.chart.timeframe_rendered == null or app.chart.timeframe_rendered.? != app.chart.timeframe;
|
||||
|
||||
if (app.chart.dirty or symbol_changed or tf_changed) {
|
||||
// Free old image
|
||||
if (app.chart.image_id) |old_id| {
|
||||
if (app.vx_app) |va| {
|
||||
va.vx.freeImage(va.tty.writer(), old_id);
|
||||
}
|
||||
app.chart.image_id = null;
|
||||
}
|
||||
|
||||
// If symbol changed, invalidate the indicator cache
|
||||
if (symbol_changed) {
|
||||
app.chart.freeCache(app.allocator);
|
||||
}
|
||||
|
||||
// Check if we can reuse cached indicators
|
||||
const cache_valid = app.chart.isCacheValid(c, app.chart.timeframe);
|
||||
|
||||
// If cache is invalid, compute new indicators
|
||||
if (!cache_valid) {
|
||||
// Free old cache if it exists
|
||||
app.chart.freeCache(app.allocator);
|
||||
|
||||
// Compute and cache new indicators
|
||||
const new_cache = chart_mod.computeIndicators(
|
||||
app.allocator,
|
||||
c,
|
||||
app.chart.timeframe,
|
||||
) catch |err| {
|
||||
app.chart.dirty = false;
|
||||
var err_buf: [128]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&err_buf, "Indicator computation failed: {s}", .{@errorName(err)}) catch "Indicator computation failed";
|
||||
app.setStatus(msg);
|
||||
return;
|
||||
};
|
||||
app.chart.cached_indicators = new_cache;
|
||||
|
||||
// Update cache metadata
|
||||
const max_days = app.chart.timeframe.tradingDays();
|
||||
const n = @min(c.len, max_days);
|
||||
const data = c[c.len - n ..];
|
||||
app.chart.cache_candle_count = data.len;
|
||||
app.chart.cache_timeframe = app.chart.timeframe;
|
||||
app.chart.cache_last_close = if (data.len > 0) data[data.len - 1].close else 0;
|
||||
}
|
||||
|
||||
// Render and transmit — use the app's main allocator, NOT the arena,
|
||||
// because z2d allocates large pixel buffers that would bloat the arena.
|
||||
if (app.vx_app) |va| {
|
||||
// Pass cached indicators to avoid recomputation during rendering
|
||||
const cached_ptr: ?*const chart_mod.CachedIndicators = if (app.chart.cached_indicators) |*ci| ci else null;
|
||||
|
||||
const chart_result = chart_mod.renderChart(
|
||||
app.allocator,
|
||||
c,
|
||||
app.chart.timeframe,
|
||||
capped_w,
|
||||
capped_h,
|
||||
th,
|
||||
cached_ptr,
|
||||
) catch |err| {
|
||||
app.chart.dirty = false;
|
||||
var err_buf: [128]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&err_buf, "Chart render failed: {s}", .{@errorName(err)}) catch "Chart render failed";
|
||||
app.setStatus(msg);
|
||||
return;
|
||||
};
|
||||
defer app.allocator.free(chart_result.rgb_data);
|
||||
|
||||
// Base64-encode and transmit raw RGB data directly via Kitty protocol.
|
||||
// This avoids the PNG encode → file write → file read → PNG decode roundtrip.
|
||||
const base64_enc = std.base64.standard.Encoder;
|
||||
const b64_buf = app.allocator.alloc(u8, base64_enc.calcSize(chart_result.rgb_data.len)) catch {
|
||||
app.chart.dirty = false;
|
||||
app.setStatus("Chart: base64 alloc failed");
|
||||
return;
|
||||
};
|
||||
defer app.allocator.free(b64_buf);
|
||||
const encoded = base64_enc.encode(b64_buf, chart_result.rgb_data);
|
||||
|
||||
const img = va.vx.transmitPreEncodedImage(
|
||||
va.tty.writer(),
|
||||
encoded,
|
||||
chart_result.width,
|
||||
chart_result.height,
|
||||
.rgb,
|
||||
) catch |err| {
|
||||
app.chart.dirty = false;
|
||||
var err_buf: [128]u8 = undefined;
|
||||
const msg = std.fmt.bufPrint(&err_buf, "Image transmit failed: {s}", .{@errorName(err)}) catch "Image transmit failed";
|
||||
app.setStatus(msg);
|
||||
return;
|
||||
};
|
||||
|
||||
app.chart.image_id = img.id;
|
||||
app.chart.image_width = @intCast(chart_cols);
|
||||
app.chart.image_height = chart_rows;
|
||||
|
||||
// Track what we rendered
|
||||
const sym_len = @min(app.symbol.len, 16);
|
||||
@memcpy(app.chart.symbol[0..sym_len], app.symbol[0..sym_len]);
|
||||
app.chart.symbol_len = sym_len;
|
||||
app.chart.timeframe_rendered = app.chart.timeframe;
|
||||
app.chart.price_min = chart_result.price_min;
|
||||
app.chart.price_max = chart_result.price_max;
|
||||
app.chart.rsi_latest = chart_result.rsi_latest;
|
||||
app.chart.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Place the image in the cell buffer
|
||||
if (app.chart.image_id) |img_id| {
|
||||
// Place image at the first cell of the chart area
|
||||
const chart_row_start: usize = header_rows;
|
||||
const chart_col_start: usize = 1; // 1 col left margin
|
||||
const buf_idx = chart_row_start * @as(usize, width) + chart_col_start;
|
||||
if (buf_idx < buf.len) {
|
||||
buf[buf_idx] = .{
|
||||
.char = .{ .grapheme = " " },
|
||||
.style = th.contentStyle(),
|
||||
.image = .{
|
||||
.img_id = img_id,
|
||||
.options = .{
|
||||
.size = .{
|
||||
.rows = app.chart.image_height,
|
||||
.cols = app.chart.image_width,
|
||||
},
|
||||
.scale = .contain,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Axis labels (terminal text in the right margin) ───────────
|
||||
// The chart image uses layout fractions: price=72%, gap=8%, RSI=20%
|
||||
// Map these to terminal rows to position labels.
|
||||
const img_rows = app.chart.image_height;
|
||||
const label_col: usize = @as(usize, chart_col_start) + @as(usize, app.chart.image_width) + 1;
|
||||
const label_style = th.mutedStyle();
|
||||
|
||||
if (label_col + 8 <= width and img_rows >= 4 and app.chart.price_max > app.chart.price_min) {
|
||||
// Price axis labels — evenly spaced across the price panel (top 72%)
|
||||
const price_panel_rows = @as(f64, @floatFromInt(img_rows)) * 0.72;
|
||||
const n_price_labels: usize = 5;
|
||||
for (0..n_price_labels) |i| {
|
||||
const frac = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(n_price_labels - 1));
|
||||
const price_val = app.chart.price_max - frac * (app.chart.price_max - app.chart.price_min);
|
||||
const row_f = @as(f64, @floatFromInt(chart_row_start)) + frac * price_panel_rows;
|
||||
const row: usize = @intFromFloat(@round(row_f));
|
||||
if (row >= height) continue;
|
||||
|
||||
var lbl_buf: [16]u8 = undefined;
|
||||
const lbl = fmt.fmtMoneyAbs(&lbl_buf, price_val);
|
||||
const start_idx = row * @as(usize, width) + label_col;
|
||||
for (lbl, 0..) |ch, ci| {
|
||||
const idx = start_idx + ci;
|
||||
if (idx < buf.len and label_col + ci < width) {
|
||||
buf[idx] = .{
|
||||
.char = .{ .grapheme = glyph(ch) },
|
||||
.style = label_style,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RSI axis labels — positioned within the RSI panel (bottom 20%, after 80% offset)
|
||||
const rsi_panel_start_f = @as(f64, @floatFromInt(img_rows)) * 0.80;
|
||||
const rsi_panel_h = @as(f64, @floatFromInt(img_rows)) * 0.20;
|
||||
const rsi_labels = [_]struct { val: f64, label: []const u8 }{
|
||||
.{ .val = 70, .label = "70" },
|
||||
.{ .val = 50, .label = "50" },
|
||||
.{ .val = 30, .label = "30" },
|
||||
};
|
||||
for (rsi_labels) |rl| {
|
||||
// RSI maps 0-100 top-to-bottom within the RSI panel
|
||||
const rsi_frac = 1.0 - (rl.val / 100.0);
|
||||
const row_f = @as(f64, @floatFromInt(chart_row_start)) + rsi_panel_start_f + rsi_frac * rsi_panel_h;
|
||||
const row: usize = @intFromFloat(@round(row_f));
|
||||
if (row >= height) continue;
|
||||
|
||||
const start_idx = row * @as(usize, width) + label_col;
|
||||
for (rl.label, 0..) |ch, ci| {
|
||||
const idx = start_idx + ci;
|
||||
if (idx < buf.len and label_col + ci < width) {
|
||||
buf[idx] = .{
|
||||
.char = .{ .grapheme = glyph(ch) },
|
||||
.style = label_style,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render quote details below the chart image as styled text
|
||||
const detail_start_row = header_rows + app.chart.image_height;
|
||||
if (detail_start_row + 8 < height) {
|
||||
var detail_lines: std.ArrayList(StyledLine) = .empty;
|
||||
try detail_lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
const latest = c[c.len - 1];
|
||||
const quote_data = app.quote;
|
||||
const price = if (quote_data) |q| q.close else latest.close;
|
||||
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
|
||||
|
||||
try buildDetailColumns(app, arena, &detail_lines, latest, quote_data, price, prev_close);
|
||||
|
||||
// Write detail lines into the buffer below the image
|
||||
const detail_buf_start = detail_start_row * @as(usize, width);
|
||||
const remaining_height = height - @as(u16, @intCast(detail_start_row));
|
||||
const detail_slice = try detail_lines.toOwnedSlice(arena);
|
||||
if (detail_buf_start < buf.len) {
|
||||
try app.drawStyledContent(arena, buf[detail_buf_start..], width, remaining_height, detail_slice);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn buildStyledLines(app: *App, arena: std.mem.Allocator) ![]const StyledLine {
|
||||
const th = app.theme;
|
||||
var lines: std.ArrayList(StyledLine) = .empty;
|
||||
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
if (app.symbol.len == 0) {
|
||||
try lines.append(arena, .{ .text = " No symbol selected. Press / to enter a symbol.", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
var ago_buf: [16]u8 = undefined;
|
||||
if (app.quote != null and app.quote_timestamp > 0) {
|
||||
const ago_str = fmt.fmtTimeAgo(&ago_buf, app.quote_timestamp);
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (live, ~15 min delay, refreshed {s})", .{ app.symbol, ago_str }), .style = th.headerStyle() });
|
||||
} else if (app.candle_last_date) |d| {
|
||||
var cdate_buf: [10]u8 = undefined;
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s} (as of close on {s})", .{ app.symbol, d.format(&cdate_buf) }), .style = th.headerStyle() });
|
||||
} else {
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s}", .{app.symbol}), .style = th.headerStyle() });
|
||||
}
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
|
||||
// Use stored real-time quote if available (fetched on manual refresh)
|
||||
const quote_data = app.quote;
|
||||
|
||||
const c = app.candles orelse {
|
||||
if (quote_data) |q| {
|
||||
// No candle data but have a quote - show it
|
||||
var qclose_buf: [24]u8 = undefined;
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&qclose_buf, q.close)}), .style = th.contentStyle() });
|
||||
{
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (q.change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, q.change, q.percent_change)}), .style = change_style });
|
||||
}
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " No data. Run: zfin perf {s}", .{app.symbol}), .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
};
|
||||
if (c.len == 0) {
|
||||
try lines.append(arena, .{ .text = " No candle data.", .style = th.mutedStyle() });
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
// Use real-time quote price if available, otherwise latest candle
|
||||
const price = if (quote_data) |q| q.close else c[c.len - 1].close;
|
||||
const prev_close = if (quote_data) |q| q.previous_close else if (c.len >= 2) c[c.len - 2].close else @as(f64, 0);
|
||||
const latest = c[c.len - 1];
|
||||
|
||||
try buildDetailColumns(app, arena, &lines, latest, quote_data, price, prev_close);
|
||||
|
||||
// Braille sparkline chart of recent 60 trading days
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
const chart_days: usize = @min(c.len, 60);
|
||||
const chart_data = c[c.len - chart_days ..];
|
||||
try tui.renderBrailleToStyledLines(arena, &lines, chart_data, th);
|
||||
|
||||
// Recent history table
|
||||
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
|
||||
try lines.append(arena, .{ .text = " Recent History:", .style = th.headerStyle() });
|
||||
try lines.append(arena, .{ .text = try std.fmt.allocPrint(arena, " {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}", .{ "Date", "Open", "High", "Low", "Close", "Volume" }), .style = th.mutedStyle() });
|
||||
|
||||
const start_idx = if (c.len > 20) c.len - 20 else 0;
|
||||
for (c[start_idx..]) |candle| {
|
||||
var row_buf: [128]u8 = undefined;
|
||||
const day_change = if (candle.close >= candle.open) th.positiveStyle() else th.negativeStyle();
|
||||
try lines.append(arena, .{ .text = try arena.dupe(u8, fmt.fmtCandleRow(&row_buf, candle)), .style = day_change });
|
||||
}
|
||||
|
||||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
// ── Quote detail columns (price/OHLCV | ETF stats | sectors | holdings) ──
|
||||
|
||||
const Column = struct {
|
||||
texts: std.ArrayList([]const u8),
|
||||
styles: std.ArrayList(vaxis.Style),
|
||||
width: usize, // fixed column width for padding
|
||||
|
||||
fn init() Column {
|
||||
return .{
|
||||
.texts = .empty,
|
||||
.styles = .empty,
|
||||
.width = 0,
|
||||
};
|
||||
}
|
||||
|
||||
fn add(app: *Column, arena: std.mem.Allocator, text: []const u8, style: vaxis.Style) !void {
|
||||
try app.texts.append(arena, text);
|
||||
try app.styles.append(arena, style);
|
||||
}
|
||||
|
||||
fn len(app: *const Column) usize {
|
||||
return app.texts.items.len;
|
||||
}
|
||||
};
|
||||
|
||||
fn buildDetailColumns(
|
||||
app: *App,
|
||||
arena: std.mem.Allocator,
|
||||
lines: *std.ArrayList(StyledLine),
|
||||
latest: zfin.Candle,
|
||||
quote_data: ?zfin.Quote,
|
||||
price: f64,
|
||||
prev_close: f64,
|
||||
) !void {
|
||||
const th = app.theme;
|
||||
var date_buf: [10]u8 = undefined;
|
||||
var close_buf: [24]u8 = undefined;
|
||||
var vol_buf: [32]u8 = undefined;
|
||||
|
||||
// Column 1: Price/OHLCV
|
||||
var col1 = Column.init();
|
||||
col1.width = 30;
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Date: {s}", .{latest.date.format(&date_buf)}), th.contentStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Price: {s}", .{fmt.fmtMoneyAbs(&close_buf, price)}), th.contentStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Open: ${d:.2}", .{if (quote_data) |q| q.open else latest.open}), th.mutedStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " High: ${d:.2}", .{if (quote_data) |q| q.high else latest.high}), th.mutedStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Low: ${d:.2}", .{if (quote_data) |q| q.low else latest.low}), th.mutedStyle());
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Volume: {s}", .{fmt.fmtIntCommas(&vol_buf, if (quote_data) |q| q.volume else latest.volume)}), th.mutedStyle());
|
||||
if (prev_close > 0) {
|
||||
const change = price - prev_close;
|
||||
const pct = (change / prev_close) * 100.0;
|
||||
var chg_buf: [64]u8 = undefined;
|
||||
const change_style = if (change >= 0) th.positiveStyle() else th.negativeStyle();
|
||||
try col1.add(arena, try std.fmt.allocPrint(arena, " Change: {s}", .{fmt.fmtPriceChange(&chg_buf, change, pct)}), change_style);
|
||||
}
|
||||
|
||||
// Columns 2-4: ETF profile (only for actual ETFs)
|
||||
var col2 = Column.init(); // ETF stats
|
||||
col2.width = 22;
|
||||
var col3 = Column.init(); // Sectors
|
||||
col3.width = 26;
|
||||
var col4 = Column.init(); // Top holdings
|
||||
col4.width = 30;
|
||||
|
||||
if (app.etf_profile) |profile| {
|
||||
// Col 2: ETF key stats
|
||||
try col2.add(arena, "ETF Profile", th.headerStyle());
|
||||
if (profile.expense_ratio) |er| {
|
||||
try col2.add(arena, try std.fmt.allocPrint(arena, " Expense: {d:.2}%", .{er * 100.0}), th.contentStyle());
|
||||
}
|
||||
if (profile.net_assets) |na| {
|
||||
try col2.add(arena, try std.fmt.allocPrint(arena, " Assets: ${s}", .{std.mem.trimRight(u8, &fmt.fmtLargeNum(na), &.{' '})}), th.contentStyle());
|
||||
}
|
||||
if (profile.dividend_yield) |dy| {
|
||||
try col2.add(arena, try std.fmt.allocPrint(arena, " Yield: {d:.2}%", .{dy * 100.0}), th.contentStyle());
|
||||
}
|
||||
if (profile.total_holdings) |th_val| {
|
||||
try col2.add(arena, try std.fmt.allocPrint(arena, " Holdings: {d}", .{th_val}), th.mutedStyle());
|
||||
}
|
||||
|
||||
// Col 3: Sector allocation
|
||||
if (profile.sectors) |sectors| {
|
||||
if (sectors.len > 0) {
|
||||
try col3.add(arena, "Sectors", th.headerStyle());
|
||||
const show = @min(sectors.len, 7);
|
||||
for (sectors[0..show]) |sec| {
|
||||
var title_buf: [64]u8 = undefined;
|
||||
const title_name = fmt.toTitleCase(&title_buf, sec.name);
|
||||
const name = if (title_name.len > 20) title_name[0..20] else title_name;
|
||||
try col3.add(arena, try std.fmt.allocPrint(arena, " {d:>5.1}% {s}", .{ sec.weight * 100.0, name }), th.contentStyle());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Col 4: Top holdings
|
||||
if (profile.holdings) |holdings| {
|
||||
if (holdings.len > 0) {
|
||||
try col4.add(arena, "Top Holdings", th.headerStyle());
|
||||
const show = @min(holdings.len, 7);
|
||||
for (holdings[0..show]) |h| {
|
||||
const sym_str = h.symbol orelse "--";
|
||||
try col4.add(arena, try std.fmt.allocPrint(arena, " {s:>6} {d:>5.1}%", .{ sym_str, h.weight * 100.0 }), th.contentStyle());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all columns into grapheme-based StyledLines
|
||||
const gap: usize = 3;
|
||||
const bg_style = vaxis.Style{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(th.bg) };
|
||||
const cols = [_]*const Column{ &col1, &col2, &col3, &col4 };
|
||||
var max_rows: usize = 0;
|
||||
for (cols) |col| max_rows = @max(max_rows, col.len());
|
||||
|
||||
// Total max width for allocation
|
||||
const max_width = col1.width + gap + col2.width + gap + col3.width + gap + col4.width + 4;
|
||||
|
||||
for (0..max_rows) |ri| {
|
||||
const graphemes = try arena.alloc([]const u8, max_width);
|
||||
const col_styles = try arena.alloc(vaxis.Style, max_width);
|
||||
var pos: usize = 0;
|
||||
|
||||
for (cols, 0..) |col, ci| {
|
||||
if (ci > 0 and col.len() == 0) continue; // skip empty columns entirely
|
||||
if (ci > 0) {
|
||||
// Gap between columns
|
||||
for (0..gap) |_| {
|
||||
if (pos < max_width) {
|
||||
graphemes[pos] = " ";
|
||||
col_styles[pos] = bg_style;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ri < col.len()) {
|
||||
const text = col.texts.items[ri];
|
||||
const style = col.styles.items[ri];
|
||||
// Write text characters
|
||||
for (0..@min(text.len, col.width)) |ci2| {
|
||||
if (pos < max_width) {
|
||||
graphemes[pos] = glyph(text[ci2]);
|
||||
col_styles[pos] = style;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
// Pad to column width
|
||||
if (text.len < col.width) {
|
||||
for (0..col.width - text.len) |_| {
|
||||
if (pos < max_width) {
|
||||
graphemes[pos] = " ";
|
||||
col_styles[pos] = bg_style;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Empty row in this column - pad full width
|
||||
for (0..col.width) |_| {
|
||||
if (pos < max_width) {
|
||||
graphemes[pos] = " ";
|
||||
col_styles[pos] = bg_style;
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try lines.append(arena, .{
|
||||
.text = "",
|
||||
.style = bg_style,
|
||||
.graphemes = graphemes[0..pos],
|
||||
.cell_styles = col_styles[0..pos],
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue