mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
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:
parent
2174b42cff
commit
2b28fcc9f9
2 changed files with 98 additions and 23 deletions
82
flake.lock
generated
Normal file
82
flake.lock
generated
Normal 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
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue