feat: foreign flow (idx stocks flow, get_foreign_flow)

`idx stocks flow SYMBOL` shows what foreign investors bought and sold of a
stock, session by session, from IDX's own daily trading summary: foreign
buy, sell and net volume, net value at the session's average price, and
the share of value traded outside the regular board, with range totals.
Latest 10 sessions by default (--days, at most 60) or --from/--to. The MCP
server exposes it as get_foreign_flow.

- api/idx: pure parser for GetStockSummary (rows dated another session
  are dropped) and a provider over curl-impersonate's profile fallback
  with a 30 s limit; Cloudflare passes only some fingerprints.
- flow: each session is cached whole by date, so other stocks' flows for
  the same sessions need no request. Weekends are never requested.
- Idx::idx_code is shared by broker and flow for the .JK-only check.

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 05:09:21 +00:00
commit 29f143fa1b
40 changed files with 1278 additions and 29 deletions

View file

@ -84,7 +84,7 @@ impl Broker<'_> {
query: &BrokerQuery,
policy: CachePolicy,
) -> Result<Fetched<BrokerSummary>, IdxError> {
let (resolved, code) = self.idx_code(symbol)?;
let (resolved, code) = self.idx.idx_code(symbol, "a broker summary")?;
let today = today_wib();
check_range(query, today)?;
let key = format!(
@ -123,7 +123,7 @@ impl Broker<'_> {
board: BrokerBoard,
policy: CachePolicy,
) -> Result<Fetched<BrokerSummary>, IdxError> {
let (resolved, _) = self.idx_code(symbol)?;
let (resolved, _) = self.idx.idx_code(symbol, "a broker summary")?;
let today = today_wib();
for back in 0..LATEST_LOOKBACK_DAYS {
let day = today - Duration::days(back);
@ -153,17 +153,6 @@ impl Broker<'_> {
"{resolved}: no trades in the last {LATEST_LOOKBACK_DAYS} days"
)))
}
/// The resolved symbol and the bare IDX code IPOT takes.
fn idx_code(&self, symbol: &str) -> Result<(String, String), IdxError> {
let resolved = self.idx.resolve(symbol)?;
match resolved.strip_suffix(".JK") {
Some(code) if !code.is_empty() => Ok((resolved.clone(), code.to_string())),
_ => Err(IdxError::Unsupported(format!(
"{resolved}: broker summaries cover IDX (.JK) stocks only"
))),
}
}
}
/// The public summary of one parsed page. With one investor side selected,