root.zig review

This commit is contained in:
Emil Lerch 2026-03-03 12:58:14 -08:00
parent 3e24d37040
commit f936531721
Signed by: lobo
GPG key ID: A7B62D657EF764F8
7 changed files with 135 additions and 97 deletions

View file

@ -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, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {
const result = svc.getDividends(symbol) catch |err| switch (err) {
zfin.DataError.NoApiKey => {
try cli.stderrPrint("Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n");
@ -18,21 +18,11 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Co
if (result.source == .cached) try cli.stderrPrint("(using cached dividend data)\n");
// Fetch current price for yield calculation
// Fetch current price for yield calculation via DataService
var current_price: ?f64 = null;
if (config.twelvedata_key) |td_key| {
var td = zfin.TwelveData.init(allocator, td_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();
current_price = q.close();
} else |_| {}
} else |_| {}
}
if (svc.getQuote(symbol)) |q| {
current_price = q.close;
} else |_| {}
try display(result.data, symbol, current_price, color, out);
}

View file

@ -6,13 +6,7 @@ const cli = @import("common.zig");
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,
/// and outputs a metadata SRF file to stdout.
/// If the argument looks like a symbol (no path separators, no .srf extension), enrich just that symbol.
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, arg: []const u8, out: *std.Io.Writer) !void {
// Check for Alpha Vantage API key
const av_key = config.alphavantage_key orelse {
try cli.stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
return;
};
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, arg: []const u8, out: *std.Io.Writer) !void {
// Determine if arg is a symbol or a file path
const is_file = std.mem.endsWith(u8, arg, ".srf") or
std.mem.indexOfScalar(u8, arg, '/') != null or
@ -20,27 +14,27 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, arg: []const u8, o
if (!is_file) {
// Single symbol mode: enrich one symbol, output appendable SRF (no header)
try enrichSymbol(allocator, av_key, arg, out);
try enrichSymbol(allocator, svc, arg, out);
return;
}
// Portfolio file mode: enrich all symbols
try enrichPortfolio(allocator, av_key, arg, out);
try enrichPortfolio(allocator, svc, arg, out);
}
/// Enrich a single symbol and output appendable SRF lines to stdout.
fn enrichSymbol(allocator: std.mem.Allocator, av_key: []const u8, sym: []const u8, out: *std.Io.Writer) !void {
const AV = zfin.AlphaVantage;
var av = AV.init(allocator, av_key);
defer av.deinit();
fn enrichSymbol(allocator: std.mem.Allocator, svc: *zfin.DataService, sym: []const u8, out: *std.Io.Writer) !void {
{
var msg_buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n";
try cli.stderrPrint(msg);
}
const overview = av.fetchCompanyOverview(allocator, sym) catch {
const overview = svc.getCompanyOverview(sym) catch |err| {
if (err == zfin.DataError.NoApiKey) {
try cli.stderrPrint("Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n");
return;
}
try cli.stderrPrint("Error: Failed to fetch data for symbol\n");
try out.print("# {s} -- fetch failed\n", .{sym});
try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym});
@ -84,8 +78,7 @@ fn enrichSymbol(allocator: std.mem.Allocator, av_key: []const u8, sym: []const u
}
/// Enrich all symbols from a portfolio file.
fn enrichPortfolio(allocator: std.mem.Allocator, av_key: []const u8, file_path: []const u8, out: *std.Io.Writer) !void {
const AV = zfin.AlphaVantage;
fn enrichPortfolio(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, out: *std.Io.Writer) !void {
// Load portfolio
const file_data = std.fs.cwd().readFileAlloc(allocator, file_path, 10 * 1024 * 1024) catch {
@ -108,9 +101,6 @@ fn enrichPortfolio(allocator: std.mem.Allocator, av_key: []const u8, file_path:
const syms = try portfolio.stockSymbols(allocator);
defer allocator.free(syms);
var av = AV.init(allocator, av_key);
defer av.deinit();
try out.print("#!srfv1\n", .{});
try out.print("# Portfolio classification metadata\n", .{});
try out.print("# Generated from Alpha Vantage OVERVIEW data\n", .{});
@ -152,7 +142,7 @@ fn enrichPortfolio(allocator: std.mem.Allocator, av_key: []const u8, file_path:
try cli.stderrPrint(msg);
}
const overview = av.fetchCompanyOverview(allocator, sym) catch {
const overview = svc.getCompanyOverview(sym) catch {
try out.print("# {s} -- fetch failed\n", .{sym});
try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n\n", .{sym});
failed += 1;

View file

@ -12,7 +12,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const
try cli.stderrPrint("Looking up via OpenFIGI...\n");
// Try full batch lookup for richer output
const results = zfin.OpenFigi.lookupCusips(allocator, &.{cusip}, svc.config.openfigi_key) catch {
const results = svc.lookupCusips(&.{cusip}) catch {
try cli.stderrPrint("Error: OpenFIGI request failed (network error)\n");
return;
};
@ -38,7 +38,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const
}
}
pub fn display(result: zfin.OpenFigi.FigiResult, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
pub fn display(result: zfin.CusipResult, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
if (result.ticker) |ticker| {
try cli.setBold(out, color);
try out.print("{s}", .{cusip});
@ -78,7 +78,7 @@ pub fn display(result: zfin.OpenFigi.FigiResult, cusip: []const u8, color: bool,
test "display shows ticker mapping" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const result: zfin.OpenFigi.FigiResult = .{
const result: zfin.CusipResult = .{
.ticker = "AAPL",
.name = "Apple Inc",
.security_type = "Common Stock",
@ -96,7 +96,7 @@ test "display shows ticker mapping" {
test "display shows no-ticker message" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const result: zfin.OpenFigi.FigiResult = .{
const result: zfin.CusipResult = .{
.ticker = null,
.name = "Some Fund",
.security_type = null,
@ -112,7 +112,7 @@ test "display shows no-ticker message" {
test "display no ANSI without color" {
var buf: [4096]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
const result: zfin.OpenFigi.FigiResult = .{
const result: zfin.CusipResult = .{
.ticker = "MSFT",
.name = null,
.security_type = null,

View file

@ -14,7 +14,7 @@ pub const QuoteData = struct {
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 {
pub fn run(allocator: std.mem.Allocator, 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 => {
@ -29,30 +29,19 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataSer
defer allocator.free(candle_result.data);
const candles = candle_result.data;
// Fetch real-time quote
// Fetch real-time quote via DataService
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 |_| {}
}
if (svc.getQuote(symbol)) |q| {
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 |_| {}
try display(allocator, candles, quote, symbol, color, out);
}

View file

@ -114,7 +114,7 @@ pub fn main() !u8 {
try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
return 1;
}
try commands.quote.run(allocator, config, &svc, args[2], color, out);
try commands.quote.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "history")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
@ -126,7 +126,7 @@ pub fn main() !u8 {
try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
return 1;
}
try commands.divs.run(allocator, &svc, config, args[2], color, out);
try commands.divs.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "splits")) {
if (args.len < 3) {
try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
@ -196,7 +196,7 @@ pub fn main() !u8 {
try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
return 1;
}
try commands.enrich.run(allocator, config, args[2], out);
try commands.enrich.run(allocator, &svc, args[2], out);
} else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf";

View file

@ -3,62 +3,108 @@
//! Fetches, caches, and analyzes US equity/ETF financial data from
//! multiple free-tier API providers (Twelve Data, Polygon, Finnhub,
//! Alpha Vantage). Includes Morningstar-style performance calculations.
//!
//! ## Getting Started
//!
//! Most consumers should start with `DataService`, which orchestrates
//! fetching, caching, and provider selection. Pair it with a `Config`
//! (populated from environment variables / .env) to get going:
//!
//! ```
//! const config = try zfin.Config.load(allocator);
//! var svc = zfin.DataService.init(allocator, config);
//! const result = try svc.getCandles("AAPL");
//! ```
//!
//! For portfolio workflows, load a Portfolio from an SRF file and pass
//! it through `risk` and `performance` for analytics.
//!
//! The `format` module contains shared rendering helpers used by both
//! the CLI commands and TUI.
// -- Data models --
// Data Models
/// Calendar date with financial-market helpers (trading days, expiry rules).
pub const Date = @import("models/date.zig").Date;
/// OHLCV price bar (open, high, low, close, volume) for a single trading day.
pub const Candle = @import("models/candle.zig").Candle;
/// Cash dividend payment with ex-date, pay-date, and amount.
pub const Dividend = @import("models/dividend.zig").Dividend;
pub const DividendType = @import("models/dividend.zig").DividendType;
/// Stock split event (ratio + effective date).
pub const Split = @import("models/split.zig").Split;
/// Single options contract (strike, expiry, greeks, bid/ask).
pub const OptionContract = @import("models/option.zig").OptionContract;
/// Full options chain for a symbol (calls + puts grouped by expiry).
pub const OptionsChain = @import("models/option.zig").OptionsChain;
pub const ContractType = @import("models/option.zig").ContractType;
/// Quarterly earnings event with EPS estimate, actual, and surprise.
pub const EarningsEvent = @import("models/earnings.zig").EarningsEvent;
pub const ReportTime = @import("models/earnings.zig").ReportTime;
/// ETF profile: expense ratio, AUM, top holdings, sector weights.
pub const EtfProfile = @import("models/etf_profile.zig").EtfProfile;
pub const Holding = @import("models/etf_profile.zig").Holding;
pub const SectorWeight = @import("models/etf_profile.zig").SectorWeight;
pub const TickerInfo = @import("models/ticker_info.zig").TickerInfo;
pub const SecurityType = @import("models/ticker_info.zig").SecurityType;
/// A single position lot (shares, cost basis, purchase date, type).
pub const Lot = @import("models/portfolio.zig").Lot;
pub const LotType = @import("models/portfolio.zig").LotType;
/// Aggregated position: symbol + all its lots.
pub const Position = @import("models/portfolio.zig").Position;
/// Parsed portfolio: positions, cash, CDs, options, metadata.
pub const Portfolio = @import("models/portfolio.zig").Portfolio;
/// Real-time or delayed price quote (last, bid, ask, volume).
pub const Quote = @import("models/quote.zig").Quote;
// -- Infrastructure --
pub const Config = @import("config.zig").Config;
pub const RateLimiter = @import("net/rate_limiter.zig").RateLimiter;
pub const http = @import("net/http.zig");
// Infrastructure
// -- Cache --
/// Runtime configuration loaded from environment / .env file (API keys, paths).
pub const Config = @import("config.zig").Config;
// Cache
/// SRF-backed file cache for candles, dividends, earnings, and other fetched data.
pub const cache = @import("cache/store.zig");
// -- Analytics --
// Analytics
/// Morningstar-style holding-period return calculations (total + annualized).
pub const performance = @import("analytics/performance.zig");
/// Portfolio risk metrics: sector weights, fallback prices, DRIP aggregation.
pub const risk = @import("analytics/risk.zig");
/// Technical indicators: SMA, EMA, Bollinger Bands, RSI, MACD.
pub const indicators = @import("analytics/indicators.zig");
/// Fundamental analysis: valuation, momentum, quality, and yield scoring.
pub const analysis = @import("analytics/analysis.zig");
// -- Classification --
// Classification
/// Sector/industry/country classification for enriched securities.
pub const classification = @import("models/classification.zig");
// -- Formatting (shared between CLI and TUI) --
// Formatting
/// Shared rendering helpers (money formatting, charts, earnings rows, bars)
/// used by both CLI commands and TUI.
pub const format = @import("format.zig");
// -- Service layer --
// Service Layer
/// High-level data service: orchestrates providers, caching, and fallback logic.
pub const DataService = @import("service.zig").DataService;
/// Errors returned by DataService (NoApiKey, RateLimited, etc.).
pub const DataError = @import("service.zig").DataError;
pub const DataSource = @import("service.zig").Source;
// -- Providers --
pub const Provider = @import("providers/provider.zig").Provider;
pub const TwelveData = @import("providers/twelvedata.zig").TwelveData;
pub const Polygon = @import("providers/polygon.zig").Polygon;
pub const Finnhub = @import("providers/finnhub.zig").Finnhub;
pub const Cboe = @import("providers/cboe.zig").Cboe;
pub const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
pub const OpenFigi = @import("providers/openfigi.zig");
/// Company overview data (sector, industry, country, market cap) from Alpha Vantage.
pub const CompanyOverview = @import("service.zig").CompanyOverview;
// -- Re-export SRF for portfolio file loading --
pub const srf = @import("srf");
/// Result of a CUSIP-to-ticker lookup (ticker, name, security type).
pub const CusipResult = @import("service.zig").CusipResult;

View file

@ -23,6 +23,7 @@ const Polygon = @import("providers/polygon.zig").Polygon;
const Finnhub = @import("providers/finnhub.zig").Finnhub;
const Cboe = @import("providers/cboe.zig").Cboe;
const AlphaVantage = @import("providers/alphavantage.zig").AlphaVantage;
const alphavantage = @import("providers/alphavantage.zig");
const OpenFigi = @import("providers/openfigi.zig");
const performance = @import("analytics/performance.zig");
@ -34,6 +35,12 @@ pub const DataError = error{
OutOfMemory,
};
/// Re-exported provider types needed by commands via DataService.
pub const CompanyOverview = alphavantage.CompanyOverview;
/// Result of a CUSIP-to-ticker lookup (provider-agnostic).
pub const CusipResult = OpenFigi.FigiResult;
/// Indicates whether the returned data came from cache or was freshly fetched.
pub const Source = enum {
cached,
@ -333,6 +340,14 @@ pub const DataService = struct {
};
}
/// Fetch company overview (sector, industry, country, market cap) from Alpha Vantage.
/// No cache -- always fetches fresh. Caller must free the returned string fields.
pub fn getCompanyOverview(self: *DataService, symbol: []const u8) DataError!CompanyOverview {
var av = try self.getAlphaVantage();
return av.fetchCompanyOverview(self.allocator, symbol) catch
return DataError.FetchFailed;
}
/// Compute trailing returns for a symbol (fetches candles + dividends).
/// Returns both as-of-date and month-end trailing returns.
/// As-of-date: end = latest close. Matches Morningstar "Trailing Returns" page.
@ -565,6 +580,14 @@ pub const DataService = struct {
// CUSIP Resolution
/// Look up multiple CUSIPs in a single batch request via OpenFIGI.
/// Results array is parallel to the input cusips array (same length, same order).
/// Caller owns the returned slice and all strings within each CusipResult.
pub fn lookupCusips(self: *DataService, cusips: []const []const u8) DataError![]CusipResult {
return OpenFigi.lookupCusips(self.allocator, cusips, self.config.openfigi_key) catch
return DataError.FetchFailed;
}
/// Look up a CUSIP via OpenFIGI API. Returns the ticker if found, null otherwise.
/// Results are cached in {cache_dir}/cusip_tickers.srf.
/// Caller owns the returned string.