feat: bandarmology (idx stocks bandar, analyze_bandar)

`idx stocks bandar SYMBOL...` reads the broker summary: for the top 1, 3,
5 and 10 brokers per side, buy value minus sell value in basis points of
value traded, with a signal from big accumulation to big distribution on
the headline tier (--top, default 5). Several symbols give a screen ranked
by that figure (at most 100, one request each), which pipes from
`stocks screen`. The MCP server exposes it as analyze_bandar (20 symbols).

- analysis/bandar.rs is pure and versioned (`top_concentration_v1`):
  integer basis points, no figure missing counted as zero, buy/sell VWAP
  from exact averages, and the brokers on both sides listed because their
  gross figures overstate their direction.
- Thresholds (±5%/±15%) follow fzn0x/idx (MIT); docs call them heuristics.
- Invalid input fails a screen at once; other errors fail one symbol and
  are warned about on stderr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-18 04:55:25 +00:00
commit 970da26a0e
25 changed files with 1588 additions and 37 deletions

View file

@ -1,3 +1,4 @@
pub mod bandar;
pub mod fundamental;
pub mod report;
pub mod signals;
@ -8,9 +9,11 @@ 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::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,
@ -28,7 +31,69 @@ pub struct Comparison {
pub failures: Vec<(String, IdxError)>,
}
/// 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)?;
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)?;
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 })
}
/// Technical analysis over one year of daily history from `source`
/// (see `Market::history_source`).
pub fn technical(