diff --git a/src/commands/divs.zig b/src/commands/divs.zig index 8a40597..26fba01 100644 --- a/src/commands/divs.zig +++ b/src/commands/divs.zig @@ -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); } diff --git a/src/commands/enrich.zig b/src/commands/enrich.zig index d71d7aa..f2910a0 100644 --- a/src/commands/enrich.zig +++ b/src/commands/enrich.zig @@ -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; diff --git a/src/commands/lookup.zig b/src/commands/lookup.zig index e46041f..7b9e18d 100644 --- a/src/commands/lookup.zig +++ b/src/commands/lookup.zig @@ -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, diff --git a/src/commands/quote.zig b/src/commands/quote.zig index 14d1f50..93015c5 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -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); } diff --git a/src/main.zig b/src/main.zig index 0bc30da..1976e6c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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"; diff --git a/src/root.zig b/src/root.zig index d650cb9..8c7828d 100644 --- a/src/root.zig +++ b/src/root.zig @@ -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; diff --git a/src/service.zig b/src/service.zig index cc46cf0..d761f37 100644 --- a/src/service.zig +++ b/src/service.zig @@ -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.