mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
Merge branch 'feat/stockbit-movements' into fix/mcp-warnings
Kept both helpers where exodus.rs conflicted: blocked() from this branch and is_read_path() from #78. Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
commit
0582585004
18 changed files with 301 additions and 76 deletions
|
|
@ -271,6 +271,12 @@ fn wait_for_session(
|
|||
));
|
||||
retry_at = Some(Instant::now() + VERIFY_RETRY);
|
||||
}
|
||||
// A block holds for every session from this network, so
|
||||
// the wait ends with it rather than at the timeout.
|
||||
Err(err @ IdxError::UpstreamBlocked(_)) => {
|
||||
devtools.close();
|
||||
return Err(err);
|
||||
}
|
||||
Err(err) => {
|
||||
crate::runtime::warn(format!(
|
||||
"Stockbit refused the session in idx's browser ({err}); sign in \
|
||||
|
|
@ -1041,6 +1047,35 @@ mod tests {
|
|||
let _ = std::fs::remove_dir_all(&profile);
|
||||
}
|
||||
|
||||
/// A CDN block is never retried: the wait ends with it at once, not at
|
||||
/// the timeout.
|
||||
#[test]
|
||||
fn a_blocked_check_ends_the_wait() {
|
||||
let profile = temp_dir("blocked");
|
||||
let browser = FakeBrowser::start(Fake::default());
|
||||
write_endpoint(&profile, browser.port);
|
||||
let mut devtools = connect(&profile, &|| true).unwrap();
|
||||
let checks = std::cell::Cell::new(0);
|
||||
let started = Instant::now();
|
||||
let err = wait_for_session(
|
||||
&mut devtools,
|
||||
Duration::from_secs(30),
|
||||
&|_| {
|
||||
checks.set(checks.get() + 1);
|
||||
Err(IdxError::UpstreamBlocked(
|
||||
"HTTP 403 from its CDN".to_string(),
|
||||
))
|
||||
},
|
||||
&|| true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, IdxError::UpstreamBlocked(_)), "{err}");
|
||||
assert_eq!(checks.get(), 1);
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
browser.finish();
|
||||
let _ = std::fs::remove_dir_all(&profile);
|
||||
}
|
||||
|
||||
/// Closing the window ends the wait at once, not at the timeout.
|
||||
#[test]
|
||||
fn closing_the_window_ends_the_wait() {
|
||||
|
|
|
|||
|
|
@ -98,10 +98,12 @@ fn entitlement(
|
|||
action.factor = number(suffixed(row, "factor"));
|
||||
match kind {
|
||||
ActionKind::Dividend => {
|
||||
action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, 2));
|
||||
action.currency = text(suffixed(row, "currency"))
|
||||
.map(|c| c.trim_start_matches("CURRENCY_").to_ascii_uppercase())
|
||||
.filter(|c| c != "IDR");
|
||||
// Rupiah to the sen; a dollar dividend can be a fraction of a cent.
|
||||
let places = if action.currency.is_some() { 6 } else { 2 };
|
||||
action.cash_per_share = number(suffixed(row, "value")).map(|v| round_to(v, places));
|
||||
}
|
||||
ActionKind::Rights => {
|
||||
action.price = whole(suffixed(row, "price"));
|
||||
|
|
@ -413,7 +415,9 @@ mod tests {
|
|||
"dividend_currency":"CURRENCY_IDR"}]}}"#;
|
||||
let rows = parse_corporate_actions(ActionKind::Dividend, body).unwrap();
|
||||
assert_eq!(rows[0].currency.as_deref(), Some("USD"));
|
||||
assert_eq!(rows[0].cash_per_share, Some(0.0012), "not rounded to cents");
|
||||
assert_eq!(rows[1].currency, None);
|
||||
assert_eq!(rows[1].cash_per_share, Some(5.0));
|
||||
assert_eq!((rows[1].date, rows[1].cum_date), (day("2026-11-12"), None));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,16 @@ use crate::movements::types::{MovementPage, MovementQuery};
|
|||
|
||||
const BASE_URL: &str = "https://exodus.stockbit.com";
|
||||
const REFRESH_PATH: &str = "/login/refresh";
|
||||
/// The only paths idx GETs from the session API. The same API places orders,
|
||||
/// withdraws cash and changes the PIN; a path outside these fails before any
|
||||
/// request (or token refresh) leaves the process. A feature that reads a new
|
||||
/// endpoint adds its prefix here.
|
||||
const READ_PATHS: &[&str] = &[
|
||||
"/company-price-feed/",
|
||||
"/charts/",
|
||||
"/corpaction/",
|
||||
"/insider/company/majorholder?",
|
||||
];
|
||||
/// The prefix of the states in which the regular market trades:
|
||||
/// `STATE_NAME_SESSION_2` was recorded on 2026-11-03 from 13:30; session 1 is
|
||||
/// assumed to follow the same naming. Every other state (`STATE_NAME_BREAK`,
|
||||
|
|
@ -179,6 +189,11 @@ impl Exodus {
|
|||
}
|
||||
|
||||
fn get(&self, path: &str, subject: &str) -> Result<String, IdxError> {
|
||||
if !is_read_path(path) {
|
||||
return Err(IdxError::Unsupported(format!(
|
||||
"idx does not call {path} on Stockbit's session API"
|
||||
)));
|
||||
}
|
||||
let refresher = |refresh: &str| self.refresh(refresh);
|
||||
let token = self.store.access(&refresher, unix_now())?;
|
||||
let mut reply = self.send(path, &token)?;
|
||||
|
|
@ -503,10 +518,40 @@ pub(crate) fn blocked() -> IdxError {
|
|||
)
|
||||
}
|
||||
|
||||
/// Whether `path` is one idx reads; `..` never climbs out of a prefix.
|
||||
fn is_read_path(path: &str) -> bool {
|
||||
READ_PATHS.iter().any(|prefix| path.starts_with(prefix)) && !path.contains("..")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Only the endpoints idx reads pass; trading and account paths, and a
|
||||
/// path that climbs out of a prefix, never leave the process.
|
||||
#[test]
|
||||
fn only_read_paths_are_called() {
|
||||
for path in [
|
||||
"/company-price-feed/market-time/session",
|
||||
"/company-price-feed/v2/orderbook/companies/BBCA",
|
||||
"/charts/BBCA/daily?timeframe=1y&chart_type=PRICE_CHART_TYPE_CANDLE",
|
||||
"/corpaction/dividend?symbol=BBCA",
|
||||
"/insider/company/majorholder?page=1&symbol=BBCA",
|
||||
] {
|
||||
assert!(is_read_path(path), "{path}");
|
||||
}
|
||||
for path in [
|
||||
"/order/buy",
|
||||
"/login/refresh",
|
||||
"/corpaction/../order/buy",
|
||||
"/insider/company/majorholder/../../order",
|
||||
"/charts",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_read_path(path), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
std::fs::read_to_string(format!(
|
||||
"{}/tests/fixtures/stockbit_exodus_{name}",
|
||||
|
|
|
|||
|
|
@ -320,12 +320,10 @@ fn keep_after_failed_check(err: &IdxError, rotated: bool) -> bool {
|
|||
}
|
||||
|
||||
/// Whether a session check failed for a reason that says nothing about the
|
||||
/// session itself, so the same session may be checked again later.
|
||||
/// session itself, so the same session may be checked again later. A CDN
|
||||
/// block (`UpstreamBlocked`) is not: idx never retries a block.
|
||||
pub(crate) fn check_is_transient(err: &IdxError) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
IdxError::Http(_) | IdxError::RateLimited | IdxError::UpstreamBlocked(_)
|
||||
)
|
||||
matches!(err, IdxError::Http(_) | IdxError::RateLimited)
|
||||
}
|
||||
|
||||
fn restore(store: &SessionStore, previous: Option<Vec<u8>>) {
|
||||
|
|
@ -368,6 +366,9 @@ mod tests {
|
|||
assert!(check_is_transient(&IdxError::RateLimited));
|
||||
assert!(check_is_transient(&network));
|
||||
assert!(!check_is_transient(&refused));
|
||||
assert!(!check_is_transient(&IdxError::UpstreamBlocked(
|
||||
"403".to_string()
|
||||
)));
|
||||
assert!(!check_is_transient(&IdxError::InvalidInput(
|
||||
"x".to_string()
|
||||
)));
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ impl Corporate<'_> {
|
|||
}
|
||||
|
||||
/// The market's corporate actions of `kinds` dated within `range` (see
|
||||
/// `CorporateAction::date`), oldest first.
|
||||
/// `CorporateAction::date`), plus offers still open in it (see
|
||||
/// `CalendarRange::includes`), oldest first.
|
||||
pub fn calendar(
|
||||
&self,
|
||||
range: CalendarRange,
|
||||
|
|
@ -52,7 +53,7 @@ impl Corporate<'_> {
|
|||
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||
let fetched = self.fetch(kinds, None, policy)?;
|
||||
Ok(fetched.map(|mut actions| {
|
||||
actions.retain(|a| range.contains(a.date));
|
||||
actions.retain(|a| range.includes(a));
|
||||
actions.sort_by(|a, b| (a.date, &a.symbol, a.kind).cmp(&(b.date, &b.symbol, b.kind)));
|
||||
actions
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ use crate::error::IdxError;
|
|||
pub enum ActionKind {
|
||||
/// Cash dividend.
|
||||
Dividend,
|
||||
#[cfg_attr(feature = "clap", value(name = "stock-dividend"))]
|
||||
#[cfg_attr(
|
||||
feature = "clap",
|
||||
value(name = "stock-dividend", alias = "stock_dividend")
|
||||
)]
|
||||
#[serde(alias = "stock_dividend")]
|
||||
StockDividend,
|
||||
/// Bonus shares.
|
||||
|
|
@ -22,7 +25,10 @@ pub enum ActionKind {
|
|||
Warrant,
|
||||
/// Stock split.
|
||||
Split,
|
||||
#[cfg_attr(feature = "clap", value(name = "reverse-split"))]
|
||||
#[cfg_attr(
|
||||
feature = "clap",
|
||||
value(name = "reverse-split", alias = "reverse_split")
|
||||
)]
|
||||
#[serde(alias = "reverse_split")]
|
||||
ReverseSplit,
|
||||
/// Shareholders' meeting (RUPS).
|
||||
|
|
@ -167,8 +173,8 @@ pub struct CorporateAction {
|
|||
pub record_date: Option<NaiveDate>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pay_date: Option<NaiveDate>,
|
||||
/// Cash dividend per share, IDR as Stockbit gives it (up to 2 decimals,
|
||||
/// e.g. 3.8).
|
||||
/// Cash dividend per share as Stockbit gives it: IDR to the sen (e.g.
|
||||
/// 3.8), another `currency` to 6 decimals (e.g. USD 0.0012).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cash_per_share: Option<f64>,
|
||||
/// The dividend's currency (ISO code, e.g. `USD`) when it is not IDR;
|
||||
|
|
@ -282,6 +288,20 @@ impl CalendarRange {
|
|||
pub fn contains(&self, date: NaiveDate) -> bool {
|
||||
(self.from..=self.to).contains(&date)
|
||||
}
|
||||
|
||||
/// Whether `action` belongs on this calendar: dated in it, or an offer
|
||||
/// still open in it (rights trading, a tender offer, an IPO offer), so a
|
||||
/// calendar shows what can still be acted on. A warrant's windows run
|
||||
/// for years, so a warrant counts by its date alone.
|
||||
pub fn includes(&self, action: &CorporateAction) -> bool {
|
||||
let open_offer = matches!(
|
||||
action.kind,
|
||||
ActionKind::Rights | ActionKind::TenderOffer | ActionKind::Ipo
|
||||
) && action
|
||||
.period
|
||||
.is_some_and(|period| period.start <= self.to && period.end >= self.from);
|
||||
self.contains(action.date) || open_offer
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -292,6 +312,49 @@ mod tests {
|
|||
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
|
||||
/// A calendar shows what is dated in it and the offers still open in it;
|
||||
/// a warrant's years-long windows do not put it on every calendar.
|
||||
#[test]
|
||||
fn test_calendar_includes_offers_still_open() {
|
||||
let range = CalendarRange {
|
||||
from: day("2026-11-03"),
|
||||
to: day("2026-11-12"),
|
||||
};
|
||||
let with_period = |kind, date: &str, start: &str, end: &str| {
|
||||
let mut action = CorporateAction::new("AUTO.JK".into(), kind, day(date));
|
||||
action.period = Some(DateRange {
|
||||
start: day(start),
|
||||
end: day(end),
|
||||
});
|
||||
action
|
||||
};
|
||||
let tender = with_period(
|
||||
ActionKind::TenderOffer,
|
||||
"2026-10-13",
|
||||
"2026-10-13",
|
||||
"2026-11-11",
|
||||
);
|
||||
assert!(range.includes(&tender), "open until 11-11");
|
||||
let rights = with_period(ActionKind::Rights, "2026-10-20", "2026-10-28", "2026-11-03");
|
||||
assert!(range.includes(&rights), "trades on the first day");
|
||||
let closed = with_period(
|
||||
ActionKind::TenderOffer,
|
||||
"2026-09-01",
|
||||
"2026-09-01",
|
||||
"2026-11-02",
|
||||
);
|
||||
assert!(!range.includes(&closed), "closed the day before");
|
||||
let warrant = with_period(
|
||||
ActionKind::Warrant,
|
||||
"2025-01-10",
|
||||
"2025-01-10",
|
||||
"2028-01-10",
|
||||
);
|
||||
assert!(!range.includes(&warrant), "a warrant counts by its date");
|
||||
let dated = CorporateAction::new("BBCA.JK".into(), ActionKind::Dividend, day("2026-11-12"));
|
||||
assert!(range.includes(&dated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calendar_range_defaults_and_bounds() {
|
||||
let today = day("2026-11-03");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue