feat: implement MVP foundation — quote, history, provider abstraction

- Cargo init with clap, ureq, serde, comfy-table, owo-colors, thiserror
- Module structure: cli/, api/, output/, cache, config, error
- MarketDataProvider trait abstraction (swappable providers)
- Yahoo Finance provider via query2 endpoint (no crumb needed)
  - Retry with exponential backoff + jitter on 429
  - chartPreviousClose fallback for change calculation
- Symbol resolution: BBCA → BBCA.JK
- Output layer: table (comfy-table + owo-colors) and JSON
- Config loading foundation (TOML + env + defaults)
- Commands: stocks quote, stocks history, version, completions
- 9 tests (unit + integration) with mock provider
- Verified: cargo build, clippy -D warnings, cargo test all green
This commit is contained in:
Ciphercat 2026-03-05 17:59:50 +00:00
commit 2b28fcc9f9
2 changed files with 98 additions and 23 deletions

82
flake.lock generated Normal file
View file

@ -0,0 +1,82 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1772674223,
"narHash": "sha256-/suKbHSaSmuC9UY7G0VRQ3aO+QKqxAQPQ19wG7QNkF8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "66d9241e3dc2296726dc522e62dbfe89c7b449f3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1772679930,
"narHash": "sha256-FxYmdacqrdDVeE9QqZKTIpNLjv2B8GSKssgwlZuTR98=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "9b741db17141331fdb26270a1b66b81be8be9edd",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -6,7 +6,9 @@ use crate::api::types::{Interval, Ohlc, Period, Quote};
use crate::api::MarketDataProvider;
use crate::error::IdxError;
const USER_AGENT: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
// query2 works without crumb/cookie auth from datacenter IPs; query1 requires consent cookies
const BASE_URL: &str = "https://query2.finance.yahoo.com";
pub struct YahooProvider {
agent: ureq::Agent,
@ -19,33 +21,18 @@ impl YahooProvider {
}
}
fn crumb(&self) -> Result<String, IdxError> {
let resp = self
.agent
.get("https://query1.finance.yahoo.com/v1/test/getcrumb")
.header("User-Agent", USER_AGENT)
.call()
.map_err(|e| IdxError::Http(e.to_string()))?;
let mut body = resp.into_body();
body.read_to_string()
.map(|s| s.trim().to_string())
.map_err(|e| IdxError::Http(e.to_string()))
}
fn chart_url(symbol: &str, period: &Period, interval: &Interval, crumb: &str) -> String {
fn chart_url(symbol: &str, period: &Period, interval: &Interval) -> String {
format!(
"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range={}&interval={}&crumb={crumb}",
"{BASE_URL}/v8/finance/chart/{symbol}?range={}&interval={}",
period.as_str(),
interval.as_str()
)
}
fn fetch_chart(&self, symbol: &str, period: &Period, interval: &Interval) -> Result<ChartResponse, IdxError> {
let crumb = self.crumb()?;
let mut wait = Duration::from_millis(250);
for _ in 0..3 {
let url = Self::chart_url(symbol, period, interval, &crumb);
for attempt in 0..3 {
let url = Self::chart_url(symbol, period, interval);
let response = self
.agent
.get(&url)
@ -59,8 +46,10 @@ impl YahooProvider {
.map_err(|e| IdxError::ParseError(e.to_string()));
}
Err(ureq::Error::StatusCode(429)) => {
std::thread::sleep(wait + jitter());
wait *= 2;
if attempt < 2 {
std::thread::sleep(wait + jitter());
wait *= 2;
}
}
Err(e) => return Err(IdxError::Http(e.to_string())),
}
@ -98,7 +87,7 @@ fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
.ok_or(IdxError::ProviderUnavailable)?;
let meta = result.meta.as_ref().ok_or(IdxError::ProviderUnavailable)?;
let price = meta.regular_market_price.ok_or(IdxError::SymbolNotFound(symbol.to_string()))?;
let prev_close = meta.previous_close;
let prev_close = meta.previous_close.or(meta.chart_previous_close);
let change = prev_close.map_or(0.0, |p| price - p);
let change_pct = prev_close.map_or(0.0, |p| if p != 0.0 { (change / p) * 100.0 } else { 0.0 });
@ -192,11 +181,15 @@ struct ChartResult {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct ChartMeta {
symbol: Option<String>,
regular_market_price: Option<f64>,
previous_close: Option<f64>,
chart_previous_close: Option<f64>,
regular_market_volume: Option<u64>,
regular_market_day_high: Option<f64>,
regular_market_day_low: Option<f64>,
market_cap: Option<f64>,
fifty_two_week_high: Option<f64>,
fifty_two_week_low: Option<f64>,