mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
feat: foreign flow from IPOT by default (--source ipot|idx)
`idx stocks flow` now reads IPOT by default, which needs nothing installed: per session, the all-investor broker summary gives the value traded and the foreign one gives foreign buying (its footer), the foreign net and selling (buying less the net), across all boards, through the broker summary cache. IPOT's foreign net matched IDX's estimate within 0.3 B on BBCA 2026-10-12 to 16. `--source idx` (and `source` on get_foreign_flow) keeps IDX's trading summary: exact share counts, close and the non-regular share, behind curl-impersonate and Cloudflare. - ForeignFlowDay gains foreign_buy_value, foreign_sell_value and foreign_share_bps for both sources (estimated at the session average for IDX); ForeignFlow gains source, the value totals and rounded. - The table lays out each source's figures; the live smoke runs both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
parent
b137dbf540
commit
8b507d78e2
37 changed files with 2080 additions and 143 deletions
|
|
@ -167,7 +167,11 @@ impl Broker<'_> {
|
|||
/// trades the lists show. `F. NVal`, the foreign net, ignores both filters:
|
||||
/// on all boards it gives the side's selling (foreign buying less the foreign
|
||||
/// net; domestic buying plus it), and with a board filter it is dropped.
|
||||
fn summary_from_raw(symbol: &str, query: &BrokerQuery, raw: BrokerSummaryRaw) -> BrokerSummary {
|
||||
pub(crate) fn summary_from_raw(
|
||||
symbol: &str,
|
||||
query: &BrokerQuery,
|
||||
raw: BrokerSummaryRaw,
|
||||
) -> BrokerSummary {
|
||||
let one_side = query.investor != BrokerInvestor::All;
|
||||
let all_boards = query.board == BrokerBoard::All;
|
||||
let foreign_net = raw.foreign_net_value.filter(|_| all_boards);
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
//! 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.
|
||||
//! by session (end of day). IPOT (the default) needs two plain requests per
|
||||
//! session; IDX's trading summary covers every stock in one request per
|
||||
//! session, cached by date, but needs curl-impersonate.
|
||||
|
||||
pub mod types;
|
||||
|
||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
|
||||
|
||||
use crate::api::{today_wib, trading_summary_provider};
|
||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery, BrokerSummary};
|
||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||
use crate::error::IdxError;
|
||||
use types::{FlowRange, ForeignFlow, ForeignFlowDay, StockDay};
|
||||
use types::{FlowRange, FlowSource, ForeignFlow, ForeignFlowDay, StockDay};
|
||||
|
||||
const SOURCE: &str = "idx";
|
||||
/// Most sessions `FlowRange::Latest` reads, one request each.
|
||||
/// Most sessions `FlowRange::Latest` reads.
|
||||
pub const MAX_SESSIONS: usize = 60;
|
||||
/// Longest `FlowRange::Dates` range, calendar days.
|
||||
pub const MAX_RANGE_DAYS: i64 = 92;
|
||||
|
|
@ -24,17 +25,36 @@ pub struct Flow<'a> {
|
|||
pub(crate) idx: &'a Idx,
|
||||
}
|
||||
|
||||
/// What one weekday held for the stock.
|
||||
enum Day {
|
||||
/// The market (or, for IPOT, the stock) did not trade.
|
||||
NoSession,
|
||||
/// The market traded without the stock (a suspension).
|
||||
Absent,
|
||||
Traded {
|
||||
day: Box<ForeignFlowDay>,
|
||||
rounded: bool,
|
||||
},
|
||||
}
|
||||
|
||||
struct Fetch {
|
||||
day: Day,
|
||||
fetched_at: DateTime<Utc>,
|
||||
stale: bool,
|
||||
}
|
||||
|
||||
impl Flow<'_> {
|
||||
/// Today in WIB.
|
||||
pub fn today(&self) -> NaiveDate {
|
||||
today_wib()
|
||||
}
|
||||
|
||||
/// Foreign buying and selling of `symbol` over `range`.
|
||||
/// Foreign buying and selling of `symbol` over `range`, from `source`.
|
||||
pub fn foreign(
|
||||
&self,
|
||||
symbol: &str,
|
||||
range: FlowRange,
|
||||
source: FlowSource,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetched<ForeignFlow>, IdxError> {
|
||||
let (resolved, code) = self.idx.idx_code(symbol, "foreign flow")?;
|
||||
|
|
@ -63,8 +83,9 @@ impl Flow<'_> {
|
|||
}
|
||||
};
|
||||
|
||||
let mut rows = Vec::new();
|
||||
let mut days = Vec::new();
|
||||
let mut sessions = 0;
|
||||
let mut rounded = false;
|
||||
let mut fetched_at: Option<DateTime<Utc>> = None;
|
||||
let mut stale = false;
|
||||
for date in dates {
|
||||
|
|
@ -74,39 +95,117 @@ impl Flow<'_> {
|
|||
if matches!(date.weekday(), Weekday::Sat | Weekday::Sun) {
|
||||
continue;
|
||||
}
|
||||
let day = match self.session(date, today, policy) {
|
||||
Ok(day) => day,
|
||||
let fetch = match source {
|
||||
FlowSource::Ipot => self.ipot_day(&resolved, date, policy),
|
||||
FlowSource::Idx => self.idx_day(&code, date, today, policy),
|
||||
};
|
||||
let fetch = match fetch {
|
||||
Ok(fetch) => fetch,
|
||||
Err(IdxError::CacheMiss(_)) if skip_misses => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if day.data.is_empty() {
|
||||
continue;
|
||||
match fetch.day {
|
||||
Day::NoSession => continue,
|
||||
Day::Absent => {}
|
||||
Day::Traded {
|
||||
day,
|
||||
rounded: day_rounded,
|
||||
} => {
|
||||
days.push(*day);
|
||||
rounded |= day_rounded;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
fetched_at = Some(fetched_at.map_or(fetch.fetched_at, |at| at.min(fetch.fetched_at)));
|
||||
stale |= fetch.stale;
|
||||
}
|
||||
if sessions == 0 {
|
||||
return Err(if policy == CachePolicy::Offline {
|
||||
IdxError::CacheMiss(format!("idx-stocksummary/{resolved} (no cached session)"))
|
||||
IdxError::CacheMiss(format!(
|
||||
"{}-flow/{resolved} (no cached session)",
|
||||
source.as_str()
|
||||
))
|
||||
} else {
|
||||
IdxError::NoMarketData(format!("{resolved}: no IDX sessions in range"))
|
||||
IdxError::NoMarketData(format!("{resolved}: no sessions in range"))
|
||||
});
|
||||
}
|
||||
rows.reverse();
|
||||
let flow = foreign_flow(&resolved, &rows).ok_or(IdxError::SymbolNotFound(code))?;
|
||||
days.reverse();
|
||||
let flow =
|
||||
foreign_flow(&resolved, source, days, rounded).ok_or(IdxError::SymbolNotFound(code))?;
|
||||
Ok(Fetched {
|
||||
data: flow,
|
||||
source: SOURCE,
|
||||
source: source.as_str(),
|
||||
fetched_at: fetched_at.unwrap_or_else(Utc::now),
|
||||
stale,
|
||||
})
|
||||
}
|
||||
|
||||
/// One session from IPOT: the all-investor summary for the value traded,
|
||||
/// the foreign one for foreign buying, selling and net. Both go through
|
||||
/// the broker summary cache.
|
||||
fn ipot_day(
|
||||
&self,
|
||||
resolved: &str,
|
||||
date: NaiveDate,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetch, IdxError> {
|
||||
let broker = self.idx.broker();
|
||||
let all_query = BrokerQuery {
|
||||
from: date,
|
||||
to: date,
|
||||
investor: BrokerInvestor::All,
|
||||
board: BrokerBoard::All,
|
||||
};
|
||||
let all = broker.summary(resolved, &all_query, policy)?;
|
||||
if all.data.is_empty() {
|
||||
return Ok(Fetch {
|
||||
day: Day::NoSession,
|
||||
fetched_at: all.fetched_at,
|
||||
stale: all.stale,
|
||||
});
|
||||
}
|
||||
let foreign_query = BrokerQuery {
|
||||
investor: BrokerInvestor::Foreign,
|
||||
..all_query
|
||||
};
|
||||
let foreign = broker.summary(resolved, &foreign_query, policy)?;
|
||||
Ok(Fetch {
|
||||
day: Day::Traded {
|
||||
day: Box::new(ipot_flow_day(date, &all.data, &foreign.data)),
|
||||
rounded: all.data.rounded || foreign.data.rounded,
|
||||
},
|
||||
fetched_at: all.fetched_at.min(foreign.fetched_at),
|
||||
stale: all.stale || foreign.stale,
|
||||
})
|
||||
}
|
||||
|
||||
/// One session from IDX's trading summary of every stock.
|
||||
fn idx_day(
|
||||
&self,
|
||||
code: &str,
|
||||
date: NaiveDate,
|
||||
today: NaiveDate,
|
||||
policy: CachePolicy,
|
||||
) -> Result<Fetch, IdxError> {
|
||||
let session = self.session(date, today, policy)?;
|
||||
let day = if session.data.is_empty() {
|
||||
Day::NoSession
|
||||
} else {
|
||||
match session.data.iter().find(|row| row.code == code) {
|
||||
Some(row) => Day::Traded {
|
||||
day: Box::new(idx_flow_day(row)),
|
||||
rounded: false,
|
||||
},
|
||||
None => Day::Absent,
|
||||
}
|
||||
};
|
||||
Ok(Fetch {
|
||||
day,
|
||||
fetched_at: session.fetched_at,
|
||||
stale: session.stale,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every stock's summary for one session; empty without trading.
|
||||
fn session(
|
||||
&self,
|
||||
|
|
@ -127,7 +226,7 @@ impl Flow<'_> {
|
|||
TTL_TODAY_SECS
|
||||
},
|
||||
subject: &key,
|
||||
source: SOURCE,
|
||||
source: FlowSource::Idx.as_str(),
|
||||
},
|
||||
policy,
|
||||
|| provider.stock_summary(date),
|
||||
|
|
@ -160,17 +259,54 @@ 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 {
|
||||
/// `shares` at the session's average price (value over volume), whole IDR.
|
||||
fn at_average(shares: Option<i64>, value: Option<i64>, volume: Option<i64>) -> Option<i64> {
|
||||
match (shares, value, volume) {
|
||||
(Some(shares), Some(value), Some(volume)) if volume > 0 => {
|
||||
i64::try_from(i128::from(shares) * i128::from(value) / i128::from(volume)).ok()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Foreign buying plus selling over twice `total`.
|
||||
fn share(buy: Option<i64>, sell: Option<i64>, total: Option<i64>) -> Option<i64> {
|
||||
let traded = buy?.checked_add(sell?)?;
|
||||
bps(traded, total?.checked_mul(2)?)
|
||||
}
|
||||
|
||||
/// One IPOT session: the value traded from the all-investor summary, the
|
||||
/// foreign figures from the foreign one (`buy_value` is its footer,
|
||||
/// `sell_value` that less the foreign net).
|
||||
pub fn ipot_flow_day(
|
||||
date: NaiveDate,
|
||||
all: &BrokerSummary,
|
||||
foreign: &BrokerSummary,
|
||||
) -> ForeignFlowDay {
|
||||
let foreign_net_value = foreign.foreign_net_value.or(all.foreign_net_value);
|
||||
ForeignFlowDay {
|
||||
date,
|
||||
close: None,
|
||||
change_bps: None,
|
||||
volume: all.total_lots.and_then(|lots| lots.checked_mul(100)),
|
||||
value: all.total_value,
|
||||
foreign_buy: None,
|
||||
foreign_sell: None,
|
||||
foreign_net: None,
|
||||
foreign_buy_value: foreign.buy_value,
|
||||
foreign_sell_value: foreign.sell_value,
|
||||
foreign_net_value,
|
||||
foreign_share_bps: share(foreign.buy_value, foreign.sell_value, all.total_value),
|
||||
non_regular_bps: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One IDX session for one stock.
|
||||
pub fn idx_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,
|
||||
|
|
@ -183,7 +319,10 @@ fn flow_day(row: &StockDay) -> ForeignFlowDay {
|
|||
foreign_buy: row.foreign_buy,
|
||||
foreign_sell: row.foreign_sell,
|
||||
foreign_net,
|
||||
foreign_net_value,
|
||||
foreign_buy_value: at_average(row.foreign_buy, row.value, row.volume),
|
||||
foreign_sell_value: at_average(row.foreign_sell, row.value, row.volume),
|
||||
foreign_net_value: at_average(foreign_net, row.value, row.volume),
|
||||
foreign_share_bps: share(row.foreign_buy, row.foreign_sell, row.volume),
|
||||
// `value` is the regular board only; the share is of all trading.
|
||||
non_regular_bps: row
|
||||
.non_regular_value
|
||||
|
|
@ -197,25 +336,38 @@ fn sum(days: &[ForeignFlowDay], field: fn(&ForeignFlowDay) -> Option<i64>) -> Op
|
|||
.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,
|
||||
/// The flow over `days` (one stock, oldest first); `None` without days.
|
||||
pub fn foreign_flow(
|
||||
symbol: &str,
|
||||
source: FlowSource,
|
||||
days: Vec<ForeignFlowDay>,
|
||||
rounded: bool,
|
||||
) -> Option<ForeignFlow> {
|
||||
let (from, to) = (days.first()?.date, days.last()?.date);
|
||||
// IDX has exact shares; IPOT has values.
|
||||
let foreign_share_bps = match source {
|
||||
FlowSource::Idx => share(
|
||||
sum(&days, |day| day.foreign_buy),
|
||||
sum(&days, |day| day.foreign_sell),
|
||||
sum(&days, |day| day.volume),
|
||||
),
|
||||
FlowSource::Ipot => share(
|
||||
sum(&days, |day| day.foreign_buy_value),
|
||||
sum(&days, |day| day.foreign_sell_value),
|
||||
sum(&days, |day| day.value),
|
||||
),
|
||||
};
|
||||
Some(ForeignFlow {
|
||||
symbol: symbol.to_string(),
|
||||
from: first.date,
|
||||
to: last.date,
|
||||
source,
|
||||
from,
|
||||
to,
|
||||
foreign_net: sum(&days, |day| day.foreign_net),
|
||||
foreign_buy_value: sum(&days, |day| day.foreign_buy_value),
|
||||
foreign_sell_value: sum(&days, |day| day.foreign_sell_value),
|
||||
foreign_net_value: sum(&days, |day| day.foreign_net_value),
|
||||
foreign_share_bps,
|
||||
rounded,
|
||||
days,
|
||||
})
|
||||
}
|
||||
|
|
@ -242,13 +394,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn idx_flow(rows: &[StockDay]) -> Option<ForeignFlow> {
|
||||
let days = rows.iter().map(idx_flow_day).collect();
|
||||
foreign_flow("TEST.JK", FlowSource::Idx, days, false)
|
||||
}
|
||||
|
||||
#[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 flow =
|
||||
idx_flow(&[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),
|
||||
|
|
@ -256,6 +410,11 @@ mod tests {
|
|||
);
|
||||
// 400 shares at 1,100 (value over volume).
|
||||
assert_eq!(first.foreign_net_value, Some(440_000));
|
||||
assert_eq!(
|
||||
(first.foreign_buy_value, first.foreign_sell_value),
|
||||
(Some(660_000), Some(220_000))
|
||||
);
|
||||
assert_eq!(first.foreign_share_bps, Some(4000));
|
||||
// 110,000 of 1,100,000 regular plus 110,000 non-regular.
|
||||
assert_eq!(first.non_regular_bps, Some(909));
|
||||
assert_eq!(flow.days[1].change_bps, Some(-500));
|
||||
|
|
@ -270,14 +429,10 @@ mod tests {
|
|||
|
||||
#[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();
|
||||
let flow = idx_flow(&[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());
|
||||
assert!(idx_flow(&[]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -286,11 +441,50 @@ mod tests {
|
|||
let date = NaiveDate::from_ymd_opt(2026, 10, 14).unwrap();
|
||||
let rows = crate::api::idx::parse_stock_summary(raw, date).unwrap();
|
||||
let bbri: Vec<StockDay> = rows.into_iter().filter(|row| row.code == "BBRI").collect();
|
||||
let flow = foreign_flow("BBRI.JK", &bbri).unwrap();
|
||||
let flow = idx_flow(&bbri).unwrap();
|
||||
// 172.3B non-regular against 338.5B regular: 33.74%, not 50.91%.
|
||||
assert_eq!(flow.days[0].non_regular_bps, Some(3374));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ipot_flow_day_reads_foreign_buying_and_selling_from_the_summaries() {
|
||||
let day = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap();
|
||||
let summary = |investor, name: &str| {
|
||||
let html = std::fs::read_to_string(format!("tests/fixtures/{name}")).unwrap();
|
||||
let raw = crate::api::ipot::summary_from_html("BBCA", &html).unwrap();
|
||||
let query = BrokerQuery {
|
||||
from: day,
|
||||
to: day,
|
||||
investor,
|
||||
board: BrokerBoard::All,
|
||||
};
|
||||
crate::broker::summary_from_raw("BBCA.JK", &query, raw)
|
||||
};
|
||||
let all = summary(BrokerInvestor::All, "ipot_brokersummary_bbca_20261016.html");
|
||||
let foreign = summary(
|
||||
BrokerInvestor::Foreign,
|
||||
"ipot_brokersummary_bbca_20261016_foreign_all.html",
|
||||
);
|
||||
let flow = ipot_flow_day(day, &all, &foreign);
|
||||
assert_eq!(flow.value, Some(6_600_000_000_000));
|
||||
assert_eq!(flow.volume, Some(1_260_000_000));
|
||||
assert_eq!(
|
||||
(
|
||||
flow.foreign_buy_value,
|
||||
flow.foreign_sell_value,
|
||||
flow.foreign_net_value
|
||||
),
|
||||
(
|
||||
Some(1_700_000_000_000),
|
||||
Some(4_000_000_000_000),
|
||||
Some(-2_300_000_000_000)
|
||||
)
|
||||
);
|
||||
// (1.7 T + 4.0 T) / (2 * 6.6 T).
|
||||
assert_eq!(flow.foreign_share_bps, Some(4318));
|
||||
assert_eq!((flow.close, flow.foreign_net), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_range_bounds() {
|
||||
let day = |d| NaiveDate::from_ymd_opt(2026, 10, d).unwrap();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! Foreign flow types: what foreign investors bought and sold of a stock,
|
||||
//! session by session (IDX trading summary, end of day).
|
||||
//! session by session (end of day), from IPOT or IDX's trading summary.
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -9,6 +11,47 @@ use crate::error::IdxError;
|
|||
/// Sessions shown when a frontend gives neither days nor dates.
|
||||
pub const DEFAULT_SESSIONS: usize = 10;
|
||||
|
||||
/// Where a foreign flow comes from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FlowSource {
|
||||
/// Indo Premier's broker summary: foreign buy, sell and net value across
|
||||
/// all boards, two plain requests per session. Figures of a million and
|
||||
/// more are rounded to one decimal.
|
||||
#[default]
|
||||
Ipot,
|
||||
/// IDX's own trading summary: exact foreign share counts, close and the
|
||||
/// non-regular share, regular board. Needs curl-impersonate and a
|
||||
/// network IDX's Cloudflare lets through.
|
||||
Idx,
|
||||
}
|
||||
|
||||
impl FlowSource {
|
||||
pub const ALL: [Self; 2] = [Self::Ipot, Self::Idx];
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ipot => "ipot",
|
||||
Self::Idx => "idx",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for FlowSource {
|
||||
type Err = IdxError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"ipot" => Ok(Self::Ipot),
|
||||
"idx" => Ok(Self::Idx),
|
||||
_ => Err(IdxError::InvalidInput(format!(
|
||||
"invalid source '{value}'; use ipot or idx"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One stock's trading summary for one session, as IDX publishes it.
|
||||
/// Prices are whole IDR, volumes are shares. Volume, value and the foreign
|
||||
/// figures cover the regular board only (across a week of every stock, no
|
||||
|
|
@ -77,27 +120,41 @@ impl FlowRange {
|
|||
}
|
||||
}
|
||||
|
||||
/// One session. Which figures are present depends on the source: IPOT gives
|
||||
/// values across all boards, IDX gives exact shares on the regular board (and
|
||||
/// values estimated from them) with the close and the non-regular share.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ForeignFlowDay {
|
||||
pub date: NaiveDate,
|
||||
/// Close, whole IDR (IDX only).
|
||||
pub close: Option<i64>,
|
||||
/// Close against the previous close, basis points.
|
||||
/// Close against the previous close, basis points (IDX only).
|
||||
pub change_bps: Option<i64>,
|
||||
/// Shares traded on the regular board.
|
||||
/// Shares traded: the regular board (IDX), or all boards from lots
|
||||
/// (IPOT).
|
||||
pub volume: Option<i64>,
|
||||
/// Value traded on the regular board, whole IDR.
|
||||
/// Value traded, whole IDR: the regular board (IDX) or all boards (IPOT).
|
||||
pub value: Option<i64>,
|
||||
/// Shares foreign investors bought (regular board).
|
||||
/// Shares foreign investors bought (IDX only).
|
||||
pub foreign_buy: Option<i64>,
|
||||
/// Shares foreign investors sold.
|
||||
/// Shares foreign investors sold (IDX only).
|
||||
pub foreign_sell: Option<i64>,
|
||||
/// `foreign_buy - foreign_sell`, shares.
|
||||
/// `foreign_buy - foreign_sell`, shares (IDX only).
|
||||
pub foreign_net: Option<i64>,
|
||||
/// `foreign_net` at the session's average price (value over volume),
|
||||
/// whole IDR: an estimate.
|
||||
/// Value foreign investors bought, whole IDR: as published (IPOT), or
|
||||
/// estimated at the session's average price (IDX).
|
||||
pub foreign_buy_value: Option<i64>,
|
||||
/// Value foreign investors sold, whole IDR: their buying less their net
|
||||
/// (IPOT), or estimated at the session's average price (IDX).
|
||||
pub foreign_sell_value: Option<i64>,
|
||||
/// Foreign buy value minus sell value, whole IDR: as published (IPOT), or
|
||||
/// net shares at the session's average price (IDX, an estimate).
|
||||
pub foreign_net_value: Option<i64>,
|
||||
/// Foreign buying and selling over twice the session's trading: foreign
|
||||
/// investors' share of it, basis points.
|
||||
pub foreign_share_bps: Option<i64>,
|
||||
/// Value traded outside the regular board (negotiated and cash), basis
|
||||
/// points of all value traded (regular plus non-regular).
|
||||
/// points of all value traded (IDX only).
|
||||
pub non_regular_bps: Option<i64>,
|
||||
}
|
||||
|
||||
|
|
@ -105,16 +162,23 @@ pub struct ForeignFlowDay {
|
|||
pub struct ForeignFlow {
|
||||
/// Resolved ticker, e.g. `BBCA.JK`.
|
||||
pub symbol: String,
|
||||
pub source: FlowSource,
|
||||
/// 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.
|
||||
/// Net shares over the sessions (IDX only); `None` when a session lacks
|
||||
/// a figure.
|
||||
pub foreign_net: Option<i64>,
|
||||
/// Sum of the sessions' estimated net values, whole IDR.
|
||||
/// Foreign buy value over the sessions, whole IDR.
|
||||
pub foreign_buy_value: Option<i64>,
|
||||
/// Foreign sell value over the sessions, whole IDR.
|
||||
pub foreign_sell_value: Option<i64>,
|
||||
/// Foreign net value over the sessions, whole IDR.
|
||||
pub foreign_net_value: Option<i64>,
|
||||
/// Foreign buys and sells over twice the volume: foreign investors'
|
||||
/// share of regular-board trading, basis points.
|
||||
/// Foreign investors' share of trading over the sessions, basis points.
|
||||
pub foreign_share_bps: Option<i64>,
|
||||
/// The source rounded figures of a million and more to one decimal.
|
||||
pub rounded: bool,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue