mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
style: cargo fmt
This commit is contained in:
parent
5da59c36c7
commit
dc1de5b344
9 changed files with 157 additions and 61 deletions
|
|
@ -2,8 +2,8 @@ use std::time::Duration;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::api::types::{Interval, Ohlc, Period, Quote};
|
|
||||||
use crate::api::MarketDataProvider;
|
use crate::api::MarketDataProvider;
|
||||||
|
use crate::api::types::{Interval, Ohlc, Period, Quote};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
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";
|
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";
|
||||||
|
|
@ -29,15 +29,16 @@ impl YahooProvider {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fetch_chart(&self, symbol: &str, period: &Period, interval: &Interval) -> Result<ChartResponse, IdxError> {
|
fn fetch_chart(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<ChartResponse, IdxError> {
|
||||||
let mut wait = Duration::from_millis(250);
|
let mut wait = Duration::from_millis(250);
|
||||||
for attempt in 0..3 {
|
for attempt in 0..3 {
|
||||||
let url = Self::chart_url(symbol, period, interval);
|
let url = Self::chart_url(symbol, period, interval);
|
||||||
let response = self
|
let response = self.agent.get(&url).header("User-Agent", USER_AGENT).call();
|
||||||
.agent
|
|
||||||
.get(&url)
|
|
||||||
.header("User-Agent", USER_AGENT)
|
|
||||||
.call();
|
|
||||||
match response {
|
match response {
|
||||||
Ok(ok) => {
|
Ok(ok) => {
|
||||||
return ok
|
return ok
|
||||||
|
|
@ -72,7 +73,12 @@ impl MarketDataProvider for YahooProvider {
|
||||||
parse_quote(symbol, &chart)
|
parse_quote(symbol, &chart)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn history(&self, symbol: &str, period: &Period, interval: &Interval) -> Result<Vec<Ohlc>, IdxError> {
|
fn history(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
let chart = self.fetch_chart(symbol, period, interval)?;
|
let chart = self.fetch_chart(symbol, period, interval)?;
|
||||||
parse_history(&chart)
|
parse_history(&chart)
|
||||||
}
|
}
|
||||||
|
|
@ -92,12 +98,15 @@ fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote, IdxError> {
|
||||||
.and_then(|r| r.first())
|
.and_then(|r| r.first())
|
||||||
.ok_or(IdxError::ProviderUnavailable)?;
|
.ok_or(IdxError::ProviderUnavailable)?;
|
||||||
let meta = result.meta.as_ref().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 price = meta
|
||||||
|
.regular_market_price
|
||||||
|
.ok_or(IdxError::SymbolNotFound(symbol.to_string()))?;
|
||||||
let prev_close = meta.previous_close.or(meta.chart_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 = 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 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) {
|
let (week52_position, range_signal) = match (meta.fifty_two_week_low, meta.fifty_two_week_high)
|
||||||
|
{
|
||||||
(Some(low), Some(high)) if high > low => {
|
(Some(low), Some(high)) if high > low => {
|
||||||
let pos = (price - low) / (high - low);
|
let pos = (price - low) / (high - low);
|
||||||
let signal = if pos > 0.66 {
|
let signal = if pos > 0.66 {
|
||||||
|
|
@ -141,7 +150,10 @@ fn parse_history(chart: &ChartResponse) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|r| r.first())
|
.and_then(|r| r.first())
|
||||||
.ok_or(IdxError::ProviderUnavailable)?;
|
.ok_or(IdxError::ProviderUnavailable)?;
|
||||||
let timestamps = result.timestamp.as_ref().ok_or(IdxError::ProviderUnavailable)?;
|
let timestamps = result
|
||||||
|
.timestamp
|
||||||
|
.as_ref()
|
||||||
|
.ok_or(IdxError::ProviderUnavailable)?;
|
||||||
let quote = result
|
let quote = result
|
||||||
.indicators
|
.indicators
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -151,11 +163,23 @@ fn parse_history(chart: &ChartResponse) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (i, ts) in timestamps.iter().enumerate() {
|
for (i, ts) in timestamps.iter().enumerate() {
|
||||||
let open = quote.open.as_ref().and_then(|v| v.get(i).copied().flatten());
|
let open = quote
|
||||||
let high = quote.high.as_ref().and_then(|v| v.get(i).copied().flatten());
|
.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 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 close = quote
|
||||||
let volume = quote.volume.as_ref().and_then(|v| v.get(i).copied().flatten());
|
.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)) =
|
if let (Some(open), Some(high), Some(low), Some(close), Some(volume)) =
|
||||||
(open, high, low, close, volume)
|
(open, high, low, close, volume)
|
||||||
|
|
@ -224,7 +248,9 @@ struct IndicatorQuote {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{parse_history, parse_history_from_str, parse_quote, parse_quote_from_str, ChartResponse};
|
use super::{
|
||||||
|
ChartResponse, parse_history, parse_history_from_str, parse_quote, parse_quote_from_str,
|
||||||
|
};
|
||||||
|
|
||||||
const SAMPLE: &str = r#"{
|
const SAMPLE: &str = r#"{
|
||||||
"chart": {
|
"chart": {
|
||||||
|
|
@ -264,8 +290,10 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_realistic_fixture_json() {
|
fn parses_realistic_fixture_json() {
|
||||||
let quote_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json").expect("fixture exists");
|
let quote_raw =
|
||||||
let history_raw = std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json").expect("fixture exists");
|
std::fs::read_to_string("tests/fixtures/chart_bbca_1d.json").expect("fixture exists");
|
||||||
|
let history_raw =
|
||||||
|
std::fs::read_to_string("tests/fixtures/chart_bbca_3mo.json").expect("fixture exists");
|
||||||
|
|
||||||
let quote = parse_quote_from_str("BBCA.JK", "e_raw).expect("fixture quote parsed");
|
let quote = parse_quote_from_str("BBCA.JK", "e_raw).expect("fixture quote parsed");
|
||||||
assert_eq!(quote.symbol, "BBCA.JK");
|
assert_eq!(quote.symbol, "BBCA.JK");
|
||||||
|
|
|
||||||
57
src/cache.rs
57
src/cache.rs
|
|
@ -43,23 +43,39 @@ impl Cache {
|
||||||
Self { root }
|
Self { root }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get<T: DeserializeOwned>(
|
||||||
pub fn get<T: DeserializeOwned>(&self, data_type: &str, symbol: &str) -> Result<Option<T>, IdxError> {
|
&self,
|
||||||
|
data_type: &str,
|
||||||
|
symbol: &str,
|
||||||
|
) -> Result<Option<T>, IdxError> {
|
||||||
let Some(entry): Option<CacheEntry<T>> = self.read_entry(data_type, symbol)? else {
|
let Some(entry): Option<CacheEntry<T>> = self.read_entry(data_type, symbol)? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let age = Utc::now().signed_duration_since(entry.fetched_at);
|
let age = Utc::now().signed_duration_since(entry.fetched_at);
|
||||||
if age > chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs)).map_err(|e| IdxError::CacheMiss(e.to_string()))? {
|
if age
|
||||||
|
> chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs))
|
||||||
|
.map_err(|e| IdxError::CacheMiss(e.to_string()))?
|
||||||
|
{
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Ok(Some(entry.data))
|
Ok(Some(entry.data))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_stale<T: DeserializeOwned>(&self, data_type: &str, symbol: &str) -> Result<Option<T>, IdxError> {
|
pub fn get_stale<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
data_type: &str,
|
||||||
|
symbol: &str,
|
||||||
|
) -> Result<Option<T>, IdxError> {
|
||||||
Ok(self.read_entry::<T>(data_type, symbol)?.map(|e| e.data))
|
Ok(self.read_entry::<T>(data_type, symbol)?.map(|e| e.data))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn put<T: Serialize>(&self, data_type: &str, symbol: &str, data: &T, ttl_secs: u64) -> Result<(), IdxError> {
|
pub fn put<T: Serialize>(
|
||||||
|
&self,
|
||||||
|
data_type: &str,
|
||||||
|
symbol: &str,
|
||||||
|
data: &T,
|
||||||
|
ttl_secs: u64,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
let path = self.entry_path(data_type, symbol);
|
let path = self.entry_path(data_type, symbol);
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| IdxError::Io(e.to_string()))?;
|
fs::create_dir_all(parent).map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
|
|
@ -70,7 +86,8 @@ impl Cache {
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
let raw = serde_json::to_string_pretty(&entry).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
let raw = serde_json::to_string_pretty(&entry)
|
||||||
|
.map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||||
fs::write(path, raw).map_err(|e| IdxError::Io(e.to_string()))
|
fs::write(path, raw).map_err(|e| IdxError::Io(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,7 +105,8 @@ impl Cache {
|
||||||
files += 1;
|
files += 1;
|
||||||
total_size += meta.len();
|
total_size += meta.len();
|
||||||
if let Ok(raw) = fs::read_to_string(p)
|
if let Ok(raw) = fs::read_to_string(p)
|
||||||
&& let Ok(entry) = serde_json::from_str::<CacheEntry<serde_json::Value>>(&raw)
|
&& let Ok(entry) =
|
||||||
|
serde_json::from_str::<CacheEntry<serde_json::Value>>(&raw)
|
||||||
{
|
{
|
||||||
oldest = Some(oldest.map_or(entry.fetched_at, |o| o.min(entry.fetched_at)));
|
oldest = Some(oldest.map_or(entry.fetched_at, |o| o.min(entry.fetched_at)));
|
||||||
newest = Some(newest.map_or(entry.fetched_at, |n| n.max(entry.fetched_at)));
|
newest = Some(newest.map_or(entry.fetched_at, |n| n.max(entry.fetched_at)));
|
||||||
|
|
@ -132,7 +150,11 @@ impl Cache {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_entry<T: DeserializeOwned>(&self, data_type: &str, symbol: &str) -> Result<Option<CacheEntry<T>>, IdxError> {
|
fn read_entry<T: DeserializeOwned>(
|
||||||
|
&self,
|
||||||
|
data_type: &str,
|
||||||
|
symbol: &str,
|
||||||
|
) -> Result<Option<CacheEntry<T>>, IdxError> {
|
||||||
let path = self.entry_path(data_type, symbol);
|
let path = self.entry_path(data_type, symbol);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
|
@ -178,20 +200,29 @@ mod tests {
|
||||||
let root = tmp();
|
let root = tmp();
|
||||||
let cache = Cache::with_root(root.clone());
|
let cache = Cache::with_root(root.clone());
|
||||||
|
|
||||||
cache.put("quote", "BBCA.JK", &T { v: 7 }, 300).expect("cache write");
|
cache
|
||||||
|
.put("quote", "BBCA.JK", &T { v: 7 }, 300)
|
||||||
|
.expect("cache write");
|
||||||
let fresh: Option<T> = cache.get("quote", "BBCA.JK").expect("cache read fresh");
|
let fresh: Option<T> = cache.get("quote", "BBCA.JK").expect("cache read fresh");
|
||||||
assert_eq!(fresh, Some(T { v: 7 }));
|
assert_eq!(fresh, Some(T { v: 7 }));
|
||||||
|
|
||||||
let path = root.join("quote/BBCA.JK.json");
|
let path = root.join("quote/BBCA.JK.json");
|
||||||
let mut entry: CacheEntry<T> = serde_json::from_str(&fs::read_to_string(&path).expect("read cache file"))
|
let mut entry: CacheEntry<T> =
|
||||||
.expect("parse cache entry");
|
serde_json::from_str(&fs::read_to_string(&path).expect("read cache file"))
|
||||||
|
.expect("parse cache entry");
|
||||||
entry.fetched_at = chrono::Utc::now() - chrono::Duration::seconds(1000);
|
entry.fetched_at = chrono::Utc::now() - chrono::Duration::seconds(1000);
|
||||||
fs::write(&path, serde_json::to_string(&entry).expect("serialize entry")).expect("write old entry");
|
fs::write(
|
||||||
|
&path,
|
||||||
|
serde_json::to_string(&entry).expect("serialize entry"),
|
||||||
|
)
|
||||||
|
.expect("write old entry");
|
||||||
|
|
||||||
let expired: Option<T> = cache.get("quote", "BBCA.JK").expect("cache read expired");
|
let expired: Option<T> = cache.get("quote", "BBCA.JK").expect("cache read expired");
|
||||||
assert_eq!(expired, None);
|
assert_eq!(expired, None);
|
||||||
|
|
||||||
let stale: Option<T> = cache.get_stale("quote", "BBCA.JK").expect("cache read stale");
|
let stale: Option<T> = cache
|
||||||
|
.get_stale("quote", "BBCA.JK")
|
||||||
|
.expect("cache read stale");
|
||||||
assert_eq!(stale, Some(T { v: 7 }));
|
assert_eq!(stale, Some(T { v: 7 }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use clap::{Args, Subcommand};
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
use crate::api::types::{Interval, Period};
|
|
||||||
use crate::api::MarketDataProvider;
|
use crate::api::MarketDataProvider;
|
||||||
|
use crate::api::types::{Interval, Period};
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
use crate::config::IdxConfig;
|
use crate::config::IdxConfig;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
@ -15,7 +15,9 @@ pub struct StocksCmd {
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
pub enum StocksSubcommand {
|
pub enum StocksSubcommand {
|
||||||
Quote { symbols: Vec<String> },
|
Quote {
|
||||||
|
symbols: Vec<String>,
|
||||||
|
},
|
||||||
History {
|
History {
|
||||||
symbol: String,
|
symbol: String,
|
||||||
#[arg(long, value_enum, default_value_t = Period::ThreeMonths)]
|
#[arg(long, value_enum, default_value_t = Period::ThreeMonths)]
|
||||||
|
|
@ -39,9 +41,7 @@ pub fn handle(
|
||||||
let mut quotes = Vec::new();
|
let mut quotes = Vec::new();
|
||||||
for sym in symbols.iter().flat_map(|s| s.split(',')) {
|
for sym in symbols.iter().flat_map(|s| s.split(',')) {
|
||||||
let resolved = crate::api::resolve_symbol(sym, &config.exchange);
|
let resolved = crate::api::resolve_symbol(sym, &config.exchange);
|
||||||
if !no_cache
|
if !no_cache && let Some(q) = cache.get("quote", &resolved)? {
|
||||||
&& let Some(q) = cache.get("quote", &resolved)?
|
|
||||||
{
|
|
||||||
quotes.push(q);
|
quotes.push(q);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -61,10 +61,10 @@ pub fn handle(
|
||||||
quotes.push(q);
|
quotes.push(q);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if !no_cache
|
if !no_cache && let Some(stale) = cache.get_stale("quote", &resolved)? {
|
||||||
&& let Some(stale) = cache.get_stale("quote", &resolved)?
|
eprintln!(
|
||||||
{
|
"warning: network failed, serving stale cache for {resolved}"
|
||||||
eprintln!("warning: network failed, serving stale cache for {resolved}");
|
);
|
||||||
quotes.push(stale);
|
quotes.push(stale);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -82,13 +82,17 @@ pub fn handle(
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||||
let key = format!("{}-{}", period.as_str(), interval.as_str());
|
let key = format!("{}-{}", period.as_str(), interval.as_str());
|
||||||
if !no_cache
|
if !no_cache
|
||||||
&& let Some(history) = cache.get::<Vec<crate::api::types::Ohlc>>("history", &format!("{resolved}-{key}"))?
|
&& let Some(history) = cache
|
||||||
|
.get::<Vec<crate::api::types::Ohlc>>("history", &format!("{resolved}-{key}"))?
|
||||||
{
|
{
|
||||||
return render_history(&resolved, &history, &config.output);
|
return render_history(&resolved, &history, &config.output);
|
||||||
}
|
}
|
||||||
if offline {
|
if offline {
|
||||||
let stale = cache
|
let stale = cache
|
||||||
.get_stale::<Vec<crate::api::types::Ohlc>>("history", &format!("{resolved}-{key}"))?
|
.get_stale::<Vec<crate::api::types::Ohlc>>(
|
||||||
|
"history",
|
||||||
|
&format!("{resolved}-{key}"),
|
||||||
|
)?
|
||||||
.ok_or_else(|| IdxError::CacheMiss(format!("history/{resolved}-{key}")))?;
|
.ok_or_else(|| IdxError::CacheMiss(format!("history/{resolved}-{key}")))?;
|
||||||
return render_history(&resolved, &stale, &config.output);
|
return render_history(&resolved, &stale, &config.output);
|
||||||
}
|
}
|
||||||
|
|
@ -96,13 +100,21 @@ pub fn handle(
|
||||||
match provider.history(&resolved, period, interval) {
|
match provider.history(&resolved, period, interval) {
|
||||||
Ok(history) => {
|
Ok(history) => {
|
||||||
if !no_cache {
|
if !no_cache {
|
||||||
cache.put("history", &format!("{resolved}-{key}"), &history, config.quote_ttl)?;
|
cache.put(
|
||||||
|
"history",
|
||||||
|
&format!("{resolved}-{key}"),
|
||||||
|
&history,
|
||||||
|
config.quote_ttl,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
render_history(&resolved, &history, &config.output)
|
render_history(&resolved, &history, &config.output)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if !no_cache
|
if !no_cache
|
||||||
&& let Some(stale) = cache.get_stale::<Vec<crate::api::types::Ohlc>>("history", &format!("{resolved}-{key}"))?
|
&& let Some(stale) = cache.get_stale::<Vec<crate::api::types::Ohlc>>(
|
||||||
|
"history",
|
||||||
|
&format!("{resolved}-{key}"),
|
||||||
|
)?
|
||||||
{
|
{
|
||||||
eprintln!("warning: network failed, serving stale cache for {resolved}");
|
eprintln!("warning: network failed, serving stale cache for {resolved}");
|
||||||
return render_history(&resolved, &stale, &config.output);
|
return render_history(&resolved, &stale, &config.output);
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,8 @@ impl IdxConfig {
|
||||||
let path = config_path()?;
|
let path = config_path()?;
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
|
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
let parsed: FileConfig = toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
|
let parsed: FileConfig =
|
||||||
|
toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
|
||||||
if let Some(general) = parsed.general {
|
if let Some(general) = parsed.general {
|
||||||
if let Some(exchange) = general.exchange {
|
if let Some(exchange) = general.exchange {
|
||||||
cfg.exchange = exchange;
|
cfg.exchange = exchange;
|
||||||
|
|
@ -142,7 +143,8 @@ pub fn get_config_value(key: &str) -> Result<Option<String>, IdxError> {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let raw = fs::read_to_string(path).map_err(|e| IdxError::Io(e.to_string()))?;
|
let raw = fs::read_to_string(path).map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
let value: toml::Value = toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
|
let value: toml::Value =
|
||||||
|
toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
|
||||||
|
|
||||||
let mut cur = &value;
|
let mut cur = &value;
|
||||||
for part in key.split('.') {
|
for part in key.split('.') {
|
||||||
|
|
@ -157,7 +159,8 @@ pub fn get_config_value(key: &str) -> Result<Option<String>, IdxError> {
|
||||||
pub fn set_config_value(key: &str, value: &str) -> Result<(), IdxError> {
|
pub fn set_config_value(key: &str, value: &str) -> Result<(), IdxError> {
|
||||||
let path = ensure_default_config()?;
|
let path = ensure_default_config()?;
|
||||||
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
|
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
let mut root: toml::Value = toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
|
let mut root: toml::Value =
|
||||||
|
toml::from_str(&raw).map_err(|e| IdxError::ConfigError(e.to_string()))?;
|
||||||
|
|
||||||
let mut parts = key.split('.').peekable();
|
let mut parts = key.split('.').peekable();
|
||||||
let mut current = root
|
let mut current = root
|
||||||
|
|
@ -180,8 +183,11 @@ pub fn set_config_value(key: &str, value: &str) -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::write(&path, toml::to_string_pretty(&root).map_err(|e| IdxError::ConfigError(e.to_string()))?)
|
fs::write(
|
||||||
.map_err(|e| IdxError::Io(e.to_string()))
|
&path,
|
||||||
|
toml::to_string_pretty(&root).map_err(|e| IdxError::ConfigError(e.to_string()))?,
|
||||||
|
)
|
||||||
|
.map_err(|e| IdxError::Io(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_toml_value(value: &str) -> toml::Value {
|
fn parse_toml_value(value: &str) -> toml::Value {
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,13 @@ fn run() -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
Commands::Stocks(stocks) => {
|
Commands::Stocks(stocks) => {
|
||||||
let provider = default_provider();
|
let provider = default_provider();
|
||||||
if let Err(err) = cli::stocks::handle(stocks, &config, provider.as_ref(), cli.offline, cli.no_cache) {
|
if let Err(err) = cli::stocks::handle(
|
||||||
|
stocks,
|
||||||
|
&config,
|
||||||
|
provider.as_ref(),
|
||||||
|
cli.offline,
|
||||||
|
cli.no_cache,
|
||||||
|
) {
|
||||||
emit_error(&err, &config.output);
|
emit_error(&err, &config.output);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ use serde::Serialize;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
||||||
pub fn print_json<T: Serialize + ?Sized>(value: &T) -> Result<(), IdxError> {
|
pub fn print_json<T: Serialize + ?Sized>(value: &T) -> Result<(), IdxError> {
|
||||||
let out = serde_json::to_string_pretty(value).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
let out =
|
||||||
|
serde_json::to_string_pretty(value).map_err(|e| IdxError::ParseError(e.to_string()))?;
|
||||||
println!("{out}");
|
println!("{out}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,22 @@ pub enum OutputFormat {
|
||||||
Json,
|
Json,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_quotes(quotes: &[Quote], format: &OutputFormat, no_color: bool) -> Result<(), IdxError> {
|
pub fn render_quotes(
|
||||||
|
quotes: &[Quote],
|
||||||
|
format: &OutputFormat,
|
||||||
|
no_color: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
match format {
|
match format {
|
||||||
OutputFormat::Table => table::print_quotes(quotes, no_color),
|
OutputFormat::Table => table::print_quotes(quotes, no_color),
|
||||||
OutputFormat::Json => json::print_json(quotes),
|
OutputFormat::Json => json::print_json(quotes),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_history(symbol: &str, history: &[Ohlc], format: &OutputFormat) -> Result<(), IdxError> {
|
pub fn render_history(
|
||||||
|
symbol: &str,
|
||||||
|
history: &[Ohlc],
|
||||||
|
format: &OutputFormat,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
match format {
|
match format {
|
||||||
OutputFormat::Table => table::print_history(symbol, history),
|
OutputFormat::Table => table::print_history(symbol, history),
|
||||||
OutputFormat::Json => json::print_json(history),
|
OutputFormat::Json => json::print_json(history),
|
||||||
|
|
@ -38,7 +46,10 @@ pub fn emit_error(err: &IdxError, format: &OutputFormat) {
|
||||||
"code": format!("{:?}", err.code()).to_uppercase(),
|
"code": format!("{:?}", err.code()).to_uppercase(),
|
||||||
"message": err.to_string()
|
"message": err.to_string()
|
||||||
});
|
});
|
||||||
eprintln!("{}", serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()));
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
|
use comfy_table::{Cell, Color, ContentArrangement, Table, presets::UTF8_FULL};
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
|
|
||||||
use crate::api::types::{Ohlc, Quote};
|
use crate::api::types::{Ohlc, Quote};
|
||||||
|
|
@ -39,7 +39,11 @@ pub fn print_quotes(quotes: &[Quote], no_color: bool) -> Result<(), IdxError> {
|
||||||
Cell::new(format!("{:+.2}", q.change)),
|
Cell::new(format!("{:+.2}", q.change)),
|
||||||
pct_cell,
|
pct_cell,
|
||||||
Cell::new(format_idr(q.volume as f64)),
|
Cell::new(format_idr(q.volume as f64)),
|
||||||
Cell::new(q.market_cap.map(format_idr).unwrap_or_else(|| "-".to_string())),
|
Cell::new(
|
||||||
|
q.market_cap
|
||||||
|
.map(format_idr)
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
use assert_cmd::{cargo::cargo_bin, Command};
|
use assert_cmd::{Command, cargo::cargo_bin};
|
||||||
use predicates::prelude::*;
|
use predicates::prelude::*;
|
||||||
|
|
||||||
fn bin() -> Command {
|
fn bin() -> Command {
|
||||||
|
|
@ -16,10 +16,7 @@ fn test_env_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_works() {
|
fn help_works() {
|
||||||
bin()
|
bin().arg("--help").assert().success();
|
||||||
.arg("--help")
|
|
||||||
.assert()
|
|
||||||
.success();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue