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

@ -0,0 +1,73 @@
//! IDX's own site: the daily trading summary of every listed stock.
//!
//! `GET https://www.idx.co.id/primary/TradingSummary/GetStockSummary`
//! `?length=9999&start=0&date=YYYYMMDD` answers JSON with one row per stock:
//! prices, volume and value, foreign buy and sell volume, and non-regular
//! (negotiated and cash) volume and value. A day without trading has no
//! rows. Cloudflare in front of the site rejects some TLS fingerprints, so
//! the request goes through curl-impersonate's profile fallback.
mod parse;
use chrono::NaiveDate;
pub use parse::parse_stock_summary;
use super::TradingSummaryProvider;
use crate::curl_impersonate;
use crate::error::IdxError;
use crate::flow::types::StockDay;
const STOCK_SUMMARY_URL: &str = "https://www.idx.co.id/primary/TradingSummary/GetStockSummary";
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.4 Safari/605.1.15";
/// One session's summary is about 650 KB; it must not hold a command or an
/// MCP call for long.
const MAX_TIME_SECS: &str = "30";
pub fn stock_summary_url(date: NaiveDate) -> String {
format!(
"{STOCK_SUMMARY_URL}?length=9999&start=0&date={}",
date.format("%Y%m%d")
)
}
/// A Cloudflare challenge page is not the JSON asked for: try the next
/// profile.
fn accept_json(body: &[u8]) -> Result<(), IdxError> {
match body.iter().find(|byte| !byte.is_ascii_whitespace()) {
Some(b'{') => Ok(()),
_ => Err(IdxError::Http(
"IDX stock summary: answered a challenge page instead of JSON (HTTP 403)".to_string(),
)),
}
}
pub struct IdxSiteProvider;
impl TradingSummaryProvider for IdxSiteProvider {
fn stock_summary(&self, date: NaiveDate) -> Result<Vec<StockDay>, IdxError> {
let args: Vec<String> = [
"--silent",
"--show-error",
"--location",
"--fail",
"--compressed",
"--max-time",
MAX_TIME_SECS,
"--header",
&format!("User-Agent: {USER_AGENT}"),
"--header",
"Accept: application/json,text/plain,*/*",
"--header",
"Referer: https://www.idx.co.id/id/data-pasar/ringkasan-perdagangan/ringkasan-saham/",
&stock_summary_url(date),
]
.iter()
.map(|arg| arg.to_string())
.collect();
let output =
curl_impersonate::run_owned_with_fallback("IDX stock summary", &args, &accept_json)?;
let body = String::from_utf8(output.stdout)
.map_err(|e| IdxError::Http(format!("IDX stock summary: not UTF-8: {e}")))?;
parse_stock_summary(&body, date)
}
}

View file

@ -0,0 +1,114 @@
//! Pure parser for IDX's `GetStockSummary` JSON, tested against
//! `tests/fixtures/idx_stocksummary_*.json`.
use chrono::NaiveDate;
use serde::Deserialize;
use crate::error::IdxError;
use crate::flow::types::StockDay;
#[derive(Deserialize)]
struct StockSummaryResponse {
data: Vec<StockSummaryRawRow>,
}
#[derive(Deserialize)]
#[serde(rename_all = "PascalCase")]
struct StockSummaryRawRow {
date: String,
stock_code: String,
previous: Option<f64>,
high: Option<f64>,
low: Option<f64>,
close: Option<f64>,
volume: Option<f64>,
value: Option<f64>,
frequency: Option<f64>,
foreign_buy: Option<f64>,
foreign_sell: Option<f64>,
non_regular_volume: Option<f64>,
non_regular_value: Option<f64>,
}
fn whole(value: Option<f64>) -> Option<i64> {
value
.filter(|value| value.is_finite())
.map(|value| value.round() as i64)
}
/// The rows of one session. A day without trading (weekend, holiday) is
/// empty; rows dated other than `date` are dropped, so another session's
/// figures never pass as this one's.
pub fn parse_stock_summary(raw: &str, date: NaiveDate) -> Result<Vec<StockDay>, IdxError> {
let response: StockSummaryResponse = serde_json::from_str(raw)
.map_err(|e| IdxError::ParseError(format!("idx stock summary: {e}")))?;
Ok(response
.data
.into_iter()
.filter(|row| {
row.date
.get(..10)
.and_then(|day| NaiveDate::parse_from_str(day, "%Y-%m-%d").ok())
== Some(date)
})
.map(|row| StockDay {
code: row.stock_code.trim().to_ascii_uppercase(),
date,
previous: whole(row.previous),
high: whole(row.high),
low: whole(row.low),
close: whole(row.close),
volume: whole(row.volume),
value: whole(row.value),
frequency: whole(row.frequency),
foreign_buy: whole(row.foreign_buy),
foreign_sell: whole(row.foreign_sell),
non_regular_volume: whole(row.non_regular_volume),
non_regular_value: whole(row.non_regular_value),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
const DAY: &str = include_str!("../../../tests/fixtures/idx_stocksummary_20261016.json");
const SATURDAY: &str = include_str!("../../../tests/fixtures/idx_stocksummary_20261017.json");
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
#[test]
fn test_parse_stock_summary_reads_foreign_and_non_regular_figures() {
let rows = parse_stock_summary(DAY, date(2026, 10, 16)).unwrap();
let bbca = rows.iter().find(|row| row.code == "BBCA").unwrap();
assert_eq!((bbca.previous, bbca.close), (Some(6075), Some(5175)));
assert_eq!(bbca.volume, Some(1_241_098_200));
assert_eq!(bbca.value, Some(6_474_934_315_000));
assert_eq!(
(bbca.foreign_buy, bbca.foreign_sell),
(Some(309_392_800), Some(757_227_900))
);
assert_eq!(bbca.non_regular_value, Some(100_183_151_475));
}
#[test]
fn test_parse_stock_summary_keeps_only_the_requested_session() {
assert!(
parse_stock_summary(SATURDAY, date(2026, 10, 17))
.unwrap()
.is_empty()
);
assert!(
parse_stock_summary(DAY, date(2026, 10, 15))
.unwrap()
.is_empty()
);
assert!(matches!(
parse_stock_summary("<html>Just a moment...</html>", date(2026, 10, 16)),
Err(IdxError::ParseError(_))
));
}
}

View file

@ -3,7 +3,8 @@
use super::types::{Bar, Fundamentals, Interval, Period, Quote};
use super::{
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider, ipot, msn, yahoo,
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, QuoteProvider,
TradingSummaryProvider, idx, ipot, msn, yahoo,
};
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
use crate::config::ProviderKind;
@ -164,3 +165,26 @@ impl BrokerSummaryProvider for MockBrokerProvider {
ipot::summary_from_html(code, &html)
}
}
/// Trading summaries from `tests/fixtures/idx_stocksummary_<date>.json`; a
/// date without a fixture had no trading.
pub struct MockTradingSummaryProvider;
impl TradingSummaryProvider for MockTradingSummaryProvider {
fn stock_summary(
&self,
date: chrono::NaiveDate,
) -> Result<Vec<crate::flow::types::StockDay>, IdxError> {
if std::env::var("IDX_MOCK_ERROR").is_ok() {
return Err(IdxError::ProviderUnavailable);
}
let path = format!(
"tests/fixtures/idx_stocksummary_{}.json",
date.format("%Y%m%d")
);
match std::fs::read_to_string(path) {
Ok(raw) => idx::parse_stock_summary(&raw, date),
Err(_) => Ok(Vec::new()),
}
}
}

View file

@ -1,3 +1,4 @@
pub mod idx;
pub mod ipot;
#[cfg(feature = "testing")]
pub mod mock;
@ -54,6 +55,15 @@ pub trait BrokerSummaryProvider {
) -> Result<ipot::BrokerSummaryRaw, IdxError>;
}
/// Every stock's trading summary for one session (IDX's own site).
pub trait TradingSummaryProvider {
/// Empty for a day without trading.
fn stock_summary(
&self,
date: chrono::NaiveDate,
) -> Result<Vec<crate::flow::types::StockDay>, IdxError>;
}
pub trait FundamentalsProvider {
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
}
@ -279,6 +289,14 @@ pub fn broker_provider() -> Box<dyn BrokerSummaryProvider> {
Box::new(ipot::IpotProvider::new())
}
pub fn trading_summary_provider() -> Box<dyn TradingSummaryProvider> {
#[cfg(feature = "testing")]
if mock::enabled() {
return Box::new(mock::MockTradingSummaryProvider);
}
Box::new(idx::IdxSiteProvider)
}
/// Today in WIB, the date IDX trades on.
pub(crate) fn today_wib() -> chrono::NaiveDate {
#[cfg(feature = "testing")]

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,

View file

@ -90,6 +90,10 @@ impl Idx {
crate::broker::Broker { idx: self }
}
pub fn flow(&self) -> crate::flow::Flow<'_> {
crate::flow::Flow { idx: self }
}
pub fn financial(&self) -> crate::financial::Financial<'_> {
crate::financial::Financial { idx: self }
}
@ -107,6 +111,18 @@ impl Idx {
crate::api::resolve_symbol(symbol, &self.config.exchange)
}
/// The resolved symbol and its bare IDX code, for sources that cover
/// IDX (`.JK`) stocks only; `what` names the data in the error.
pub(crate) fn idx_code(&self, symbol: &str, what: &str) -> Result<(String, String), IdxError> {
let resolved = self.resolve(symbol)?;
match resolved.strip_suffix(".JK") {
Some(code) if !code.is_empty() => Ok((resolved.clone(), code.to_string())),
_ => Err(IdxError::Unsupported(format!(
"{resolved}: {what} covers IDX (.JK) stocks only"
))),
}
}
pub(crate) fn bucket(&self, key: &str) -> String {
format!("{}-{key}", self.provider.kind().as_str())
}

View file

@ -0,0 +1,291 @@
//! Foreign flow: what foreign investors bought and sold of a stock, session
//! by session, from IDX's daily trading summary (end of day). One request
//! covers every stock for one session and is cached by date.
pub mod types;
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
use crate::api::{today_wib, trading_summary_provider};
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
use crate::error::IdxError;
use types::{FlowRange, ForeignFlow, ForeignFlowDay, StockDay};
const SOURCE: &str = "idx";
/// Most sessions `FlowRange::Latest` reads, one request each.
pub const MAX_SESSIONS: usize = 60;
/// Longest `FlowRange::Dates` range, calendar days.
pub const MAX_RANGE_DAYS: i64 = 92;
/// A past session no longer changes.
const TTL_CLOSED_SECS: u64 = 30 * 24 * 3600;
const TTL_TODAY_SECS: u64 = 5 * 60;
pub struct Flow<'a> {
pub(crate) idx: &'a Idx,
}
impl Flow<'_> {
/// Today in WIB.
pub fn today(&self) -> NaiveDate {
today_wib()
}
/// Foreign buying and selling of `symbol` over `range`.
pub fn foreign(
&self,
symbol: &str,
range: FlowRange,
policy: CachePolicy,
) -> Result<Fetched<ForeignFlow>, IdxError> {
let (resolved, code) = self.idx.idx_code(symbol, "foreign flow")?;
let today = today_wib();
let (dates, wanted, skip_misses) = match range {
FlowRange::Latest { sessions } => {
if !(1..=MAX_SESSIONS).contains(&sessions) {
return Err(IdxError::InvalidInput(format!(
"days must be 1 to {MAX_SESSIONS} (got {sessions})"
)));
}
let lookback = 2 * sessions as i64 + 10;
let dates: Vec<NaiveDate> = (0..lookback)
.map(|back| today - Duration::days(back))
.collect();
// Offline, an uncached day says nothing; look further back.
(dates, sessions, policy == CachePolicy::Offline)
}
FlowRange::Dates { from, to } => {
check_range(from, to, today)?;
let dates = (0..=(to - from).num_days())
.rev()
.map(|offset| from + Duration::days(offset))
.collect();
(dates, usize::MAX, false)
}
};
let mut rows = Vec::new();
let mut sessions = 0;
let mut fetched_at: Option<DateTime<Utc>> = None;
let mut stale = false;
for date in dates {
if sessions == wanted {
break;
}
if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) {
continue;
}
let day = match self.session(date, today, policy) {
Ok(day) => day,
Err(IdxError::CacheMiss(_)) if skip_misses => continue,
Err(err) => return Err(err),
};
if day.data.is_empty() {
continue;
}
sessions += 1;
fetched_at = Some(fetched_at.map_or(day.fetched_at, |at| at.min(day.fetched_at)));
stale |= day.stale;
// A stock missing from a session did not trade (suspension).
if let Some(row) = day.data.into_iter().find(|row| row.code == code) {
rows.push(row);
}
}
if sessions == 0 {
return Err(if policy == CachePolicy::Offline {
IdxError::CacheMiss(format!("idx-stocksummary/{resolved} (no cached session)"))
} else {
IdxError::NoMarketData(format!("{resolved}: no IDX sessions in range"))
});
}
rows.reverse();
let flow = foreign_flow(&resolved, &rows).ok_or(IdxError::SymbolNotFound(code))?;
Ok(Fetched {
data: flow,
source: SOURCE,
fetched_at: fetched_at.unwrap_or_else(Utc::now),
stale,
})
}
/// Every stock's summary for one session; empty without trading.
fn session(
&self,
date: NaiveDate,
today: NaiveDate,
policy: CachePolicy,
) -> Result<Fetched<Vec<StockDay>>, IdxError> {
let provider = trading_summary_provider();
let key = date.to_string();
cached_fetch(
&self.idx.cache,
CacheSpec {
bucket: "idx-stocksummary",
key: &key,
ttl_secs: if date < today {
TTL_CLOSED_SECS
} else {
TTL_TODAY_SECS
},
subject: &key,
source: SOURCE,
},
policy,
|| provider.stock_summary(date),
Ok,
)
}
}
fn check_range(from: NaiveDate, to: NaiveDate, today: NaiveDate) -> Result<(), IdxError> {
if from > to {
return Err(IdxError::InvalidInput(format!(
"--from {from} is after --to {to}"
)));
}
if to > today {
return Err(IdxError::InvalidInput(format!(
"{to} is in the future (today is {today} in WIB)"
)));
}
if (to - from).num_days() >= MAX_RANGE_DAYS {
return Err(IdxError::InvalidInput(format!(
"a foreign flow covers at most {MAX_RANGE_DAYS} days"
)));
}
Ok(())
}
/// `part` in basis points of `whole`, rounded; `None` without a positive whole.
fn bps(part: i64, whole: i64) -> Option<i64> {
(whole > 0).then(|| (part as f64 * 10_000.0 / whole as f64).round() as i64)
}
fn flow_day(row: &StockDay) -> ForeignFlowDay {
let foreign_net = row
.foreign_buy
.zip(row.foreign_sell)
.map(|(buy, sell)| buy - sell);
let foreign_net_value = match (foreign_net, row.value, row.volume) {
(Some(net), Some(value), Some(volume)) if volume > 0 => {
i64::try_from(i128::from(net) * i128::from(value) / i128::from(volume)).ok()
}
_ => None,
};
ForeignFlowDay {
date: row.date,
close: row.close,
change_bps: row
.close
.zip(row.previous)
.and_then(|(close, previous)| bps(close - previous, previous)),
volume: row.volume,
value: row.value,
foreign_buy: row.foreign_buy,
foreign_sell: row.foreign_sell,
foreign_net,
foreign_net_value,
non_regular_bps: row
.non_regular_value
.zip(row.value)
.and_then(|(part, whole)| bps(part, whole)),
}
}
fn sum(days: &[ForeignFlowDay], field: fn(&ForeignFlowDay) -> Option<i64>) -> Option<i64> {
days.iter()
.try_fold(0_i64, |total, day| total.checked_add(field(day)?))
}
/// The flow over `rows` (one stock, oldest first); `None` without rows.
pub fn foreign_flow(symbol: &str, rows: &[StockDay]) -> Option<ForeignFlow> {
let (first, last) = (rows.first()?, rows.last()?);
let days: Vec<ForeignFlowDay> = rows.iter().map(flow_day).collect();
let foreign_share_bps = match (
sum(&days, |day| day.foreign_buy),
sum(&days, |day| day.foreign_sell),
sum(&days, |day| day.volume),
) {
(Some(buy), Some(sell), Some(volume)) => bps(buy + sell, 2 * volume),
_ => None,
};
Some(ForeignFlow {
symbol: symbol.to_string(),
from: first.date,
to: last.date,
foreign_net: sum(&days, |day| day.foreign_net),
foreign_net_value: sum(&days, |day| day.foreign_net_value),
foreign_share_bps,
days,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn row(day: u32, close: i64, buy: i64, sell: Option<i64>) -> StockDay {
StockDay {
code: "TEST".to_string(),
date: NaiveDate::from_ymd_opt(2026, 10, day).unwrap(),
previous: Some(1000),
high: None,
low: None,
close: Some(close),
volume: Some(1_000),
value: Some(1_100_000),
frequency: None,
foreign_buy: Some(buy),
foreign_sell: sell,
non_regular_volume: None,
non_regular_value: Some(110_000),
}
}
#[test]
fn test_foreign_flow_sums_sessions_and_estimates_value() {
let flow = foreign_flow(
"TEST.JK",
&[row(15, 1100, 600, Some(200)), row(16, 950, 100, Some(300))],
)
.unwrap();
let first = &flow.days[0];
assert_eq!(
(first.foreign_net, first.change_bps),
(Some(400), Some(1000))
);
// 400 shares at 1,100 (value over volume).
assert_eq!(first.foreign_net_value, Some(440_000));
assert_eq!(first.non_regular_bps, Some(1000));
assert_eq!(flow.days[1].change_bps, Some(-500));
assert_eq!(
(flow.foreign_net, flow.foreign_net_value),
(Some(200), Some(220_000))
);
// (700 + 500) / (2 * 2,000).
assert_eq!(flow.foreign_share_bps, Some(3000));
assert_eq!((flow.from.day(), flow.to.day()), (15, 16));
}
#[test]
fn test_foreign_flow_never_counts_a_missing_figure_as_zero() {
let flow = foreign_flow(
"TEST.JK",
&[row(15, 1100, 600, Some(200)), row(16, 950, 100, None)],
)
.unwrap();
assert_eq!(flow.days[1].foreign_net, None);
assert_eq!((flow.foreign_net, flow.foreign_share_bps), (None, None));
assert!(foreign_flow("TEST.JK", &[]).is_none());
}
#[test]
fn test_check_range_bounds() {
let day = |d| NaiveDate::from_ymd_opt(2026, 10, d).unwrap();
let today = day(18);
assert!(check_range(day(1), day(16), today).is_ok());
assert!(check_range(day(16), day(12), today).is_err());
assert!(check_range(day(16), day(19), today).is_err());
let long_ago = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap();
assert!(check_range(long_ago, day(16), today).is_err());
}
}

View file

@ -0,0 +1,117 @@
//! Foreign flow types: what foreign investors bought and sold of a stock,
//! session by session (IDX trading summary, end of day).
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use crate::error::IdxError;
/// Sessions shown when a frontend gives neither days nor dates.
pub const DEFAULT_SESSIONS: usize = 10;
/// One stock's trading summary for one session, as IDX publishes it.
/// Prices are whole IDR, volumes are shares.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StockDay {
/// Bare IDX code, e.g. `BBCA`.
pub code: String,
pub date: NaiveDate,
pub previous: Option<i64>,
pub high: Option<i64>,
pub low: Option<i64>,
pub close: Option<i64>,
pub volume: Option<i64>,
/// Value traded, whole IDR, all boards.
pub value: Option<i64>,
pub frequency: Option<i64>,
/// Shares bought by foreign investors.
pub foreign_buy: Option<i64>,
/// Shares sold by foreign investors.
pub foreign_sell: Option<i64>,
/// Shares traded outside the regular board (negotiated and cash).
pub non_regular_volume: Option<i64>,
/// Value traded outside the regular board, whole IDR.
pub non_regular_value: Option<i64>,
}
/// Which sessions a foreign flow covers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlowRange {
/// The latest `sessions` sessions with trades.
Latest { sessions: usize },
/// Every session in `from..=to` (WIB dates).
Dates { from: NaiveDate, to: NaiveDate },
}
impl FlowRange {
/// From a frontend's `days`, or `from` with an optional `to` (default
/// today) as `YYYY-MM-DD`; neither means the latest
/// [`DEFAULT_SESSIONS`].
pub fn from_args(
days: Option<usize>,
from: Option<&str>,
to: Option<&str>,
today: NaiveDate,
) -> Result<Self, IdxError> {
let parse = |name: &str, value: &str| {
NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d").map_err(|_| {
IdxError::InvalidInput(format!("invalid {name} '{value}'; use YYYY-MM-DD"))
})
};
match (days, from, to) {
(days, None, None) => Ok(Self::Latest {
sessions: days.unwrap_or(DEFAULT_SESSIONS),
}),
(None, Some(from), to) => Ok(Self::Dates {
from: parse("from", from)?,
to: to.map(|to| parse("to", to)).transpose()?.unwrap_or(today),
}),
(None, None, Some(_)) => Err(IdxError::InvalidInput("to needs from".to_string())),
(Some(_), _, _) => Err(IdxError::InvalidInput(
"days cannot be combined with from or to".to_string(),
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForeignFlowDay {
pub date: NaiveDate,
pub close: Option<i64>,
/// Close against the previous close, basis points.
pub change_bps: Option<i64>,
/// Shares traded.
pub volume: Option<i64>,
/// Value traded, whole IDR.
pub value: Option<i64>,
/// Shares foreign investors bought.
pub foreign_buy: Option<i64>,
/// Shares foreign investors sold.
pub foreign_sell: Option<i64>,
/// `foreign_buy - foreign_sell`, shares.
pub foreign_net: Option<i64>,
/// `foreign_net` at the session's average price (value over volume),
/// whole IDR: an estimate.
pub foreign_net_value: Option<i64>,
/// Value traded outside the regular board (negotiated and cash), basis
/// points of the session's value.
pub non_regular_bps: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForeignFlow {
/// Resolved ticker, e.g. `BBCA.JK`.
pub symbol: String,
/// First and last session covered.
pub from: NaiveDate,
pub to: NaiveDate,
/// Sessions the stock traded, oldest first.
pub days: Vec<ForeignFlowDay>,
/// Net shares over the sessions; `None` when a session lacks a figure.
pub foreign_net: Option<i64>,
/// Sum of the sessions' estimated net values, whole IDR.
pub foreign_net_value: Option<i64>,
/// Foreign buys and sells over twice the volume: foreign investors'
/// share of trading, basis points.
pub foreign_share_bps: Option<i64>,
}

View file

@ -11,6 +11,7 @@ pub mod config;
pub mod curl_impersonate;
pub mod error;
pub mod financial;
pub mod flow;
#[cfg(feature = "ownership")]
pub mod investor;
pub mod market;