203 lines
8.5 KiB
Zig
203 lines
8.5 KiB
Zig
const std = @import("std");
|
|
const zfin = @import("../root.zig");
|
|
const cli = @import("common.zig");
|
|
const fmt = cli.fmt;
|
|
|
|
/// Quote data extracted from the real-time API (or synthesized from candles).
|
|
pub const QuoteData = struct {
|
|
price: f64,
|
|
open: f64,
|
|
high: f64,
|
|
low: f64,
|
|
volume: u64,
|
|
prev_close: f64,
|
|
date: zfin.Date,
|
|
};
|
|
|
|
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
|
|
// 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");
|
|
return;
|
|
},
|
|
else => {
|
|
try cli.stderrPrint("Error fetching candle data.\n");
|
|
return;
|
|
},
|
|
};
|
|
defer allocator.free(candle_result.data);
|
|
const candles = candle_result.data;
|
|
|
|
// Fetch real-time quote
|
|
var quote: ?QuoteData = null;
|
|
|
|
if (config.twelvedata_key) |key| {
|
|
var td = zfin.TwelveData.init(allocator, key);
|
|
defer td.deinit();
|
|
if (td.fetchQuote(allocator, symbol)) |qr_val| {
|
|
var qr = qr_val;
|
|
defer qr.deinit();
|
|
if (qr.parse(allocator)) |q_val| {
|
|
var q = q_val;
|
|
defer q.deinit();
|
|
quote = .{
|
|
.price = q.close(),
|
|
.open = q.open(),
|
|
.high = q.high(),
|
|
.low = q.low(),
|
|
.volume = q.volume(),
|
|
.prev_close = q.previous_close(),
|
|
.date = if (candles.len > 0) candles[candles.len - 1].date else fmt.todayDate(),
|
|
};
|
|
} else |_| {}
|
|
} else |_| {}
|
|
}
|
|
|
|
try display(allocator, candles, quote, symbol, color, out);
|
|
}
|
|
|
|
pub fn display(allocator: std.mem.Allocator, candles: []const zfin.Candle, quote: ?QuoteData, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
|
|
const has_quote = quote != null;
|
|
|
|
// Header
|
|
try cli.setBold(out, color);
|
|
if (quote) |q| {
|
|
var price_buf: [24]u8 = undefined;
|
|
try out.print("\n{s} {s}\n", .{ symbol, fmt.fmtMoney(&price_buf, q.price) });
|
|
} else if (candles.len > 0) {
|
|
var price_buf: [24]u8 = undefined;
|
|
try out.print("\n{s} {s} (close)\n", .{ symbol, fmt.fmtMoney(&price_buf, candles[candles.len - 1].close) });
|
|
} else {
|
|
try out.print("\n{s}\n", .{symbol});
|
|
}
|
|
try cli.reset(out, color);
|
|
try out.print("========================================\n", .{});
|
|
|
|
// Quote details
|
|
const price = if (quote) |q| q.price else if (candles.len > 0) candles[candles.len - 1].close else @as(f64, 0);
|
|
const prev_close = if (quote) |q| q.prev_close else if (candles.len >= 2) candles[candles.len - 2].close else @as(f64, 0);
|
|
|
|
if (candles.len > 0 or has_quote) {
|
|
const latest_date = if (quote) |q| q.date else if (candles.len > 0) candles[candles.len - 1].date else fmt.todayDate();
|
|
const open_val = if (quote) |q| q.open else if (candles.len > 0) candles[candles.len - 1].open else @as(f64, 0);
|
|
const high_val = if (quote) |q| q.high else if (candles.len > 0) candles[candles.len - 1].high else @as(f64, 0);
|
|
const low_val = if (quote) |q| q.low else if (candles.len > 0) candles[candles.len - 1].low else @as(f64, 0);
|
|
const vol_val = if (quote) |q| q.volume else if (candles.len > 0) candles[candles.len - 1].volume else @as(u64, 0);
|
|
|
|
var date_buf: [10]u8 = undefined;
|
|
var vol_buf: [32]u8 = undefined;
|
|
try out.print(" Date: {s}\n", .{latest_date.format(&date_buf)});
|
|
try out.print(" Open: ${d:.2}\n", .{open_val});
|
|
try out.print(" High: ${d:.2}\n", .{high_val});
|
|
try out.print(" Low: ${d:.2}\n", .{low_val});
|
|
try out.print(" Volume: {s}\n", .{fmt.fmtIntCommas(&vol_buf, vol_val)});
|
|
|
|
if (prev_close > 0) {
|
|
const change = price - prev_close;
|
|
const pct = (change / prev_close) * 100.0;
|
|
try cli.setGainLoss(out, color, change);
|
|
if (change >= 0) {
|
|
try out.print(" Change: +${d:.2} (+{d:.2}%)\n", .{ change, pct });
|
|
} else {
|
|
try out.print(" Change: -${d:.2} ({d:.2}%)\n", .{ -change, pct });
|
|
}
|
|
try cli.reset(out, color);
|
|
}
|
|
}
|
|
|
|
// Braille chart (60 columns, 10 rows)
|
|
if (candles.len >= 2) {
|
|
try out.print("\n", .{});
|
|
const chart_days: usize = @min(candles.len, 60);
|
|
const chart_data = candles[candles.len - chart_days ..];
|
|
var chart = fmt.computeBrailleChart(allocator, chart_data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
|
|
if (chart) |*ch| {
|
|
defer ch.deinit(allocator);
|
|
try fmt.writeBrailleAnsi(out, ch, color, cli.CLR_MUTED);
|
|
}
|
|
}
|
|
|
|
// Recent history table (last 20 candles)
|
|
if (candles.len > 0) {
|
|
try out.print("\n", .{});
|
|
try cli.setBold(out, color);
|
|
try out.print(" Recent History:\n", .{});
|
|
try cli.reset(out, color);
|
|
try cli.setFg(out, color, cli.CLR_MUTED);
|
|
try out.print(" {s:>12} {s:>10} {s:>10} {s:>10} {s:>10} {s:>12}\n", .{
|
|
"Date", "Open", "High", "Low", "Close", "Volume",
|
|
});
|
|
try cli.reset(out, color);
|
|
|
|
const start_idx = if (candles.len > 20) candles.len - 20 else 0;
|
|
for (candles[start_idx..]) |candle| {
|
|
var db: [10]u8 = undefined;
|
|
var vb: [32]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 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),
|
|
});
|
|
try cli.reset(out, color);
|
|
}
|
|
try out.print("\n {d} trading days shown\n", .{candles[start_idx..].len});
|
|
}
|
|
|
|
try out.print("\n", .{});
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────
|
|
|
|
test "display with candles only" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
|
defer _ = gpa.deinit();
|
|
const candles = [_]zfin.Candle{
|
|
.{ .date = .{ .days = 20000 }, .open = 150.0, .high = 155.0, .low = 149.0, .close = 153.0, .adj_close = 153.0, .volume = 50_000_000 },
|
|
.{ .date = .{ .days = 20001 }, .open = 153.0, .high = 158.0, .low = 152.0, .close = 156.0, .adj_close = 156.0, .volume = 45_000_000 },
|
|
};
|
|
try display(gpa.allocator(), &candles, null, "AAPL", false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Recent History") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "2 trading days shown") != null);
|
|
}
|
|
|
|
test "display with quote data" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
|
defer _ = gpa.deinit();
|
|
const candles = [_]zfin.Candle{};
|
|
const quote: QuoteData = .{
|
|
.price = 175.50,
|
|
.open = 174.00,
|
|
.high = 176.00,
|
|
.low = 173.50,
|
|
.volume = 60_000_000,
|
|
.prev_close = 172.00,
|
|
.date = .{ .days = 20001 },
|
|
};
|
|
try display(gpa.allocator(), &candles, quote, "AAPL", false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null);
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "Change") != null);
|
|
// Should NOT have "(close)" since we have a real quote
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "(close)") == null);
|
|
}
|
|
|
|
test "display no ANSI without color" {
|
|
var buf: [8192]u8 = undefined;
|
|
var w: std.Io.Writer = .fixed(&buf);
|
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
|
defer _ = gpa.deinit();
|
|
const candles = [_]zfin.Candle{
|
|
.{ .date = .{ .days = 20000 }, .open = 100.0, .high = 105.0, .low = 99.0, .close = 103.0, .adj_close = 103.0, .volume = 1_000_000 },
|
|
};
|
|
try display(gpa.allocator(), &candles, null, "SPY", false, &w);
|
|
const out = w.buffered();
|
|
try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null);
|
|
}
|