idx-cli/crates/idx-core/src/analysis/mod.rs
0xrsydn 63587e4dc2 fix(history): fall back to Yahoo when Stockbit refuses the session
auto history picks Stockbit when a session file exists, so an expired
session failed history and technical with AUTHERROR. auto now retries on
Yahoo with a warning; an explicit --history-provider stockbit still fails.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
2026-11-04 17:31:20 +07:00

231 lines
7.9 KiB
Rust

pub mod bandar;
pub mod fundamental;
pub mod report;
pub mod signals;
pub mod technical;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::api::history_provider;
use crate::api::types::{Fundamentals, Interval, Period};
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::config::HistoryProviderKind;
use crate::error::IdxError;
use crate::market::HistorySource;
use bandar::{BandarReport, analyze_bandar, check_top};
use fundamental::{
FundamentalReport, GrowthReport, RiskReport, ValuationReport, analyze_fundamental,
analyze_growth, analyze_risk, analyze_valuation,
};
use report::{TechnicalReport, build_technical_report};
/// Analysis reports. Reports are cached as computed, not their inputs.
pub struct Analysis<'a> {
pub(crate) idx: &'a Idx,
}
/// Comparison results; symbols that failed are listed with their error.
pub struct Comparison {
pub reports: Vec<Fetched<FundamentalReport>>,
pub failures: Vec<(String, IdxError)>,
}
/// One investor side is weighed against its own selling, which the source
/// gives only across all boards.
fn check_scope(investor: BrokerInvestor, board: BrokerBoard) -> Result<(), IdxError> {
if investor == BrokerInvestor::All || board == BrokerBoard::All {
Ok(())
} else {
Err(IdxError::InvalidInput(format!(
"bandar for {} investors needs --board all: the broker summary gives one side's \
selling only across all boards",
investor.as_str()
)))
}
}
/// Bandar readings for several symbols, strongest accumulation first;
/// symbols that failed are listed with their error.
pub struct BandarScreen {
pub reports: Vec<Fetched<BandarReport>>,
pub failures: Vec<(String, IdxError)>,
}
/// The sessions and trades a bandar reading covers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BandarQuery {
pub range: BrokerRange,
pub investor: BrokerInvestor,
pub board: BrokerBoard,
/// Headline tier, one of `bandar::TIERS`.
pub top: u8,
}
impl Analysis<'_> {
/// Bandarmology over the broker summary `query` selects.
pub fn bandar(
&self,
symbol: &str,
query: &BandarQuery,
policy: CachePolicy,
) -> Result<Fetched<BandarReport>, IdxError> {
let top = check_top(query.top)?;
check_scope(query.investor, query.board)?;
let summary =
self.idx
.broker()
.fetch(symbol, query.range, query.investor, query.board, policy)?;
Ok(summary.map(|summary| analyze_bandar(&summary, top)))
}
/// [`Self::bandar`] for each symbol, ranked by the headline imbalance.
/// Invalid input fails the whole screen; other errors fail one symbol.
pub fn bandar_screen<S: AsRef<str>>(
&self,
symbols: &[S],
query: &BandarQuery,
policy: CachePolicy,
) -> Result<BandarScreen, IdxError> {
check_top(query.top)?;
check_scope(query.investor, query.board)?;
let mut reports = Vec::new();
let mut failures = Vec::new();
for symbol in symbols {
let resolved = self.idx.resolve(symbol.as_ref())?;
match self.bandar(&resolved, query, policy) {
Ok(report) => reports.push(report),
Err(err @ IdxError::InvalidInput(_)) => return Err(err),
Err(err) => failures.push((resolved, err)),
}
}
if reports.is_empty() {
return Err(failures
.pop()
.map(|(_, err)| err)
.unwrap_or_else(|| IdxError::InvalidInput("no symbols to screen".to_string())));
}
// Strongest accumulation first; readings without an imbalance last.
reports.sort_by_key(|report| std::cmp::Reverse(report.data.imbalance_bps));
Ok(BandarScreen { reports, failures })
}
/// Where `technical` reads its year of daily history from, for an
/// optional `--history-provider` override.
pub fn technical_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
self.idx
.market()
.history_source(mode, &Period::OneYear, &Interval::Day)
}
/// Technical analysis over one year of daily history from `source`
/// (see `technical_source`).
pub fn technical(
&self,
symbol: &str,
source: HistorySource,
policy: CachePolicy,
) -> Result<Fetched<TechnicalReport>, IdxError> {
let idx = self.idx;
let resolved = idx.resolve(symbol)?;
idx.market().with_history_fallback(source, |source| {
let provider = history_provider(source.provider, idx.config.verbose);
let bucket = format!("{}-technical", source.provider.as_str());
cached_fetch(
&idx.cache,
CacheSpec {
bucket: &bucket,
key: &resolved,
ttl_secs: idx.config.quote_ttl,
subject: &resolved,
source: source.provider.as_str(),
},
policy,
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|history| build_technical_report(&resolved, &history),
)
})
}
pub fn growth(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<GrowthReport>, IdxError> {
self.fundamental_report(symbol, "growth", policy, |_, f| analyze_growth(f))
}
pub fn valuation(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<ValuationReport>, IdxError> {
self.fundamental_report(symbol, "valuation", policy, |_, f| analyze_valuation(f))
}
pub fn risk(&self, symbol: &str, policy: CachePolicy) -> Result<Fetched<RiskReport>, IdxError> {
self.fundamental_report(symbol, "risk", policy, |_, f| analyze_risk(f))
}
pub fn fundamental(
&self,
symbol: &str,
policy: CachePolicy,
) -> Result<Fetched<FundamentalReport>, IdxError> {
self.fundamental_report(symbol, "fundamental", policy, analyze_fundamental)
}
/// Fundamental reports for each symbol. Fails only if every symbol fails
/// (with the last error) or a symbol cannot be resolved.
pub fn compare<S: AsRef<str>>(
&self,
symbols: &[S],
policy: CachePolicy,
) -> Result<Comparison, IdxError> {
let mut reports = Vec::new();
let mut failures = Vec::new();
for symbol in symbols {
let resolved = self.idx.resolve(symbol.as_ref())?;
match self.fundamental(&resolved, policy) {
Ok(report) => reports.push(report),
Err(err) => failures.push((resolved, err)),
}
}
if reports.is_empty() {
return Err(failures.pop().map(|(_, err)| err).unwrap_or_else(|| {
IdxError::CacheMiss("fundamental/no symbols could be compared".to_string())
}));
}
Ok(Comparison { reports, failures })
}
fn fundamental_report<T>(
&self,
symbol: &str,
bucket: &str,
policy: CachePolicy,
analyzer: impl FnOnce(&str, &Fundamentals) -> T,
) -> Result<Fetched<T>, IdxError>
where
T: Serialize + DeserializeOwned,
{
let idx = self.idx;
let resolved = idx.resolve(symbol)?;
let bucket = idx.bucket(bucket);
cached_fetch(
&idx.cache,
CacheSpec {
bucket: &bucket,
key: &resolved,
ttl_secs: idx.config.fundamental_ttl,
subject: &resolved,
source: idx.provider.kind().as_str(),
},
policy,
|| idx.provider.market().fundamentals(&resolved),
|fundamentals| Ok(analyzer(&resolved, &fundamentals)),
)
}
}