feat: implement idx-cli MVP foundation, provider abstraction, and core quote/history commands

This commit is contained in:
Ciphercat 2026-03-05 17:54:52 +00:00
commit 2174b42cff
18 changed files with 2678 additions and 0 deletions

85
src/api/mod.rs Normal file
View file

@ -0,0 +1,85 @@
pub mod types;
pub mod yahoo;
use crate::error::IdxError;
use types::{Interval, Ohlc, Period, Quote};
pub trait MarketDataProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError>;
fn history(
&self,
symbol: &str,
period: &Period,
interval: &Interval,
) -> Result<Vec<Ohlc>, IdxError>;
}
pub fn resolve_symbol(symbol: &str, exchange: &str) -> String {
let trimmed = symbol.trim().to_uppercase();
if let Some((base, suffix)) = trimmed.rsplit_once('.')
&& !base.is_empty()
&& !suffix.is_empty()
{
return trimmed;
}
format!("{trimmed}.{}", exchange.trim().to_uppercase())
}
pub fn default_provider() -> Box<dyn MarketDataProvider> {
if std::env::var("IDX_USE_MOCK_PROVIDER").is_ok() {
Box::new(MockProvider)
} else {
Box::new(yahoo::YahooProvider::new())
}
}
struct MockProvider;
impl MarketDataProvider for MockProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
Ok(Quote {
symbol: symbol.to_string(),
price: 9875.0,
change: 117.0,
change_pct: 1.2,
volume: 12_300_000,
market_cap: Some(1_215_200_000_000_000.0),
week52_high: Some(10_250.0),
week52_low: Some(7_800.0),
week52_position: Some(0.73),
range_signal: Some("upper".to_string()),
prev_close: Some(9_758.0),
avg_volume: Some(10_000_000),
})
}
fn history(
&self,
_symbol: &str,
_period: &Period,
_interval: &Interval,
) -> Result<Vec<Ohlc>, IdxError> {
Ok(vec![Ohlc {
date: chrono::NaiveDate::from_ymd_opt(2026, 3, 1).expect("valid date"),
open: 9800.0,
high: 9900.0,
low: 9750.0,
close: 9875.0,
volume: 12_300_000,
}])
}
}
#[cfg(test)]
mod tests {
use super::resolve_symbol;
#[test]
fn resolves_symbol_variants() {
assert_eq!(resolve_symbol("bbca", "JK"), "BBCA.JK");
assert_eq!(resolve_symbol("BBCA.JK", "JK"), "BBCA.JK");
assert_eq!(resolve_symbol("TLKM.us", "JK"), "TLKM.US");
assert_eq!(resolve_symbol("abcd.ef.gh", "JK"), "ABCD.EF.GH");
assert_eq!(resolve_symbol(" bbri ", "jk"), "BBRI.JK");
}
}

84
src/api/types.rs Normal file
View file

@ -0,0 +1,84 @@
use chrono::NaiveDate;
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Quote {
pub symbol: String,
pub price: f64,
pub change: f64,
pub change_pct: f64,
pub volume: u64,
pub market_cap: Option<f64>,
pub week52_high: Option<f64>,
pub week52_low: Option<f64>,
pub week52_position: Option<f64>,
pub range_signal: Option<String>,
pub prev_close: Option<f64>,
pub avg_volume: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ohlc {
pub date: NaiveDate,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ValueEnum)]
pub enum Period {
#[value(name = "1d")]
OneDay,
#[value(name = "5d")]
FiveDays,
#[value(name = "1mo")]
OneMonth,
#[value(name = "3mo")]
ThreeMonths,
#[value(name = "6mo")]
SixMonths,
#[value(name = "1y")]
OneYear,
#[value(name = "2y")]
TwoYears,
#[value(name = "5y")]
FiveYears,
}
impl Period {
pub fn as_str(&self) -> &'static str {
match self {
Self::OneDay => "1d",
Self::FiveDays => "5d",
Self::OneMonth => "1mo",
Self::ThreeMonths => "3mo",
Self::SixMonths => "6mo",
Self::OneYear => "1y",
Self::TwoYears => "2y",
Self::FiveYears => "5y",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ValueEnum)]
pub enum Interval {
#[value(name = "1d")]
Day,
#[value(name = "1wk")]
Week,
#[value(name = "1mo")]
Month,
}
impl Interval {
pub fn as_str(&self) -> &'static str {
match self {
Self::Day => "1d",
Self::Week => "1wk",
Self::Month => "1mo",
}
}
}

259
src/api/yahoo.rs Normal file
View file

@ -0,0 +1,259 @@
use std::time::Duration;
use serde::Deserialize;
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";
pub struct YahooProvider {
agent: ureq::Agent,
}
impl YahooProvider {
pub fn new() -> Self {
Self {
agent: ureq::Agent::new_with_defaults(),
}
}
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 {
format!(
"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range={}&interval={}&crumb={crumb}",
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);
let response = self
.agent
.get(&url)
.header("User-Agent", USER_AGENT)
.call();
match response {
Ok(ok) => {
return ok
.into_body()
.read_json::<ChartResponse>()
.map_err(|e| IdxError::ParseError(e.to_string()));
}
Err(ureq::Error::StatusCode(429)) => {
std::thread::sleep(wait + jitter());
wait *= 2;
}
Err(e) => return Err(IdxError::Http(e.to_string())),
}
}
Err(IdxError::RateLimited)
}
}
fn jitter() -> Duration {
let millis = (std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_millis() % 100)
.unwrap_or(42)) as u64;
Duration::from_millis(millis)
}
impl MarketDataProvider for YahooProvider {
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
let chart = self.fetch_chart(symbol, &Period::OneDay, &Interval::Day)?;
parse_quote(symbol, &chart)
}
fn history(&self, symbol: &str, period: &Period, interval: &Interval) -> Result<Vec<Ohlc>, IdxError> {
let chart = self.fetch_chart(symbol, period, interval)?;
parse_history(&chart)
}
}
fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
let result = chart
.chart
.result
.as_ref()
.and_then(|r| r.first())
.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 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 });
let (week52_position, range_signal) = match (meta.fifty_two_week_low, meta.fifty_two_week_high) {
(Some(low), Some(high)) if high > low => {
let pos = (price - low) / (high - low);
let signal = if pos > 0.66 {
"upper"
} else if pos < 0.33 {
"lower"
} else {
"middle"
};
(Some(pos), Some(signal.to_string()))
}
_ => (None, None),
};
Ok(Quote {
symbol: meta.symbol.clone().unwrap_or_else(|| symbol.to_string()),
price,
change,
change_pct,
volume: meta.regular_market_volume.unwrap_or(0),
market_cap: meta.market_cap,
week52_high: meta.fifty_two_week_high,
week52_low: meta.fifty_two_week_low,
week52_position,
range_signal,
prev_close,
avg_volume: meta.average_daily_volume_3month,
})
}
fn parse_history(chart: &ChartResponse) -> Result<Vec<Ohlc>, IdxError> {
let result = chart
.chart
.result
.as_ref()
.and_then(|r| r.first())
.ok_or(IdxError::ProviderUnavailable)?;
let timestamps = result.timestamp.as_ref().ok_or(IdxError::ProviderUnavailable)?;
let quote = result
.indicators
.as_ref()
.and_then(|i| i.quote.as_ref())
.and_then(|q| q.first())
.ok_or(IdxError::ProviderUnavailable)?;
let mut out = Vec::new();
for (i, ts) in timestamps.iter().enumerate() {
let open = quote.open.as_ref().and_then(|v| v.get(i).copied().flatten());
let high = quote.high.as_ref().and_then(|v| v.get(i).copied().flatten());
let low = quote.low.as_ref().and_then(|v| v.get(i).copied().flatten());
let close = quote.close.as_ref().and_then(|v| v.get(i).copied().flatten());
let volume = quote.volume.as_ref().and_then(|v| v.get(i).copied().flatten());
if let (Some(open), Some(high), Some(low), Some(close), Some(volume)) =
(open, high, low, close, volume)
&& let Some(dt) = chrono::DateTime::from_timestamp(*ts, 0)
{
out.push(Ohlc {
date: dt.date_naive(),
open,
high,
low,
close,
volume,
});
}
}
Ok(out)
}
#[derive(Debug, Deserialize)]
struct ChartResponse {
chart: ChartRoot,
}
#[derive(Debug, Deserialize)]
struct ChartRoot {
result: Option<Vec<ChartResult>>,
}
#[derive(Debug, Deserialize)]
struct ChartResult {
meta: Option<ChartMeta>,
timestamp: Option<Vec<i64>>,
indicators: Option<Indicators>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChartMeta {
symbol: Option<String>,
regular_market_price: Option<f64>,
previous_close: Option<f64>,
regular_market_volume: Option<u64>,
market_cap: Option<f64>,
fifty_two_week_high: Option<f64>,
fifty_two_week_low: Option<f64>,
average_daily_volume_3month: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct Indicators {
quote: Option<Vec<IndicatorQuote>>,
}
#[derive(Debug, Deserialize)]
struct IndicatorQuote {
open: Option<Vec<Option<f64>>>,
high: Option<Vec<Option<f64>>>,
low: Option<Vec<Option<f64>>>,
close: Option<Vec<Option<f64>>>,
volume: Option<Vec<Option<u64>>>,
}
#[cfg(test)]
mod tests {
use super::{parse_history, parse_quote, ChartResponse};
const SAMPLE: &str = r#"{
"chart": {
"result": [{
"meta": {
"symbol": "BBCA.JK",
"regularMarketPrice": 9875.0,
"previousClose": 9758.0,
"regularMarketVolume": 12300000,
"marketCap": 1215200000000000,
"fiftyTwoWeekHigh": 10250.0,
"fiftyTwoWeekLow": 7800.0,
"averageDailyVolume3Month": 10000000
},
"timestamp": [1709251200,1709337600],
"indicators": {"quote":[{
"open":[9800.0,9850.0],
"high":[9900.0,9900.0],
"low":[9750.0,9800.0],
"close":[9875.0,9880.0],
"volume":[12300000,11000000]
}]}
}]
}
}"#;
#[test]
fn parses_quote_and_history() {
let chart: ChartResponse = serde_json::from_str(SAMPLE).expect("valid chart fixture");
let quote = parse_quote("BBCA.JK", &chart).expect("quote parsed");
assert_eq!(quote.symbol, "BBCA.JK");
assert_eq!(quote.price, 9875.0);
let history = parse_history(&chart).expect("history parsed");
assert_eq!(history.len(), 2);
assert_eq!(history[0].close, 9875.0);
}
}