mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
Merge pull request #74 from 0xrsydn/feat/stockbit-login
feat: Stockbit login for the order book, live quotes and daily history
This commit is contained in:
commit
400381df2d
129 changed files with 12450 additions and 187 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -24,3 +24,6 @@ out.txt
|
||||||
|
|
||||||
# Cloudflare CLI account cache
|
# Cloudflare CLI account cache
|
||||||
.cloudflare/
|
.cloudflare/
|
||||||
|
|
||||||
|
# macOS Finder metadata
|
||||||
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ Docs:
|
||||||
- `docs/OWNERSHIP_SYNC.md` / `docs/OWNERSHIP_PUBLISH.md`: snapshot consumer contract / maintainer publishing
|
- `docs/OWNERSHIP_SYNC.md` / `docs/OWNERSHIP_PUBLISH.md`: snapshot consumer contract / maintainer publishing
|
||||||
- `docs/SMOKE.md`: Nix checks, test layout, and the live drift check
|
- `docs/SMOKE.md`: Nix checks, test layout, and the live drift check
|
||||||
- `docs/DESIGN.md`: idx-core / idx-mcp design and parity gates
|
- `docs/DESIGN.md`: idx-core / idx-mcp design and parity gates
|
||||||
|
- `docs/STOCKBIT.md`: Stockbit session endpoints and the design of features on them
|
||||||
- `docs/IDX_NEWS.md`: news sources research (IDX disclosures, Google News, Yahoo) and a proposed publisher design
|
- `docs/IDX_NEWS.md`: news sources research (IDX disclosures, Google News, Yahoo) and a proposed publisher design
|
||||||
- `ROADMAP.md`: open work
|
- `ROADMAP.md`: open work
|
||||||
|
|
||||||
|
|
|
||||||
26
Cargo.lock
generated
26
Cargo.lock
generated
|
|
@ -979,6 +979,7 @@ dependencies = [
|
||||||
name = "idx-core"
|
name = "idx-core"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"directories",
|
"directories",
|
||||||
|
|
@ -1134,6 +1135,17 @@ dependencies = [
|
||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mio"
|
||||||
|
version = "1.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "new_debug_unreachable"
|
name = "new_debug_unreachable"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
|
|
@ -1764,6 +1776,16 @@ version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
|
|
@ -1966,8 +1988,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"libc",
|
||||||
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -91,12 +91,16 @@ idx ownership changes --from 2026-07-31 --to 2026-08-31
|
||||||
- Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined
|
- Analysis: `technical`, `growth`, `valuation`, `risk`, and a combined
|
||||||
`fundamental` report; multi-symbol `compare`; MSN stock `screen`
|
`fundamental` report; multi-symbol `compare`; MSN stock `screen`
|
||||||
- Providers: MSN is the default for quotes, fundamentals, and screening;
|
- Providers: MSN is the default for quotes, fundamentals, and screening;
|
||||||
Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo` and
|
Yahoo serves history. Switch with `--provider`/`IDX_PROVIDER=msn|yahoo|stockbit`
|
||||||
`IDX_HISTORY_PROVIDER=auto|yahoo|msn`
|
and `IDX_HISTORY_PROVIDER=auto|yahoo|msn|stockbit`
|
||||||
- Stockbit, opt-in and without a login: `stocks intraday` (today's price by
|
- Stockbit, opt-in: without a login, `stocks intraday` (today's price by the
|
||||||
the minute) and `--provider stockbit` quotes with the best bid/offer while
|
minute) and `--provider stockbit` quotes with the best bid/offer while the
|
||||||
the market is open. Delayed at least 10 minutes, for personal use under
|
market is open, delayed at least 10 minutes. With your own account
|
||||||
Stockbit's terms; see the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
|
(`idx stockbit login`): `stocks orderbook` depth, live
|
||||||
|
quotes, Stockbit daily history, and corporate actions (`stocks actions`,
|
||||||
|
`stocks calendar`: dividends, rights, splits, meetings, IPOs and more), and
|
||||||
|
insider and major holder buys and sells (`ownership movements`). Personal use under Stockbit's terms; see
|
||||||
|
the [Stockbit page](https://idx.rasyidanaf.com/cli/stockbit/)
|
||||||
- Local file cache with TTL, and `--offline` mode with stale-cache fallback
|
- Local file cache with TTL, and `--offline` mode with stale-cache fallback
|
||||||
|
|
||||||
### Ownership (KSEI shareholders above 1%)
|
### Ownership (KSEI shareholders above 1%)
|
||||||
|
|
@ -163,8 +167,9 @@ change it to `auto` with `idx config set general.output auto`.
|
||||||
|
|
||||||
## MCP server
|
## MCP server
|
||||||
|
|
||||||
`idx-mcp` serves the same data to MCP clients over stdio, as 15 tools:
|
`idx-mcp` serves the same data to MCP clients over stdio, as 21 tools:
|
||||||
`get_quotes`, `get_intraday`, `get_price_history`, `screen_stocks`, `get_company`,
|
`get_quotes`, `get_intraday`, `get_orderbook`, `get_corporate_actions`,
|
||||||
|
`get_corporate_calendar`, `get_shareholder_movements`, `stockbit_status`, `stockbit_login`, `get_price_history`, `screen_stocks`, `get_company`,
|
||||||
`analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`,
|
`analyze_stock`, `compare_stocks`, `get_broker_summary`, `analyze_bandar`,
|
||||||
`get_foreign_flow`, `get_shareholders`, `get_investor_holdings`,
|
`get_foreign_flow`, `get_shareholders`, `get_investor_holdings`,
|
||||||
`search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as
|
`search_investors`, `get_ownership_changes`, and `ownership_status`. Live results come wrapped as
|
||||||
|
|
|
||||||
31
ROADMAP.md
31
ROADMAP.md
|
|
@ -12,11 +12,28 @@ history lives in git.
|
||||||
- `screen presets` / `screen run <PRESET>`: built-in screener presets
|
- `screen presets` / `screen run <PRESET>`: built-in screener presets
|
||||||
(graduate `screen` out of `stocks` only if these grow into a richer workflow)
|
(graduate `screen` out of `stocks` only if these grow into a richer workflow)
|
||||||
- Multi-period financial statements (today `financials` is single-period)
|
- Multi-period financial statements (today `financials` is single-period)
|
||||||
- Order book depth: no free, login-free source exists (IDX sells delayed depth
|
- Order book depth without a login: no free source exists (IDX sells delayed
|
||||||
to vendors). Planned as the Stockbit login follow-on: opt-in with the
|
depth to vendors); `stocks orderbook` needs the user's Stockbit login.
|
||||||
user's own account, the depth ladder, intraday OHLCV and token refresh from
|
- Shareholder movement follow-ups (`docs/STOCKBIT.md`): `ownership movements
|
||||||
`exodus.stockbit.com`; fzn0x/idx (MIT) maps the endpoints. The public page
|
--summary`, a per-symbol score (net value, distinct buyers, share-plan
|
||||||
already gives the best bid/offer (L1) while the market is open.
|
flag), then an event study before calling any of it a signal; joining
|
||||||
|
holders to ownership entities by name. Corporate-action follow-ups:
|
||||||
|
ex-dates of splits, bonuses and rights noted in `stocks history`, and the
|
||||||
|
`stock_dividend` / `reversesplit` row shapes once Stockbit lists one.
|
||||||
|
- Stockbit login follow-ups: hourly OHLCV from the session's `1w` chart (the
|
||||||
|
`1d` chart is per-minute closes only); confirming session 1 is
|
||||||
|
`STATE_NAME_SESSION_1` (session 2, `BREAK` and `MARKET_CLOSED` were
|
||||||
|
recorded on 2026-11-03), and when `has_foreign_bs` turns on with the day's
|
||||||
|
foreign figures; an
|
||||||
|
opt-in daily refresh so a session outlives the refresh token's week, once
|
||||||
|
it is known whether Stockbit caps a session's total life; and the sign-in
|
||||||
|
response (`/login/v6/*`, read over DevTools' `Fetch`) as a second signal
|
||||||
|
beside the `credentialStorage` cookie, should Stockbit rename or move it.
|
||||||
|
- Google sign-in in idx's browser window: Google refused it on 2026-10-31,
|
||||||
|
most likely over `--remote-debugging-port`. First confirm by trying Google
|
||||||
|
in a plain window on a throwaway profile; if that works, sign in with no
|
||||||
|
debugging flag and reopen the profile with it, on a blank page, to read the
|
||||||
|
cookie.
|
||||||
- Observe Stockbit's `market_hour.status` in the pre-open (08:45-09:00) and
|
- Observe Stockbit's `market_hour.status` in the pre-open (08:45-09:00) and
|
||||||
the closing auction (15:50-16:00) before `--watch` or alerts build on it;
|
the closing auction (15:50-16:00) before `--watch` or alerts build on it;
|
||||||
`open`, `break` and `close` are known, and the bid/offer gate hides the book
|
`open`, `break` and `close` are known, and the bid/offer gate hides the book
|
||||||
|
|
@ -50,8 +67,8 @@ history lives in git.
|
||||||
- Stockbit's terms call its data delayed at least 10 minutes and for
|
- Stockbit's terms call its data delayed at least 10 minutes and for
|
||||||
personal, non-commercial use (on 2026-10-23 the last trade was within a
|
personal, non-commercial use (on 2026-10-23 the last trade was within a
|
||||||
minute of the clock); its CDN refuses some user agents with a 403
|
minute of the clock); its CDN refuses some user agents with a 403
|
||||||
(`UPSTREAMBLOCKED`). Its order-book sizes are not shown: the page does not
|
(`UPSTREAMBLOCKED`). The public page's best bid/offer sizes are not shown
|
||||||
say whether they are lots or shares.
|
yet; they are shares (equal to the session API's book on 2026-10-23).
|
||||||
- Yahoo can return `429` from datacenter IPs; requests retry with backoff but
|
- Yahoo can return `429` from datacenter IPs; requests retry with backoff but
|
||||||
can still fail.
|
can still fail.
|
||||||
- `stocks technical` reports the SMA200 trend as unavailable when fewer than
|
- `stocks technical` reports the SMA200 trend as unavailable when fewer than
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
pub mod ownership;
|
pub mod ownership;
|
||||||
|
pub mod stockbit;
|
||||||
pub mod stocks;
|
pub mod stocks;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand, ValueEnum};
|
use clap::{Parser, Subcommand, ValueEnum};
|
||||||
|
|
@ -69,6 +70,8 @@ pub enum Commands {
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
#[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")]
|
#[command(about = "Ownership intelligence (bootstrap with `ownership sync`)")]
|
||||||
Ownership(ownership::OwnershipCmd),
|
Ownership(ownership::OwnershipCmd),
|
||||||
|
#[command(about = "Your Stockbit session (opt-in: order book, live quotes)")]
|
||||||
|
Stockbit(stockbit::StockbitCmd),
|
||||||
#[command(about = "Generate shell completions")]
|
#[command(about = "Generate shell completions")]
|
||||||
Completions { shell: Shell },
|
Completions { shell: Shell },
|
||||||
#[command(about = "Show idx-cli version")]
|
#[command(about = "Show idx-cli version")]
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ use idx_core::error::IdxError;
|
||||||
use idx_core::investor::{
|
use idx_core::investor::{
|
||||||
ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family,
|
ConcentrationRank, HolderSource, ImportSource, Investor, parse_report_family,
|
||||||
};
|
};
|
||||||
|
use idx_core::movements::types::{ActionFilter, MovementQuery, MovementSource};
|
||||||
use idx_core::ownership::graph;
|
use idx_core::ownership::graph;
|
||||||
use idx_core::ownership::snapshot;
|
use idx_core::ownership::snapshot;
|
||||||
use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource};
|
use idx_core::ownership::types::{ChangeType, FlowSignal, HolderRow, OwnershipSource};
|
||||||
|
use idx_core::{CachePolicy, Idx};
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
#[command(
|
#[command(
|
||||||
|
|
@ -72,6 +74,17 @@ pub enum OwnershipCommand {
|
||||||
after_help = "Examples:\n idx ownership releases\n idx ownership changes --from 2026-08-31 --to 2026-09-30"
|
after_help = "Examples:\n idx ownership releases\n idx ownership changes --from 2026-08-31 --to 2026-09-30"
|
||||||
)]
|
)]
|
||||||
Changes(ChangesArgs),
|
Changes(ChangesArgs),
|
||||||
|
#[command(
|
||||||
|
about = "Insider and major holder buys and sells, as reported (Stockbit login)",
|
||||||
|
long_about = "Reported changes in holders' stakes from your own Stockbit session (`idx \
|
||||||
|
stockbit login`), newest first: IDX insider disclosures (directors, commissioners and other \
|
||||||
|
insiders, any size, with the trade price) and KSEI's changes of holders at 5% and above (Stockbit \
|
||||||
|
estimates their price). One stock's, or the whole market's without SYMBOL. Each page is 50 rows \
|
||||||
|
and one request; answers are cached for an hour.\n\nThese are filings, not signals: a share \
|
||||||
|
plan shows as many insiders buying at one price, and a row appears days after the change.",
|
||||||
|
after_help = "Examples:\n idx ownership movements BBCA\n idx ownership movements --action sell --source ksei\n idx ownership movements --from 2026-10-01 --to 2026-10-31 --pages 5\n idx ownership movements --holder 1000004481 # one holder's history (HOLDER ID column)"
|
||||||
|
)]
|
||||||
|
Movements(MovementsArgs),
|
||||||
/// Ownership network graph.
|
/// Ownership network graph.
|
||||||
Graph(GraphArgs),
|
Graph(GraphArgs),
|
||||||
/// Manual entity resolution workflow.
|
/// Manual entity resolution workflow.
|
||||||
|
|
@ -168,6 +181,30 @@ pub struct ChangesArgs {
|
||||||
pub to: String,
|
pub to: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
pub struct MovementsArgs {
|
||||||
|
/// Single ticker symbol (e.g. BBCA); omit for the whole market.
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
/// First day, YYYY-MM-DD.
|
||||||
|
#[arg(long)]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// Last day, YYYY-MM-DD.
|
||||||
|
#[arg(long)]
|
||||||
|
pub to: Option<String>,
|
||||||
|
/// One holder's movements, by the id in the HOLDER ID column.
|
||||||
|
#[arg(long = "holder", value_name = "ID")]
|
||||||
|
pub holder_id: Option<String>,
|
||||||
|
/// Only this kind of movement.
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub action: Option<ActionFilter>,
|
||||||
|
/// Only IDX insider disclosures or KSEI 5% holder changes.
|
||||||
|
#[arg(long, value_enum)]
|
||||||
|
pub source: Option<MovementSource>,
|
||||||
|
/// Pages of 50 rows to read (1-10).
|
||||||
|
#[arg(long, default_value_t = 1)]
|
||||||
|
pub pages: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
pub struct GraphArgs {
|
pub struct GraphArgs {
|
||||||
/// Ticker code or entity name to start from.
|
/// Ticker code or entity name to start from.
|
||||||
|
|
@ -209,7 +246,12 @@ pub enum ResolveCommand {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError> {
|
pub fn handle(
|
||||||
|
cmd: &OwnershipCommand,
|
||||||
|
config: &IdxConfig,
|
||||||
|
offline: bool,
|
||||||
|
no_cache: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
let investor = Investor::new(&config.core);
|
let investor = Investor::new(&config.core);
|
||||||
let inv = &investor;
|
let inv = &investor;
|
||||||
match cmd {
|
match cmd {
|
||||||
|
|
@ -223,6 +265,9 @@ pub fn handle(cmd: &OwnershipCommand, config: &IdxConfig) -> Result<(), IdxError
|
||||||
OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config),
|
OwnershipCommand::Concentration(args) => handle_concentration(inv, args, config),
|
||||||
OwnershipCommand::Flow(args) => handle_flow(inv, args, config),
|
OwnershipCommand::Flow(args) => handle_flow(inv, args, config),
|
||||||
OwnershipCommand::Changes(args) => handle_changes(inv, args, config),
|
OwnershipCommand::Changes(args) => handle_changes(inv, args, config),
|
||||||
|
OwnershipCommand::Movements(args) => {
|
||||||
|
handle_movements(args, config, CachePolicy::from_flags(offline, no_cache)?)
|
||||||
|
}
|
||||||
OwnershipCommand::Graph(args) => handle_graph(inv, args, config),
|
OwnershipCommand::Graph(args) => handle_graph(inv, args, config),
|
||||||
OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config),
|
OwnershipCommand::Resolve(args) => handle_resolve(inv, args, config),
|
||||||
OwnershipCommand::Releases => handle_releases(inv, config),
|
OwnershipCommand::Releases => handle_releases(inv, config),
|
||||||
|
|
@ -678,6 +723,38 @@ fn handle_changes(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_movements(
|
||||||
|
args: &MovementsArgs,
|
||||||
|
config: &IdxConfig,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
let mut query = MovementQuery::from_args(
|
||||||
|
args.symbol.as_deref(),
|
||||||
|
args.from.as_deref(),
|
||||||
|
args.to.as_deref(),
|
||||||
|
args.holder_id.as_deref(),
|
||||||
|
args.pages,
|
||||||
|
)?;
|
||||||
|
query.action = args.action;
|
||||||
|
query.source = args.source;
|
||||||
|
let idx = Idx::new(config.core.clone())?;
|
||||||
|
let list = idx.movements().list(&query, policy)?;
|
||||||
|
let symbol = match &query.symbol {
|
||||||
|
Some(symbol) => Some(idx_core::api::resolve_symbol(
|
||||||
|
symbol,
|
||||||
|
&config.core.exchange,
|
||||||
|
)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
crate::output::render_shareholder_movements(
|
||||||
|
symbol.as_deref(),
|
||||||
|
&list.data,
|
||||||
|
query.pages,
|
||||||
|
&config.output,
|
||||||
|
config.no_color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_resolve(
|
fn handle_resolve(
|
||||||
investor: &Investor,
|
investor: &Investor,
|
||||||
args: &ResolveArgs,
|
args: &ResolveArgs,
|
||||||
|
|
|
||||||
236
crates/idx-cli/src/cli/stockbit.rs
Normal file
236
crates/idx-cli/src/cli/stockbit.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
use std::io::{IsTerminal, Read};
|
||||||
|
|
||||||
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
use crate::output::text::terminal_safe;
|
||||||
|
use crate::output::{OutputFormat, json};
|
||||||
|
use idx_core::api::stockbit::{self, auth::SessionStatus, auth::TokenInfo, signin};
|
||||||
|
use idx_core::error::IdxError;
|
||||||
|
use idx_core::runtime;
|
||||||
|
|
||||||
|
#[derive(Debug, Args)]
|
||||||
|
#[command(
|
||||||
|
about = "Your Stockbit session (opt-in: order book, live quotes)",
|
||||||
|
long_about = "Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` \
|
||||||
|
quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, \
|
||||||
|
stored in the config directory with owner-only permissions, and never logs them. Stockbit \
|
||||||
|
rotates the refresh token on every refresh, so one session has one owner: `login` signs in \
|
||||||
|
through a browser profile only idx uses. Stockbit's terms allow personal, non-commercial use.",
|
||||||
|
after_help = "Examples:\n idx stockbit login # sign in through a browser\n idx stockbit login --no-browser # local page: bookmarklet or paste\n pbpaste | idx stockbit login # the credentialStorage cookie on stdin\n idx stockbit status\n idx stockbit logout"
|
||||||
|
)]
|
||||||
|
pub struct StockbitCmd {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: StockbitSubcommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Subcommand)]
|
||||||
|
pub enum StockbitSubcommand {
|
||||||
|
#[command(
|
||||||
|
about = "Sign in through your browser, or from stdin",
|
||||||
|
long_about = "In a terminal, idx opens Chrome (or Chromium, Edge, Brave, Helium) on \
|
||||||
|
Stockbit's sign-in page with a profile only idx uses. Sign in there: idx reads the session \
|
||||||
|
cookie over the DevTools protocol, checks it with one request, takes it out of that profile, \
|
||||||
|
and closes the window. Your password, one-time codes and any reCAPTCHA stay in the browser. \
|
||||||
|
A stored session that still works means nothing to do: idx exits at once. Over SSH, without \
|
||||||
|
a display, without such a browser, or with --no-browser, idx serves a local page on \
|
||||||
|
127.0.0.1 instead, with a bookmarklet that reads the cookie in your own browser and a \
|
||||||
|
clipboard button. With stdin piped, it reads the value of stockbit.com's \
|
||||||
|
`credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin. \
|
||||||
|
Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead; IDX_STOCKBIT_BROWSER picks the browser."
|
||||||
|
)]
|
||||||
|
Login {
|
||||||
|
/// Use the local page instead of a browser sign-in
|
||||||
|
#[arg(long)]
|
||||||
|
no_browser: bool,
|
||||||
|
/// Port for the local page (for `ssh -L`); default 7878, or any free one
|
||||||
|
#[arg(long)]
|
||||||
|
port: Option<u16>,
|
||||||
|
},
|
||||||
|
#[command(about = "Show whether a session is stored and when its tokens expire")]
|
||||||
|
Status,
|
||||||
|
#[command(about = "Delete the stored session")]
|
||||||
|
Logout,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> {
|
||||||
|
match &cmd.command {
|
||||||
|
StockbitSubcommand::Login { no_browser, port } => {
|
||||||
|
let interactive = std::io::stdin().is_terminal();
|
||||||
|
// A session that still works means there is nothing to sign in to.
|
||||||
|
let reuse = interactive && stockbit::verify_session().is_ok();
|
||||||
|
let status = if reuse {
|
||||||
|
stockbit::session_status()?
|
||||||
|
} else if interactive {
|
||||||
|
sign_in(*no_browser, *port, format)?
|
||||||
|
} else {
|
||||||
|
stockbit::login(&read_paste()?)?
|
||||||
|
};
|
||||||
|
if format.is_json() {
|
||||||
|
return json::print_json(&status);
|
||||||
|
}
|
||||||
|
if reuse {
|
||||||
|
println!(
|
||||||
|
"Already signed in to Stockbit; use `idx stockbit logout` to switch accounts."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("Stored and checked your Stockbit session.");
|
||||||
|
}
|
||||||
|
print_status(&status);
|
||||||
|
}
|
||||||
|
StockbitSubcommand::Status => {
|
||||||
|
let status = stockbit::session_status()?;
|
||||||
|
if format.is_json() {
|
||||||
|
return json::print_json(&status);
|
||||||
|
}
|
||||||
|
print_status(&status);
|
||||||
|
}
|
||||||
|
StockbitSubcommand::Logout => {
|
||||||
|
let (removed, status) = stockbit::logout()?;
|
||||||
|
if status.logged_in {
|
||||||
|
runtime::warn(
|
||||||
|
"IDX_STOCKBIT_REFRESH_TOKEN or IDX_STOCKBIT_ACCESS_TOKEN still provides a session",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if format.is_json() {
|
||||||
|
return json::print_json(&serde_json::json!({
|
||||||
|
"removed": removed,
|
||||||
|
"logged_in": status.logged_in,
|
||||||
|
"session_file": status.session_file,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if removed {
|
||||||
|
println!("Removed the stored Stockbit session.");
|
||||||
|
} else {
|
||||||
|
println!("No Stockbit session was stored.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs idx-core's sign-in in the foreground, telling the user on stderr
|
||||||
|
/// where to go. When neither the browser nor the page can start, the cookie
|
||||||
|
/// can still be pasted.
|
||||||
|
fn sign_in(
|
||||||
|
no_browser: bool,
|
||||||
|
port: Option<u16>,
|
||||||
|
format: &OutputFormat,
|
||||||
|
) -> Result<SessionStatus, IdxError> {
|
||||||
|
let options = signin::Options {
|
||||||
|
method: if no_browser {
|
||||||
|
signin::Method::Page
|
||||||
|
} else {
|
||||||
|
signin::Method::Auto
|
||||||
|
},
|
||||||
|
port,
|
||||||
|
open_page: !no_browser,
|
||||||
|
};
|
||||||
|
let sign_in = match signin::SignIn::start(&options) {
|
||||||
|
Ok(sign_in) => sign_in,
|
||||||
|
Err(err) => {
|
||||||
|
runtime::warn(format!("cannot start a sign-in ({err}); paste instead"));
|
||||||
|
return stockbit::login(&read_paste()?);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
describe(sign_in.started(), format);
|
||||||
|
sign_in.wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to sign in. These lines are the instructions, so they print even
|
||||||
|
/// with `--quiet`; on stderr, so stdout keeps only the result.
|
||||||
|
fn describe(started: &signin::Started, format: &OutputFormat) {
|
||||||
|
let say = |message: String| {
|
||||||
|
if format.is_json() {
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({ "level": "info", "message": message })
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
eprintln!("{}", terminal_safe(&message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match started {
|
||||||
|
signin::Started::Browser { browser } => say(format!(
|
||||||
|
"Opened {browser} on Stockbit's sign-in page. Sign in there; idx closes the window \
|
||||||
|
when you are done."
|
||||||
|
)),
|
||||||
|
signin::Started::Page {
|
||||||
|
url,
|
||||||
|
forward,
|
||||||
|
remote,
|
||||||
|
reason,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
if let Some(reason) = reason
|
||||||
|
&& !remote
|
||||||
|
{
|
||||||
|
runtime::warn(format!("{reason}; using the local page"));
|
||||||
|
}
|
||||||
|
say(format!("Open this page to connect Stockbit: {url}"));
|
||||||
|
if *remote {
|
||||||
|
say(format!(
|
||||||
|
"This shell is over SSH, so the page is on the remote machine. From the \
|
||||||
|
machine with your browser, run (and keep it open):\n {forward}\nthen open \
|
||||||
|
the URL above there."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signin::Started::Existing => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pasted session: one line from a terminal, else all of stdin.
|
||||||
|
fn read_paste() -> Result<String, IdxError> {
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut raw = String::new();
|
||||||
|
if stdin.is_terminal() {
|
||||||
|
eprintln!(
|
||||||
|
"Paste the value of stockbit.com's `credentialStorage` cookie (DevTools > \
|
||||||
|
Application > Cookies) or a refresh token, then press Enter:"
|
||||||
|
);
|
||||||
|
stdin
|
||||||
|
.read_line(&mut raw)
|
||||||
|
.map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
|
} else {
|
||||||
|
stdin
|
||||||
|
.lock()
|
||||||
|
.read_to_string(&mut raw)
|
||||||
|
.map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
|
}
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
return Err(IdxError::InvalidInput(
|
||||||
|
"nothing on stdin: run `idx stockbit login` in a terminal to sign in through a \
|
||||||
|
browser, pipe the credentialStorage cookie (`pbpaste | ssh <host> idx stockbit \
|
||||||
|
login`), or set IDX_STOCKBIT_REFRESH_TOKEN"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_status(status: &SessionStatus) {
|
||||||
|
println!(
|
||||||
|
"logged in: {}",
|
||||||
|
if status.logged_in { "yes" } else { "no" }
|
||||||
|
);
|
||||||
|
println!("access token: {}", token_line(&status.access));
|
||||||
|
println!("refresh token: {}", token_line(&status.refresh));
|
||||||
|
println!("session file: {}", terminal_safe(&status.session_file));
|
||||||
|
if !status.logged_in {
|
||||||
|
println!("Run `idx stockbit login` to add one.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_line(token: &TokenInfo) -> String {
|
||||||
|
match (token.present, token.expires_at, token.expires_in_secs) {
|
||||||
|
(false, _, _) => "none".to_string(),
|
||||||
|
(true, Some(at), Some(secs)) if secs > 0 => format!(
|
||||||
|
"expires {} UTC (in {}h {}m)",
|
||||||
|
at.format("%Y-%m-%d %H:%M"),
|
||||||
|
secs / 3600,
|
||||||
|
secs % 3600 / 60
|
||||||
|
),
|
||||||
|
(true, Some(at), _) => format!("expired {} UTC", at.format("%Y-%m-%d %H:%M")),
|
||||||
|
(true, None, _) => "present (expiry unknown)".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,15 +4,17 @@ use clap::{Args, Subcommand};
|
||||||
|
|
||||||
use crate::config::{HistoryProviderKind, IdxConfig};
|
use crate::config::{HistoryProviderKind, IdxConfig};
|
||||||
use crate::output::{
|
use crate::output::{
|
||||||
render_bandar, render_broker_summary, render_compare, render_earnings, render_financials,
|
render_bandar, render_broker_summary, render_compare, render_corporate_actions,
|
||||||
render_foreign_flow, render_fundamental, render_growth, render_history, render_insights,
|
render_corporate_calendar, render_earnings, render_financials, render_foreign_flow,
|
||||||
render_intraday, render_news, render_profile, render_quotes, render_risk, render_screener,
|
render_fundamental, render_growth, render_history, render_insights, render_intraday,
|
||||||
|
render_news, render_orderbook, render_profile, render_quotes, render_risk, render_screener,
|
||||||
render_sentiment, render_technical, render_valuation,
|
render_sentiment, render_technical, render_valuation,
|
||||||
};
|
};
|
||||||
use idx_core::analysis::BandarQuery;
|
use idx_core::analysis::BandarQuery;
|
||||||
use idx_core::analysis::bandar::DEFAULT_TOP;
|
use idx_core::analysis::bandar::DEFAULT_TOP;
|
||||||
use idx_core::api::types::{Interval, Period};
|
use idx_core::api::types::{Interval, Period};
|
||||||
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
use idx_core::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||||
|
use idx_core::corporate::types::{ActionKind, CalendarRange};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
|
use idx_core::financial::{EarningsFilter, StatementFilter, StatementKind};
|
||||||
use idx_core::flow::types::{FlowRange, FlowSource};
|
use idx_core::flow::types::{FlowRange, FlowSource};
|
||||||
|
|
@ -23,6 +25,8 @@ use idx_core::{CachePolicy, Idx, runtime};
|
||||||
/// separated by whitespace or commas.
|
/// separated by whitespace or commas.
|
||||||
/// Most symbols one `bandar` screen fetches, one request each.
|
/// Most symbols one `bandar` screen fetches, one request each.
|
||||||
const MAX_BANDAR_SYMBOLS: usize = 100;
|
const MAX_BANDAR_SYMBOLS: usize = 100;
|
||||||
|
/// Most price levels shown per side of an order book.
|
||||||
|
const MAX_BOOK_DEPTH: usize = 50;
|
||||||
|
|
||||||
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
|
fn expand_symbols(args: &[String]) -> Result<Vec<String>, IdxError> {
|
||||||
let mut symbols = Vec::new();
|
let mut symbols = Vec::new();
|
||||||
|
|
@ -146,6 +150,58 @@ Indices use Yahoo's tickers (^JKSE, ^JKLQ45) or Stockbit's (IHSG, LQ45).",
|
||||||
/// Single ticker symbol (e.g. BBCA).
|
/// Single ticker symbol (e.g. BBCA).
|
||||||
symbol: String,
|
symbol: String,
|
||||||
},
|
},
|
||||||
|
#[command(
|
||||||
|
about = "Order book depth and foreign flow (Stockbit login)",
|
||||||
|
long_about = "The order book of one IDX stock from your own Stockbit session (`idx stockbit \
|
||||||
|
login`): bid and offer levels with lots and queued orders, the session's OHLC, volume, value \
|
||||||
|
and foreign buy, sell and net. Outside trading it is the book as the market left it, and \
|
||||||
|
the output says so.",
|
||||||
|
after_help = "Examples:\n idx stocks orderbook BBCA\n idx stocks orderbook BBRI --depth 5\n idx -o json stocks orderbook TLKM | jq '.offers[0]'"
|
||||||
|
)]
|
||||||
|
Orderbook {
|
||||||
|
/// Single ticker symbol (e.g. BBCA).
|
||||||
|
symbol: String,
|
||||||
|
/// Price levels per side (1-50).
|
||||||
|
#[arg(long, default_value_t = 10)]
|
||||||
|
depth: usize,
|
||||||
|
},
|
||||||
|
#[command(
|
||||||
|
about = "Dividends, rights, splits and other corporate actions of a stock (Stockbit login)",
|
||||||
|
long_about = "One IDX stock's corporate actions from your own Stockbit session (`idx \
|
||||||
|
stockbit login`), newest first: cash and stock dividends, bonus shares, rights issues, \
|
||||||
|
warrants, splits and reverse splits, shareholders' meetings, public exposes and tender offers \
|
||||||
|
(`--kind ipo` adds its IPO). Stockbit serves one kind per request, 1 s apart, so the first \
|
||||||
|
call takes about a second per kind; answers are cached for 6 hours.",
|
||||||
|
after_help = "Examples:\n idx stocks actions BBCA\n idx stocks actions TLKM --kind dividend\n idx -o json stocks actions BBRI --kind dividend,split | jq '.[0]'"
|
||||||
|
)]
|
||||||
|
Actions {
|
||||||
|
/// Single ticker symbol (e.g. BBCA).
|
||||||
|
symbol: String,
|
||||||
|
/// Kinds to list (default: all but ipo).
|
||||||
|
#[arg(long = "kind", value_enum, value_delimiter = ',', num_args = 1..)]
|
||||||
|
kinds: Vec<ActionKind>,
|
||||||
|
},
|
||||||
|
#[command(
|
||||||
|
about = "Upcoming and recent corporate actions across the market (Stockbit login)",
|
||||||
|
long_about = "Corporate actions across IDX from your own Stockbit session (`idx stockbit \
|
||||||
|
login`), oldest first, dated within --from and --to (default: today and the next 30 days), \
|
||||||
|
plus rights trading, tender offers and IPO offers still open then. An action's date is its \
|
||||||
|
cum date for dividends, bonuses, rights and splits (its ex date when Stockbit gives no cum \
|
||||||
|
date), else the event's first day. Stockbit lists about a year back and what is announced ahead. One request per \
|
||||||
|
kind, 1 s apart; answers are cached for 6 hours.",
|
||||||
|
after_help = "Examples:\n idx stocks calendar\n idx stocks calendar --kind dividend,rights\n idx stocks calendar --from 2026-12-01 --to 2026-12-31 --kind meeting"
|
||||||
|
)]
|
||||||
|
Calendar {
|
||||||
|
/// First day, YYYY-MM-DD. Default: today.
|
||||||
|
#[arg(long)]
|
||||||
|
from: Option<String>,
|
||||||
|
/// Last day, YYYY-MM-DD. Default: 30 days after --from (at most 366).
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// Kinds to list (default: all).
|
||||||
|
#[arg(long = "kind", value_enum, value_delimiter = ',', num_args = 1..)]
|
||||||
|
kinds: Vec<ActionKind>,
|
||||||
|
},
|
||||||
#[command(
|
#[command(
|
||||||
about = "Get historical OHLC data",
|
about = "Get historical OHLC data",
|
||||||
after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk"
|
after_help = "Examples:\n idx stocks history BBCA --period 3mo\n idx stocks history BBCA --period 1y --interval 1wk"
|
||||||
|
|
@ -332,13 +388,47 @@ pub fn handle(
|
||||||
let day = idx.market().intraday(symbol, policy)?;
|
let day = idx.market().intraday(symbol, policy)?;
|
||||||
render_intraday(&day.data, format, no_color)
|
render_intraday(&day.data, format, no_color)
|
||||||
}
|
}
|
||||||
|
StocksSubcommand::Orderbook { symbol, depth } => {
|
||||||
|
if !(1..=MAX_BOOK_DEPTH).contains(depth) {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"--depth must be 1 to {MAX_BOOK_DEPTH} (got {depth})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut book = idx.market().orderbook(symbol, policy)?.data;
|
||||||
|
book.bids.truncate(*depth);
|
||||||
|
book.offers.truncate(*depth);
|
||||||
|
render_orderbook(&book, format, no_color)
|
||||||
|
}
|
||||||
|
StocksSubcommand::Actions { symbol, kinds } => {
|
||||||
|
let kinds = if kinds.is_empty() {
|
||||||
|
ActionKind::STOCK_DEFAULT.to_vec()
|
||||||
|
} else {
|
||||||
|
kinds.clone()
|
||||||
|
};
|
||||||
|
let actions = idx.corporate().actions(symbol, &kinds, policy)?;
|
||||||
|
let resolved = idx_core::api::resolve_symbol(symbol, &config.core.exchange)?;
|
||||||
|
render_corporate_actions(&resolved, &actions.data, format)
|
||||||
|
}
|
||||||
|
StocksSubcommand::Calendar { from, to, kinds } => {
|
||||||
|
let range =
|
||||||
|
CalendarRange::from_args(from.as_deref(), to.as_deref(), idx.corporate().today())?;
|
||||||
|
let kinds = if kinds.is_empty() {
|
||||||
|
ActionKind::ALL.to_vec()
|
||||||
|
} else {
|
||||||
|
kinds.clone()
|
||||||
|
};
|
||||||
|
let actions = idx.corporate().calendar(range, &kinds, policy)?;
|
||||||
|
render_corporate_calendar(&range, &actions.data, format)
|
||||||
|
}
|
||||||
StocksSubcommand::History {
|
StocksSubcommand::History {
|
||||||
symbol,
|
symbol,
|
||||||
period,
|
period,
|
||||||
interval,
|
interval,
|
||||||
history_provider,
|
history_provider,
|
||||||
} => {
|
} => {
|
||||||
let source = idx.market().history_source(*history_provider);
|
let source = idx
|
||||||
|
.market()
|
||||||
|
.history_source(*history_provider, period, interval);
|
||||||
announce_history_fallback(&idx, source, config);
|
announce_history_fallback(&idx, source, config);
|
||||||
let history = idx
|
let history = idx
|
||||||
.market()
|
.market()
|
||||||
|
|
@ -350,7 +440,7 @@ pub fn handle(
|
||||||
symbol,
|
symbol,
|
||||||
history_provider,
|
history_provider,
|
||||||
} => {
|
} => {
|
||||||
let source = idx.market().history_source(*history_provider);
|
let source = idx.analysis().technical_source(*history_provider);
|
||||||
announce_history_fallback(&idx, source, config);
|
announce_history_fallback(&idx, source, config);
|
||||||
let report = idx.analysis().technical(symbol, source, policy)?;
|
let report = idx.analysis().technical(symbol, source, policy)?;
|
||||||
render_technical(&report.data, format, no_color)
|
render_technical(&report.data, format, no_color)
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,17 @@ fn run() -> Result<(), IdxError> {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Commands::Stockbit(cmd) => {
|
||||||
|
if let Err(err) = cli::stockbit::handle(cmd, &config.output) {
|
||||||
|
emit_error(&err, &config.output);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
Commands::Ownership(cmd) => {
|
Commands::Ownership(cmd) => {
|
||||||
if let Err(err) = cli::ownership::handle(&cmd.command, &config) {
|
if let Err(err) =
|
||||||
|
cli::ownership::handle(&cmd.command, &config, cli.offline, cli.no_cache)
|
||||||
|
{
|
||||||
emit_error(&err, &config.output);
|
emit_error(&err, &config.output);
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,14 @@ use idx_core::analysis::fundamental::{
|
||||||
use idx_core::analysis::report::TechnicalReport;
|
use idx_core::analysis::report::TechnicalReport;
|
||||||
use idx_core::api::types::{
|
use idx_core::api::types::{
|
||||||
CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc,
|
CompanyProfile, EarningsReport, FinancialStatements, InsightData, Intraday, NewsItem, Ohlc,
|
||||||
Quote, SentimentData,
|
OrderBook, Quote, SentimentData,
|
||||||
};
|
};
|
||||||
use idx_core::broker::types::BrokerSummary;
|
use idx_core::broker::types::BrokerSummary;
|
||||||
use idx_core::config::ProviderKind;
|
use idx_core::config::ProviderKind;
|
||||||
|
use idx_core::corporate::types::{CalendarRange, CorporateAction};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::flow::types::ForeignFlow;
|
use idx_core::flow::types::ForeignFlow;
|
||||||
|
use idx_core::movements::types::MovementList;
|
||||||
|
|
||||||
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
/// Output format requested by the user (flag, `IDX_OUTPUT`, or config file).
|
||||||
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq, Serialize, serde::Deserialize, Default)]
|
||||||
|
|
@ -92,6 +94,64 @@ pub fn render_intraday(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_orderbook(
|
||||||
|
book: &OrderBook,
|
||||||
|
format: &OutputFormat,
|
||||||
|
no_color: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_orderbook(book, no_color),
|
||||||
|
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(book),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_corporate_actions(
|
||||||
|
symbol: &str,
|
||||||
|
actions: &[CorporateAction],
|
||||||
|
format: &OutputFormat,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_corporate_actions(symbol, actions),
|
||||||
|
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(actions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_corporate_calendar(
|
||||||
|
range: &CalendarRange,
|
||||||
|
actions: &[CorporateAction],
|
||||||
|
format: &OutputFormat,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_corporate_calendar(range, actions),
|
||||||
|
OutputFormat::Json | OutputFormat::Jsonl => json::print_json(actions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON is `{movements, more}` (`more`: rows remain past the pages read), plus
|
||||||
|
/// `incomplete` when a later page failed and only the rows before it came back;
|
||||||
|
/// JSONL is one movement per line.
|
||||||
|
/// `pages` is how many pages the query asked for, for the hint when more
|
||||||
|
/// rows exist. JSONL streams the rows only, so the hint goes to stderr.
|
||||||
|
pub fn render_shareholder_movements(
|
||||||
|
symbol: Option<&str>,
|
||||||
|
list: &MovementList,
|
||||||
|
pages: u32,
|
||||||
|
format: &OutputFormat,
|
||||||
|
no_color: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
match format {
|
||||||
|
OutputFormat::Table => table::print_shareholder_movements(symbol, list, pages, no_color),
|
||||||
|
OutputFormat::Json => json::print_json(list),
|
||||||
|
OutputFormat::Jsonl => {
|
||||||
|
json::print_json(&list.movements)?;
|
||||||
|
if list.more {
|
||||||
|
idx_core::runtime::info(table::more_movements_hint(pages));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_broker_summary(
|
pub fn render_broker_summary(
|
||||||
summary: &BrokerSummary,
|
summary: &BrokerSummary,
|
||||||
format: &OutputFormat,
|
format: &OutputFormat,
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,16 @@ use idx_core::analysis::fundamental::{
|
||||||
use idx_core::analysis::report::TechnicalReport;
|
use idx_core::analysis::report::TechnicalReport;
|
||||||
use idx_core::analysis::signals::Signal;
|
use idx_core::analysis::signals::Signal;
|
||||||
use idx_core::api::types::{
|
use idx_core::api::types::{
|
||||||
CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData, Intraday,
|
BookLevel, CompanyProfile, EarningsData, EarningsReport, FinancialStatements, InsightData,
|
||||||
NewsItem, Ohlc, Quote, SentimentData,
|
Intraday, NewsItem, Ohlc, OrderBook, Quote, SentimentData,
|
||||||
};
|
};
|
||||||
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
use idx_core::broker::types::{BrokerBoard, BrokerFlow, BrokerInvestor, BrokerKind, BrokerSummary};
|
||||||
|
use idx_core::corporate::types::{ActionKind, CalendarRange, CorporateAction, DateRange};
|
||||||
use idx_core::error::IdxError;
|
use idx_core::error::IdxError;
|
||||||
use idx_core::flow::types::{FlowSource, ForeignFlow};
|
use idx_core::flow::types::{FlowSource, ForeignFlow};
|
||||||
|
use idx_core::movements::types::{
|
||||||
|
MovementAction, MovementList, MovementSource, ShareholderMovement,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn format_idr(value: i64) -> String {
|
pub fn format_idr(value: i64) -> String {
|
||||||
let sign = if value.is_negative() { "-" } else { "" };
|
let sign = if value.is_negative() { "-" } else { "" };
|
||||||
|
|
@ -128,6 +132,13 @@ fn pct_cell(change_pct: Option<f64>, no_color: bool) -> Cell {
|
||||||
/// Printed under every Stockbit table: what the data is and is not.
|
/// Printed under every Stockbit table: what the data is and is not.
|
||||||
const STOCKBIT_SOURCE_NOTE: &str = "Source: Stockbit's public page, delayed at least 10 \
|
const STOCKBIT_SOURCE_NOTE: &str = "Source: Stockbit's public page, delayed at least 10 \
|
||||||
minutes (Stockbit's terms); bid/offer only while the market is open.";
|
minutes (Stockbit's terms); bid/offer only while the market is open.";
|
||||||
|
/// Under `--provider stockbit` quotes, which may come from either source.
|
||||||
|
const STOCKBIT_QUOTE_NOTE: &str = "Source: Stockbit, your session when logged in (`idx \
|
||||||
|
stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); \
|
||||||
|
bid/offer only while the market is open.";
|
||||||
|
/// Under the order book, which comes from the user's own session.
|
||||||
|
const STOCKBIT_SESSION_NOTE: &str =
|
||||||
|
"Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).";
|
||||||
|
|
||||||
fn opt_idr(value: Option<i64>) -> String {
|
fn opt_idr(value: Option<i64>) -> String {
|
||||||
value.map(format_idr).unwrap_or_else(|| "-".to_string())
|
value.map(format_idr).unwrap_or_else(|| "-".to_string())
|
||||||
|
|
@ -160,7 +171,12 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx
|
||||||
cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
|
cell(q.volume.map(format_u64).unwrap_or_else(|| "-".to_string())),
|
||||||
cell(opt_idr(q.bid)),
|
cell(opt_idr(q.bid)),
|
||||||
cell(opt_idr(q.offer)),
|
cell(opt_idr(q.offer)),
|
||||||
cell(q.market_status.as_deref().unwrap_or("-")),
|
cell(
|
||||||
|
q.market_status
|
||||||
|
.as_deref()
|
||||||
|
.map(market_words)
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
cell(q.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())),
|
cell(q.as_of.map(wib_minute).unwrap_or_else(|| "-".to_string())),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +193,7 @@ pub fn print_stockbit_quotes(quotes: &[Quote], no_color: bool) -> Result<(), Idx
|
||||||
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
|
for note in stale_quote_notes(quotes, chrono::Utc::now()) {
|
||||||
println!("{}", terminal_safe(¬e));
|
println!("{}", terminal_safe(¬e));
|
||||||
}
|
}
|
||||||
println!("{STOCKBIT_SOURCE_NOTE}");
|
println!("{STOCKBIT_QUOTE_NOTE}");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,6 +278,108 @@ pub fn print_intraday(day: &Intraday, no_color: bool) -> Result<(), IdxError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stockbit's market state for people: `STATE_NAME_MARKET_CLOSED` reads
|
||||||
|
/// `closed`, `STATE_NAME_BREAK` `break`, `STATE_NAME_SESSION_2` `session 2`.
|
||||||
|
fn market_words(state: &str) -> String {
|
||||||
|
state
|
||||||
|
.strip_prefix("STATE_NAME_MARKET_")
|
||||||
|
.or_else(|| state.strip_prefix("STATE_NAME_"))
|
||||||
|
.unwrap_or(state)
|
||||||
|
.replace('_', " ")
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_orderbook(book: &OrderBook, no_color: bool) -> Result<(), IdxError> {
|
||||||
|
let change = match (book.change, book.change_pct) {
|
||||||
|
(Some(c), Some(p)) => format!(" {c:+} ({p:+.2}%)"),
|
||||||
|
(Some(c), None) => format!(" {c:+}"),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
let change = match book.change {
|
||||||
|
_ if no_color || change.is_empty() => change,
|
||||||
|
Some(c) if c >= 0 => change.green(),
|
||||||
|
_ => change.red(),
|
||||||
|
};
|
||||||
|
let name = book
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(|n| format!(" {}", terminal_safe(n)))
|
||||||
|
.unwrap_or_default();
|
||||||
|
println!(
|
||||||
|
"{}{change}",
|
||||||
|
format!(
|
||||||
|
"{}{name} {}",
|
||||||
|
terminal_safe(&book.symbol),
|
||||||
|
format_idr(book.price)
|
||||||
|
)
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
let count = |v: Option<u64>| v.map(format_u64).unwrap_or_else(|| "-".to_string());
|
||||||
|
println!(
|
||||||
|
"prev close {} · open {} · high {} · low {} · volume {} · value {} · {} trades",
|
||||||
|
opt_idr(book.prev_close),
|
||||||
|
opt_idr(book.open),
|
||||||
|
opt_idr(book.high),
|
||||||
|
opt_idr(book.low),
|
||||||
|
count(book.volume),
|
||||||
|
count(book.value),
|
||||||
|
count(book.frequency)
|
||||||
|
);
|
||||||
|
if book.foreign_buy.is_some() || book.foreign_sell.is_some() {
|
||||||
|
println!(
|
||||||
|
"foreign buy {} · sell {} · net {}",
|
||||||
|
opt_idr(book.foreign_buy),
|
||||||
|
opt_idr(book.foreign_sell),
|
||||||
|
opt_idr(book.foreign_net)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match book.market_status.as_deref() {
|
||||||
|
Some(state) if idx_core::api::stockbit::exodus::is_trading(state) => {
|
||||||
|
println!("market open ({})", terminal_safe(&market_words(state)))
|
||||||
|
}
|
||||||
|
Some(state) => println!(
|
||||||
|
"market {}: the book as the market left it",
|
||||||
|
terminal_safe(&market_words(state))
|
||||||
|
),
|
||||||
|
None => println!("market state unknown: the book may not be live"),
|
||||||
|
}
|
||||||
|
if let Some(note) = &book.session_note {
|
||||||
|
println!("Note: {}", terminal_safe(note));
|
||||||
|
}
|
||||||
|
if book.bids.is_empty() && book.offers.is_empty() {
|
||||||
|
println!("No bids or offers.");
|
||||||
|
} else {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec!["ORDERS", "LOTS", "BID", "OFFER", "LOTS", "ORDERS"]);
|
||||||
|
let side = |level: Option<&BookLevel>| match level {
|
||||||
|
Some(l) => [
|
||||||
|
format_u64(l.orders),
|
||||||
|
format_u64(l.shares / 100),
|
||||||
|
format_idr(l.price),
|
||||||
|
],
|
||||||
|
None => ["".to_string(), "".to_string(), "".to_string()],
|
||||||
|
};
|
||||||
|
for i in 0..book.bids.len().max(book.offers.len()) {
|
||||||
|
let [bid_orders, bid_lots, bid] = side(book.bids.get(i));
|
||||||
|
let [offer_orders, offer_lots, offer] = side(book.offers.get(i));
|
||||||
|
table.add_row(vec![
|
||||||
|
cell(bid_orders),
|
||||||
|
cell(bid_lots),
|
||||||
|
cell(bid),
|
||||||
|
cell(offer),
|
||||||
|
cell(offer_lots),
|
||||||
|
cell(offer_orders),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
}
|
||||||
|
println!("{STOCKBIT_SESSION_NOTE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Whole Rupiah for stocks, two decimals for an index level.
|
/// Whole Rupiah for stocks, two decimals for an index level.
|
||||||
fn format_price(price: f64) -> String {
|
fn format_price(price: f64) -> String {
|
||||||
let cents = (price * 100.0).round() as i64;
|
let cents = (price * 100.0).round() as i64;
|
||||||
|
|
@ -1559,15 +1677,382 @@ outside the regular board (negotiated and cash)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn action_kind_label(kind: ActionKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
ActionKind::Dividend => "dividend",
|
||||||
|
ActionKind::StockDividend => "stock dividend",
|
||||||
|
ActionKind::Bonus => "bonus",
|
||||||
|
ActionKind::Rights => "rights",
|
||||||
|
ActionKind::Warrant => "warrant",
|
||||||
|
ActionKind::Split => "split",
|
||||||
|
ActionKind::ReverseSplit => "reverse split",
|
||||||
|
ActionKind::Meeting => "meeting",
|
||||||
|
ActionKind::PublicExpose => "public expose",
|
||||||
|
ActionKind::Ipo => "IPO",
|
||||||
|
ActionKind::TenderOffer => "tender offer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IDR with thousands separators and up to two decimals: `1,191.09`, `3.8`.
|
||||||
|
fn format_rupiah(value: f64) -> String {
|
||||||
|
let cents = (value * 100.0).round() as i64;
|
||||||
|
let (whole, frac) = (cents / 100, (cents % 100).abs());
|
||||||
|
match frac {
|
||||||
|
0 => format_idr(whole),
|
||||||
|
f if f % 10 == 0 => format!("{}.{}", format_idr(whole), f / 10),
|
||||||
|
f => format!("{}.{f:02}", format_idr(whole)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Another currency's amount with up to six decimals, since a dollar
|
||||||
|
/// dividend can be a fraction of a cent: `0.0012`, `1,250.5`.
|
||||||
|
fn format_foreign_amount(value: f64) -> String {
|
||||||
|
let micros = (value.abs() * 1_000_000.0).round() as u64;
|
||||||
|
let (whole, frac) = (micros / 1_000_000, micros % 1_000_000);
|
||||||
|
let sign = if value < 0.0 && micros > 0 { "-" } else { "" };
|
||||||
|
let whole = format_u64(whole);
|
||||||
|
if frac == 0 {
|
||||||
|
return format!("{sign}{whole}");
|
||||||
|
}
|
||||||
|
let frac = format!("{frac:06}");
|
||||||
|
format!("{sign}{whole}.{}", frac.trim_end_matches('0'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What an action gives or asks, in one line.
|
||||||
|
fn action_detail(action: &CorporateAction) -> String {
|
||||||
|
let span = |range: Option<DateRange>| range.map(|r| format!("{} to {}", r.start, r.end));
|
||||||
|
let price = action.price.map(|p| format!("Rp {}", format_idr(p)));
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
match action.kind {
|
||||||
|
ActionKind::Dividend => {
|
||||||
|
let (unit, amount): (String, fn(f64) -> String) = match action.currency.as_deref() {
|
||||||
|
Some(currency) => (terminal_safe(currency), format_foreign_amount),
|
||||||
|
None => ("Rp".to_string(), format_rupiah),
|
||||||
|
};
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.cash_per_share
|
||||||
|
.map(|c| format!("{unit} {} per share", amount(c))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ActionKind::StockDividend | ActionKind::Bonus => {
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.ratio
|
||||||
|
.map(|r| format!("{} new per {} held", format_u64(r.new), format_u64(r.old))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ActionKind::Split | ActionKind::ReverseSplit => {
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.ratio
|
||||||
|
.map(|r| format!("{} becomes {}", format_u64(r.old), format_u64(r.new))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ActionKind::Rights => {
|
||||||
|
parts.extend(action.ratio.map(|r| {
|
||||||
|
format!(
|
||||||
|
"{} rights per {} held",
|
||||||
|
format_u64(r.new),
|
||||||
|
format_u64(r.old)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
parts.extend(price.map(|p| format!("at {p}")));
|
||||||
|
parts.extend(span(action.period).map(|s| format!("trading {s}")));
|
||||||
|
}
|
||||||
|
ActionKind::Warrant => {
|
||||||
|
parts.extend(price.map(|p| format!("exercise at {p}")));
|
||||||
|
parts.extend(span(action.exercise).map(|s| format!("exercisable {s}")));
|
||||||
|
parts.extend(action.period.map(|r| format!("trades until {}", r.end)));
|
||||||
|
}
|
||||||
|
ActionKind::Meeting | ActionKind::PublicExpose => {
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.time
|
||||||
|
.as_deref()
|
||||||
|
.map(|t| format!("{} WIB", terminal_safe(t))),
|
||||||
|
);
|
||||||
|
parts.extend(
|
||||||
|
action
|
||||||
|
.venue
|
||||||
|
.as_deref()
|
||||||
|
.map(|v| truncate_chars(&terminal_safe(v), 60)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ActionKind::Ipo => {
|
||||||
|
parts.extend(price);
|
||||||
|
parts.extend(action.shares.map(|s| format!("{} shares", format_u64(s))));
|
||||||
|
parts.extend(span(action.period).map(|s| format!("offered {s}")));
|
||||||
|
}
|
||||||
|
ActionKind::TenderOffer => {
|
||||||
|
parts.extend(price);
|
||||||
|
parts.extend(action.shares.map(|s| {
|
||||||
|
let share = action
|
||||||
|
.percentage_bps
|
||||||
|
.map(|bps| format!(" ({}.{:02}%)", bps / 100, bps % 100))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("for {} shares{share}", format_u64(s))
|
||||||
|
}));
|
||||||
|
parts.extend(span(action.period).map(|s| format!("open {s}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
parts.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORPORATE_DATE_NOTE: &str = "DATE is the cum date for dividends, bonuses, rights and \
|
||||||
|
splits (buy by that day to be entitled); for other actions, the event's first day.";
|
||||||
|
const CORPORATE_EX_NOTE: &str = "A DATE marked (ex) is the ex date: Stockbit gives no cum \
|
||||||
|
date for it, and buying that day is too late to be entitled.";
|
||||||
|
|
||||||
|
/// An entitlement Stockbit gave no cum date for sits on its ex date.
|
||||||
|
fn on_ex_date(action: &CorporateAction) -> bool {
|
||||||
|
action.kind.is_entitlement() && action.cum_date.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_date_cell(action: &CorporateAction) -> Cell {
|
||||||
|
if on_ex_date(action) {
|
||||||
|
cell(format!("{} (ex)", action.date))
|
||||||
|
} else {
|
||||||
|
cell(action.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_corporate_notes(actions: &[CorporateAction]) {
|
||||||
|
println!("{CORPORATE_DATE_NOTE}");
|
||||||
|
if actions.iter().any(on_ex_date) {
|
||||||
|
println!("{CORPORATE_EX_NOTE}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn opt_date(date: Option<chrono::NaiveDate>) -> String {
|
||||||
|
date.map(|d| d.to_string())
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_corporate_actions(symbol: &str, actions: &[CorporateAction]) -> Result<(), IdxError> {
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!("Corporate actions for {}", terminal_safe(symbol)).bold()
|
||||||
|
);
|
||||||
|
if actions.is_empty() {
|
||||||
|
println!("None of these kinds on record.");
|
||||||
|
} else {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec!["DATE", "KIND", "EX", "RECORD", "PAY", "DETAIL"]);
|
||||||
|
for action in actions {
|
||||||
|
table.add_row(vec![
|
||||||
|
action_date_cell(action),
|
||||||
|
cell(action_kind_label(action.kind)),
|
||||||
|
cell(opt_date(action.ex_date)),
|
||||||
|
cell(opt_date(action.record_date)),
|
||||||
|
cell(opt_date(action.pay_date)),
|
||||||
|
cell(action_detail(action)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
print_corporate_notes(actions);
|
||||||
|
}
|
||||||
|
println!("{STOCKBIT_SESSION_NOTE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_corporate_calendar(
|
||||||
|
range: &CalendarRange,
|
||||||
|
actions: &[CorporateAction],
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
// Offers still open are listed by the day they opened, which can be
|
||||||
|
// before `from`; the title says so rather than claim the range bounds
|
||||||
|
// every row.
|
||||||
|
let earlier = if actions.iter().any(|a| a.date < range.from) {
|
||||||
|
", with offers opened earlier and still open"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
format!(
|
||||||
|
"Corporate actions from {} to {}{earlier} ({})",
|
||||||
|
range.from,
|
||||||
|
range.to,
|
||||||
|
actions.len()
|
||||||
|
)
|
||||||
|
.bold()
|
||||||
|
);
|
||||||
|
if actions.is_empty() {
|
||||||
|
println!("None of these kinds in that range.");
|
||||||
|
} else {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec!["DATE", "SYMBOL", "KIND", "EX", "DETAIL"]);
|
||||||
|
for action in actions {
|
||||||
|
table.add_row(vec![
|
||||||
|
action_date_cell(action),
|
||||||
|
cell(action.symbol.trim_end_matches(".JK")),
|
||||||
|
cell(action_kind_label(action.kind)),
|
||||||
|
cell(opt_date(action.ex_date)),
|
||||||
|
cell(action_detail(action)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
print_corporate_notes(actions);
|
||||||
|
}
|
||||||
|
println!("{STOCKBIT_SESSION_NOTE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn movement_action_label(action: MovementAction, no_color: bool) -> String {
|
||||||
|
let label = action.as_str().replace('_', " ");
|
||||||
|
match action {
|
||||||
|
_ if no_color => label,
|
||||||
|
MovementAction::Buy => label.green().to_string(),
|
||||||
|
MovementAction::Sell => label.red().to_string(),
|
||||||
|
_ => label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signed exact shares: `+147,933`, `-10,000,000`.
|
||||||
|
fn format_signed_shares(value: i64) -> String {
|
||||||
|
let text = format_idr(value);
|
||||||
|
if value > 0 { format!("+{text}") } else { text }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stake after the change and the change in percentage points:
|
||||||
|
/// `40.65% (-0.16)`. A stake that rounds to 0 basis points reads `<0.01%`.
|
||||||
|
fn movement_stake(movement: &ShareholderMovement) -> String {
|
||||||
|
let after = match movement.percentage_after_bps {
|
||||||
|
0 if movement.shares_after > 0 => "<0.01%".to_string(),
|
||||||
|
bps => format!("{:.2}%", bps as f64 / 100.0),
|
||||||
|
};
|
||||||
|
match movement.percentage_change_bps {
|
||||||
|
0 => after,
|
||||||
|
bps => format!("{after} ({:+.2})", bps as f64 / 100.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOVEMENT_NOTE: &str = "IDX rows are insider disclosures (directors, commissioners and \
|
||||||
|
other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated \
|
||||||
|
price. Rows appear days after the change.";
|
||||||
|
|
||||||
|
/// What to do when rows remain past the pages read.
|
||||||
|
pub fn more_movements_hint(pages: u32) -> String {
|
||||||
|
if pages >= idx_core::movements::types::MAX_PAGES {
|
||||||
|
"More rows exist: narrow --from/--to (10 pages is the most one query reads).".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"More rows exist: raise --pages (up to {}) or narrow --from/--to.",
|
||||||
|
idx_core::movements::types::MAX_PAGES
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_shareholder_movements(
|
||||||
|
symbol: Option<&str>,
|
||||||
|
list: &MovementList,
|
||||||
|
pages: u32,
|
||||||
|
no_color: bool,
|
||||||
|
) -> Result<(), IdxError> {
|
||||||
|
let title = match symbol {
|
||||||
|
Some(symbol) => format!("Shareholder movements for {}", terminal_safe(symbol)),
|
||||||
|
None => "Shareholder movements across IDX".to_string(),
|
||||||
|
};
|
||||||
|
println!("{}", format!("{title} ({})", list.movements.len()).bold());
|
||||||
|
if list.movements.is_empty() {
|
||||||
|
println!("No movements reported for this query.");
|
||||||
|
} else {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table
|
||||||
|
.load_preset(UTF8_FULL)
|
||||||
|
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||||
|
.set_header(vec![
|
||||||
|
"DATE",
|
||||||
|
"SYMBOL",
|
||||||
|
"HOLDER",
|
||||||
|
"ROLE",
|
||||||
|
"ACTION",
|
||||||
|
"SHARES",
|
||||||
|
"STAKE",
|
||||||
|
"PRICE",
|
||||||
|
"VALUE",
|
||||||
|
"SOURCE",
|
||||||
|
"HOLDER ID",
|
||||||
|
]);
|
||||||
|
for movement in &list.movements {
|
||||||
|
let estimated = if movement.price_estimated { "~" } else { "" };
|
||||||
|
table.add_row(vec![
|
||||||
|
cell(movement.date),
|
||||||
|
cell(&movement.symbol),
|
||||||
|
cell(&movement.holder),
|
||||||
|
cell(if movement.roles.is_empty() {
|
||||||
|
"-".to_string()
|
||||||
|
} else {
|
||||||
|
movement.roles.join(", ")
|
||||||
|
}),
|
||||||
|
painted(movement_action_label(movement.action, no_color)),
|
||||||
|
cell(format_signed_shares(movement.shares_change)),
|
||||||
|
cell(movement_stake(movement)),
|
||||||
|
cell(
|
||||||
|
movement
|
||||||
|
.price
|
||||||
|
.map(|p| format!("{estimated}{}", format_idr(p)))
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
|
cell(
|
||||||
|
movement
|
||||||
|
.value
|
||||||
|
.map(|v| format!("{estimated}{}", format_compact(v)))
|
||||||
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
|
),
|
||||||
|
cell(match movement.source {
|
||||||
|
Some(MovementSource::Idx) => "IDX",
|
||||||
|
Some(MovementSource::Ksei) => "KSEI",
|
||||||
|
None => "-",
|
||||||
|
}),
|
||||||
|
cell(&movement.holder_id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
println!("{table}");
|
||||||
|
println!("{MOVEMENT_NOTE}");
|
||||||
|
}
|
||||||
|
if let Some(reason) = &list.incomplete {
|
||||||
|
println!(
|
||||||
|
"Reading stopped early ({}); these are the rows read before it.",
|
||||||
|
terminal_safe(reason)
|
||||||
|
);
|
||||||
|
} else if list.more {
|
||||||
|
println!("{}", more_movements_hint(pages));
|
||||||
|
}
|
||||||
|
println!("{STOCKBIT_SESSION_NOTE}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
format_earnings_period, format_idr, format_signal, format_table_date, format_u64,
|
format_earnings_period, format_foreign_amount, format_idr, format_rupiah, format_signal,
|
||||||
humanize_metric_key, stale_quote_notes,
|
format_table_date, format_u64, humanize_metric_key, stale_quote_notes,
|
||||||
};
|
};
|
||||||
use idx_core::analysis::signals::Signal;
|
use idx_core::analysis::signals::Signal;
|
||||||
use idx_core::api::types::Quote;
|
use idx_core::api::types::Quote;
|
||||||
|
|
||||||
|
/// Rupiah to the sen; another currency keeps a fraction of a cent.
|
||||||
|
#[test]
|
||||||
|
fn dividend_amounts_keep_their_decimals() {
|
||||||
|
assert_eq!(format_rupiah(1191.09), "1,191.09");
|
||||||
|
assert_eq!(format_rupiah(3.8), "3.8");
|
||||||
|
assert_eq!(format_foreign_amount(0.0012), "0.0012");
|
||||||
|
assert_eq!(format_foreign_amount(1250.5), "1,250.5");
|
||||||
|
assert_eq!(format_foreign_amount(2.0), "2");
|
||||||
|
}
|
||||||
|
|
||||||
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
|
fn quote_as_of(symbol: &str, as_of: &str) -> Quote {
|
||||||
Quote {
|
Quote {
|
||||||
symbol: symbol.into(),
|
symbol: symbol.into(),
|
||||||
|
|
|
||||||
|
|
@ -2137,6 +2137,156 @@ fn provider_flag_overrides_env() {
|
||||||
assert_eq!(quotes[0]["market_cap"], Value::Null);
|
assert_eq!(quotes[0]["market_cap"], Value::Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stockbit_login_stores_a_private_session_and_logout_removes_it() {
|
||||||
|
let root = test_env_dir("stockbit-login");
|
||||||
|
let cookie = fs::read_to_string(fixture_path(
|
||||||
|
"stockbit_exodus_credential_storage_cookie_constructed.txt",
|
||||||
|
))
|
||||||
|
.expect("cookie fixture");
|
||||||
|
let session = root.join("config/idx/stockbit-session.json");
|
||||||
|
|
||||||
|
let status = run_success_json(
|
||||||
|
bin_with_root(&root)
|
||||||
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
|
.args(["-o", "json", "stockbit", "login"])
|
||||||
|
.write_stdin(cookie.clone()),
|
||||||
|
);
|
||||||
|
assert_eq!(status["logged_in"], true);
|
||||||
|
assert_eq!(status["refresh"]["present"], true);
|
||||||
|
let stored = fs::read_to_string(&session).expect("session file");
|
||||||
|
assert!(stored.contains("\"refresh\""));
|
||||||
|
// Status shows expiries, never a token.
|
||||||
|
assert!(!serde_json::to_string(&status).unwrap().contains("eyJ"));
|
||||||
|
#[cfg(unix)]
|
||||||
|
assert_eq!(
|
||||||
|
fs::metadata(&session).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
|
||||||
|
let book = run_success_json(
|
||||||
|
bin_with_root(&root)
|
||||||
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
|
.args(["-o", "json", "stocks", "orderbook", "BBCA"]),
|
||||||
|
);
|
||||||
|
assert_eq!(book["offers"][0]["shares"], 264_462_500);
|
||||||
|
|
||||||
|
let out = run_success_json(
|
||||||
|
bin_with_root(&root)
|
||||||
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
|
.args(["-o", "json", "stockbit", "logout"]),
|
||||||
|
);
|
||||||
|
assert_eq!(out["removed"], true);
|
||||||
|
assert!(!session.exists());
|
||||||
|
run_error_json(
|
||||||
|
bin_with_root(&root)
|
||||||
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
|
// The book fetched above is still cached for a few seconds.
|
||||||
|
.args(["-o", "json", "--no-cache", "stocks", "orderbook", "BBCA"]),
|
||||||
|
"AUTHERROR",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anything that is not a session is refused and stores nothing.
|
||||||
|
run_error_json(
|
||||||
|
bin_with_root(&root)
|
||||||
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
|
.args(["-o", "json", "stockbit", "login"])
|
||||||
|
.write_stdin("not a token"),
|
||||||
|
"INVALIDINPUT",
|
||||||
|
);
|
||||||
|
assert!(!session.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session file Stockbit no longer accepts does not break `auto` history:
|
||||||
|
/// it falls back to Yahoo with a warning. An explicit Stockbit choice fails.
|
||||||
|
#[test]
|
||||||
|
fn auto_history_falls_back_to_yahoo_when_the_session_is_refused() {
|
||||||
|
for args in [
|
||||||
|
&["stocks", "history", "BBCA", "--period", "1mo"][..],
|
||||||
|
&["stocks", "technical", "BBCA"][..],
|
||||||
|
] {
|
||||||
|
let root = test_env_dir(&format!("stockbit-refused-{}", args[1]));
|
||||||
|
let output = mock_bin_with_root(&root)
|
||||||
|
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
|
||||||
|
.env("IDX_MOCK_STOCKBIT_STATUS", "401")
|
||||||
|
.args(["-o", "json", "--provider", "stockbit"])
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.expect("run history");
|
||||||
|
assert!(output.status.success(), "{args:?}: {output:?}");
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(stderr.contains("falls back to yahoo"), "{stderr}");
|
||||||
|
serde_json::from_slice::<Value>(&output.stdout).expect("JSON on stdout");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
let root = test_env_dir("stockbit-refused-explicit");
|
||||||
|
run_error_json(
|
||||||
|
mock_bin_with_root(&root)
|
||||||
|
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
|
||||||
|
.env("IDX_MOCK_STOCKBIT_STATUS", "401")
|
||||||
|
.args([
|
||||||
|
"-o",
|
||||||
|
"json",
|
||||||
|
"--provider",
|
||||||
|
"stockbit",
|
||||||
|
"stocks",
|
||||||
|
"history",
|
||||||
|
"BBCA",
|
||||||
|
])
|
||||||
|
.args(["--period", "1mo", "--history-provider", "stockbit"]),
|
||||||
|
"AUTHERROR",
|
||||||
|
);
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A page after the first that fails keeps the rows read before it, says
|
||||||
|
/// why, and is not cached; a query's pages are cached as one answer.
|
||||||
|
#[test]
|
||||||
|
fn movements_keep_the_pages_read_when_a_later_one_fails() {
|
||||||
|
let root = test_env_dir("movements-partial");
|
||||||
|
let run = |fail_page: Option<&str>| {
|
||||||
|
let mut cmd = mock_bin_with_root(&root);
|
||||||
|
cmd.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
|
||||||
|
.args(["-o", "json", "ownership", "movements", "--pages", "2"]);
|
||||||
|
if let Some(page) = fail_page {
|
||||||
|
cmd.env("IDX_MOCK_MOVEMENTS_FAIL_PAGE", page);
|
||||||
|
}
|
||||||
|
run_success_json(&mut cmd)
|
||||||
|
};
|
||||||
|
let partial = run(Some("2"));
|
||||||
|
assert_eq!(partial["movements"].as_array().unwrap().len(), 8);
|
||||||
|
assert_eq!(partial["more"], true);
|
||||||
|
assert!(partial["incomplete"].is_string(), "{partial}");
|
||||||
|
// Not cached: the next run reads both pages.
|
||||||
|
let full = run(None);
|
||||||
|
assert_eq!(full["movements"].as_array().unwrap().len(), 14);
|
||||||
|
assert!(full.get("incomplete").is_none());
|
||||||
|
// Cached as one answer: a failing page 2 now changes nothing.
|
||||||
|
assert_eq!(run(Some("2")), full);
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A test session, signed by nobody, that expires in 2100.
|
||||||
|
const FAKE_STOCKBIT_TOKEN: &str = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln";
|
||||||
|
|
||||||
|
/// An agent on a headless box gets told how to log in, not "nothing pasted".
|
||||||
|
#[test]
|
||||||
|
fn stockbit_login_with_empty_stdin_says_how_to_log_in() {
|
||||||
|
let root = test_env_dir("stockbit-login-empty");
|
||||||
|
let output = bin_with_root(&root)
|
||||||
|
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||||
|
.args(["-o", "json", "stockbit", "login"])
|
||||||
|
.output()
|
||||||
|
.expect("run login");
|
||||||
|
assert!(!output.status.success());
|
||||||
|
let error: Value = serde_json::from_slice(&output.stderr).expect("JSON error");
|
||||||
|
assert_eq!(error["code"], "INVALIDINPUT");
|
||||||
|
let message = error["message"].as_str().unwrap();
|
||||||
|
for hint in ["idx stockbit login", "IDX_STOCKBIT_REFRESH_TOKEN"] {
|
||||||
|
assert!(message.contains(hint), "{message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invalid_quote_ttl_env_returns_non_zero() {
|
fn invalid_quote_ttl_env_returns_non_zero() {
|
||||||
test_bin("invalid-quote-ttl")
|
test_bin("invalid-quote-ttl")
|
||||||
|
|
@ -3143,3 +3293,24 @@ fn bandar_never_reads_a_broker_summary_cached_in_the_old_shape() {
|
||||||
assert!(!table.contains("No trades."), "{table}");
|
assert!(!table.contains("No trades."), "{table}");
|
||||||
assert!(table.contains("big distribution"), "{table}");
|
assert!(table.contains("big distribution"), "{table}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `--kind` takes the names JSON carried before they matched it, so a kind
|
||||||
|
/// copied from an older answer works here as it does over MCP.
|
||||||
|
#[test]
|
||||||
|
fn actions_kind_takes_the_old_json_names() {
|
||||||
|
let root = test_env_dir("actions-old-kinds");
|
||||||
|
let rows = run_success_json(
|
||||||
|
mock_bin_with_root(&root)
|
||||||
|
.env("IDX_STOCKBIT_REFRESH_TOKEN", FAKE_STOCKBIT_TOKEN)
|
||||||
|
.args(["-o", "json", "stocks", "actions", "BBCA", "--kind"])
|
||||||
|
.arg("stock_dividend,reverse_split,public_expose,tender_offer"),
|
||||||
|
);
|
||||||
|
let kinds: Vec<&str> = rows
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|row| row["kind"].as_str().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert!(kinds.contains(&"expose"), "{kinds:?}");
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,52 @@ args = ["stocks", "intraday", "MABA"]
|
||||||
name = "stockbit-quote"
|
name = "stockbit-quote"
|
||||||
args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"]
|
args = ["--provider", "stockbit", "stocks", "quote", "BBCA,MABA"]
|
||||||
|
|
||||||
|
# With a session: IDX_STOCKBIT_REFRESH_TOKEN seeds an unsigned test token that
|
||||||
|
# expires in 2100; the mock serves the recorded exodus responses.
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-session-orderbook"
|
||||||
|
args = ["stocks", "orderbook", "BBCA", "--depth", "3"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-session-quote"
|
||||||
|
args = ["--provider", "stockbit", "stocks", "quote", "BBCA"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-session-history"
|
||||||
|
args = ["--provider", "stockbit", "stocks", "history", "BBCA", "--period", "1mo"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-status-logged-out"
|
||||||
|
args = ["stockbit", "status"]
|
||||||
|
|
||||||
|
# Corporate actions: BBCA's recorded dividends, splits, meetings and exposes
|
||||||
|
# (no fixture for a kind means none, as Stockbit answers); the calendar reads
|
||||||
|
# the market-wide recordings of 2026-11-03.
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-actions"
|
||||||
|
args = ["stocks", "actions", "BBCA", "--kind", "split,meeting,expose"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-actions-dividends"
|
||||||
|
args = ["stocks", "actions", "BBCA", "--kind", "dividend"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-calendar"
|
||||||
|
args = ["stocks", "calendar", "--to", "2026-11-12", "--kind", "dividend,bonus,rights,warrant,split,ipo,tender"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", IDX_MOCK_TODAY = "2026-11-03" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "stockbit-calendar-meetings"
|
||||||
|
args = ["stocks", "calendar", "--from", "2026-11-05", "--to", "2026-11-06", "--kind", "meeting,expose"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", IDX_MOCK_TODAY = "2026-11-03" }
|
||||||
|
|
||||||
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
|
# --- stocks: broker summaries (IPOT) and foreign flow (IDX) --------------------
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
|
|
@ -366,8 +412,50 @@ args = ["stocks", "intraday", "ZZZZ"]
|
||||||
name = "error-stockbit-fundamental-unsupported"
|
name = "error-stockbit-fundamental-unsupported"
|
||||||
args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"]
|
args = ["--provider", "stockbit", "stocks", "fundamental", "BBCA"]
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "error-orderbook-no-session"
|
||||||
|
args = ["stocks", "orderbook", "BBCA"]
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "error-actions-no-session"
|
||||||
|
args = ["stocks", "actions", "BBCA"]
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "error-movements-no-session"
|
||||||
|
args = ["ownership", "movements", "BBCA"]
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "error-movements-holder"
|
||||||
|
args = ["ownership", "movements", "--holder", "RUDI"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "error-calendar-range"
|
||||||
|
args = ["stocks", "calendar", "--from", "2026-11-10", "--to", "2026-11-01"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln", IDX_MOCK_TODAY = "2026-11-03" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "error-stockbit-history-window"
|
||||||
|
args = ["stocks", "history", "BBCA", "--period", "5y", "--history-provider", "stockbit"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
# --- ownership --------------------------------------------------------------------
|
# --- ownership --------------------------------------------------------------------
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "ownership-movements"
|
||||||
|
args = ["ownership", "movements", "--pages", "2"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "ownership-movements-bbca"
|
||||||
|
args = ["ownership", "movements", "BBCA"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
|
[[case]]
|
||||||
|
name = "ownership-movements-ksei-sells"
|
||||||
|
args = ["ownership", "movements", "--action", "sell", "--source", "ksei", "--pages", "3"]
|
||||||
|
env = { IDX_STOCKBIT_REFRESH_TOKEN = "eyJhbGciOiJub25lIn0.eyJleHAiOiA0MTAyNDQ0ODAwLCAibm90ZSI6ICJ0ZXN0IHRva2VuLCBzaWduZWQgYnkgbm9ib2R5In0.c2ln" }
|
||||||
|
|
||||||
[[case]]
|
[[case]]
|
||||||
name = "ownership-releases-empty"
|
name = "ownership-releases-empty"
|
||||||
args = ["ownership", "releases"]
|
args = ["ownership", "releases"]
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ _idx() {
|
||||||
idx,ownership)
|
idx,ownership)
|
||||||
cmd="idx__ownership"
|
cmd="idx__ownership"
|
||||||
;;
|
;;
|
||||||
|
idx,stockbit)
|
||||||
|
cmd="idx__stockbit"
|
||||||
|
;;
|
||||||
idx,stocks)
|
idx,stocks)
|
||||||
cmd="idx__stocks"
|
cmd="idx__stocks"
|
||||||
;;
|
;;
|
||||||
|
|
@ -102,6 +105,9 @@ _idx() {
|
||||||
idx__help,ownership)
|
idx__help,ownership)
|
||||||
cmd="idx__help__ownership"
|
cmd="idx__help__ownership"
|
||||||
;;
|
;;
|
||||||
|
idx__help,stockbit)
|
||||||
|
cmd="idx__help__stockbit"
|
||||||
|
;;
|
||||||
idx__help,stocks)
|
idx__help,stocks)
|
||||||
cmd="idx__help__stocks"
|
cmd="idx__help__stocks"
|
||||||
;;
|
;;
|
||||||
|
|
@ -150,6 +156,9 @@ _idx() {
|
||||||
idx__help__ownership,import)
|
idx__help__ownership,import)
|
||||||
cmd="idx__help__ownership__import"
|
cmd="idx__help__ownership__import"
|
||||||
;;
|
;;
|
||||||
|
idx__help__ownership,movements)
|
||||||
|
cmd="idx__help__ownership__movements"
|
||||||
|
;;
|
||||||
idx__help__ownership,releases)
|
idx__help__ownership,releases)
|
||||||
cmd="idx__help__ownership__releases"
|
cmd="idx__help__ownership__releases"
|
||||||
;;
|
;;
|
||||||
|
|
@ -174,12 +183,27 @@ _idx() {
|
||||||
idx__help__ownership__resolve,unresolved)
|
idx__help__ownership__resolve,unresolved)
|
||||||
cmd="idx__help__ownership__resolve__unresolved"
|
cmd="idx__help__ownership__resolve__unresolved"
|
||||||
;;
|
;;
|
||||||
|
idx__help__stockbit,login)
|
||||||
|
cmd="idx__help__stockbit__login"
|
||||||
|
;;
|
||||||
|
idx__help__stockbit,logout)
|
||||||
|
cmd="idx__help__stockbit__logout"
|
||||||
|
;;
|
||||||
|
idx__help__stockbit,status)
|
||||||
|
cmd="idx__help__stockbit__status"
|
||||||
|
;;
|
||||||
|
idx__help__stocks,actions)
|
||||||
|
cmd="idx__help__stocks__actions"
|
||||||
|
;;
|
||||||
idx__help__stocks,bandar)
|
idx__help__stocks,bandar)
|
||||||
cmd="idx__help__stocks__bandar"
|
cmd="idx__help__stocks__bandar"
|
||||||
;;
|
;;
|
||||||
idx__help__stocks,broker)
|
idx__help__stocks,broker)
|
||||||
cmd="idx__help__stocks__broker"
|
cmd="idx__help__stocks__broker"
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks,calendar)
|
||||||
|
cmd="idx__help__stocks__calendar"
|
||||||
|
;;
|
||||||
idx__help__stocks,compare)
|
idx__help__stocks,compare)
|
||||||
cmd="idx__help__stocks__compare"
|
cmd="idx__help__stocks__compare"
|
||||||
;;
|
;;
|
||||||
|
|
@ -210,6 +234,9 @@ _idx() {
|
||||||
idx__help__stocks,news)
|
idx__help__stocks,news)
|
||||||
cmd="idx__help__stocks__news"
|
cmd="idx__help__stocks__news"
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks,orderbook)
|
||||||
|
cmd="idx__help__stocks__orderbook"
|
||||||
|
;;
|
||||||
idx__help__stocks,profile)
|
idx__help__stocks,profile)
|
||||||
cmd="idx__help__stocks__profile"
|
cmd="idx__help__stocks__profile"
|
||||||
;;
|
;;
|
||||||
|
|
@ -258,6 +285,9 @@ _idx() {
|
||||||
idx__ownership,import)
|
idx__ownership,import)
|
||||||
cmd="idx__ownership__import"
|
cmd="idx__ownership__import"
|
||||||
;;
|
;;
|
||||||
|
idx__ownership,movements)
|
||||||
|
cmd="idx__ownership__movements"
|
||||||
|
;;
|
||||||
idx__ownership,releases)
|
idx__ownership,releases)
|
||||||
cmd="idx__ownership__releases"
|
cmd="idx__ownership__releases"
|
||||||
;;
|
;;
|
||||||
|
|
@ -300,6 +330,9 @@ _idx() {
|
||||||
idx__ownership__help,import)
|
idx__ownership__help,import)
|
||||||
cmd="idx__ownership__help__import"
|
cmd="idx__ownership__help__import"
|
||||||
;;
|
;;
|
||||||
|
idx__ownership__help,movements)
|
||||||
|
cmd="idx__ownership__help__movements"
|
||||||
|
;;
|
||||||
idx__ownership__help,releases)
|
idx__ownership__help,releases)
|
||||||
cmd="idx__ownership__help__releases"
|
cmd="idx__ownership__help__releases"
|
||||||
;;
|
;;
|
||||||
|
|
@ -348,12 +381,42 @@ _idx() {
|
||||||
idx__ownership__resolve__help,unresolved)
|
idx__ownership__resolve__help,unresolved)
|
||||||
cmd="idx__ownership__resolve__help__unresolved"
|
cmd="idx__ownership__resolve__help__unresolved"
|
||||||
;;
|
;;
|
||||||
|
idx__stockbit,help)
|
||||||
|
cmd="idx__stockbit__help"
|
||||||
|
;;
|
||||||
|
idx__stockbit,login)
|
||||||
|
cmd="idx__stockbit__login"
|
||||||
|
;;
|
||||||
|
idx__stockbit,logout)
|
||||||
|
cmd="idx__stockbit__logout"
|
||||||
|
;;
|
||||||
|
idx__stockbit,status)
|
||||||
|
cmd="idx__stockbit__status"
|
||||||
|
;;
|
||||||
|
idx__stockbit__help,help)
|
||||||
|
cmd="idx__stockbit__help__help"
|
||||||
|
;;
|
||||||
|
idx__stockbit__help,login)
|
||||||
|
cmd="idx__stockbit__help__login"
|
||||||
|
;;
|
||||||
|
idx__stockbit__help,logout)
|
||||||
|
cmd="idx__stockbit__help__logout"
|
||||||
|
;;
|
||||||
|
idx__stockbit__help,status)
|
||||||
|
cmd="idx__stockbit__help__status"
|
||||||
|
;;
|
||||||
|
idx__stocks,actions)
|
||||||
|
cmd="idx__stocks__actions"
|
||||||
|
;;
|
||||||
idx__stocks,bandar)
|
idx__stocks,bandar)
|
||||||
cmd="idx__stocks__bandar"
|
cmd="idx__stocks__bandar"
|
||||||
;;
|
;;
|
||||||
idx__stocks,broker)
|
idx__stocks,broker)
|
||||||
cmd="idx__stocks__broker"
|
cmd="idx__stocks__broker"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks,calendar)
|
||||||
|
cmd="idx__stocks__calendar"
|
||||||
|
;;
|
||||||
idx__stocks,compare)
|
idx__stocks,compare)
|
||||||
cmd="idx__stocks__compare"
|
cmd="idx__stocks__compare"
|
||||||
;;
|
;;
|
||||||
|
|
@ -387,6 +450,9 @@ _idx() {
|
||||||
idx__stocks,news)
|
idx__stocks,news)
|
||||||
cmd="idx__stocks__news"
|
cmd="idx__stocks__news"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks,orderbook)
|
||||||
|
cmd="idx__stocks__orderbook"
|
||||||
|
;;
|
||||||
idx__stocks,profile)
|
idx__stocks,profile)
|
||||||
cmd="idx__stocks__profile"
|
cmd="idx__stocks__profile"
|
||||||
;;
|
;;
|
||||||
|
|
@ -408,12 +474,18 @@ _idx() {
|
||||||
idx__stocks,valuation)
|
idx__stocks,valuation)
|
||||||
cmd="idx__stocks__valuation"
|
cmd="idx__stocks__valuation"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help,actions)
|
||||||
|
cmd="idx__stocks__help__actions"
|
||||||
|
;;
|
||||||
idx__stocks__help,bandar)
|
idx__stocks__help,bandar)
|
||||||
cmd="idx__stocks__help__bandar"
|
cmd="idx__stocks__help__bandar"
|
||||||
;;
|
;;
|
||||||
idx__stocks__help,broker)
|
idx__stocks__help,broker)
|
||||||
cmd="idx__stocks__help__broker"
|
cmd="idx__stocks__help__broker"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help,calendar)
|
||||||
|
cmd="idx__stocks__help__calendar"
|
||||||
|
;;
|
||||||
idx__stocks__help,compare)
|
idx__stocks__help,compare)
|
||||||
cmd="idx__stocks__help__compare"
|
cmd="idx__stocks__help__compare"
|
||||||
;;
|
;;
|
||||||
|
|
@ -447,6 +519,9 @@ _idx() {
|
||||||
idx__stocks__help,news)
|
idx__stocks__help,news)
|
||||||
cmd="idx__stocks__help__news"
|
cmd="idx__stocks__help__news"
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help,orderbook)
|
||||||
|
cmd="idx__stocks__help__orderbook"
|
||||||
|
;;
|
||||||
idx__stocks__help,profile)
|
idx__stocks__help,profile)
|
||||||
cmd="idx__stocks__help__profile"
|
cmd="idx__stocks__help__profile"
|
||||||
;;
|
;;
|
||||||
|
|
@ -475,7 +550,7 @@ _idx() {
|
||||||
|
|
||||||
case "${cmd}" in
|
case "${cmd}" in
|
||||||
idx)
|
idx)
|
||||||
opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership completions version help"
|
opts="-o -q -v -V -h --output --no-color --quiet --verbose --provider --offline --no-cache --version --help stocks config cache ownership stockbit completions version help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -875,7 +950,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__help)
|
idx__help)
|
||||||
opts="stocks config cache ownership completions version help"
|
opts="stocks config cache ownership stockbit completions version help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1029,7 +1104,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__help__ownership)
|
idx__help__ownership)
|
||||||
opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases"
|
opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1154,6 +1229,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__help__ownership__movements)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__help__ownership__releases)
|
idx__help__ownership__releases)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -1266,8 +1355,8 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__help__stocks)
|
idx__help__stockbit)
|
||||||
opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
|
opts="login status logout"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1280,6 +1369,76 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__help__stockbit__login)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__help__stockbit__logout)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__help__stockbit__status)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__help__stocks)
|
||||||
|
opts="quote intraday orderbook actions calendar history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__help__stocks__actions)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__help__stocks__bandar)
|
idx__help__stocks__bandar)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -1308,6 +1467,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks__calendar)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__help__stocks__compare)
|
idx__help__stocks__compare)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -1448,6 +1621,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__help__stocks__orderbook)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__help__stocks__profile)
|
idx__help__stocks__profile)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -1561,7 +1748,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__ownership)
|
idx__ownership)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1809,7 +1996,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__ownership__help)
|
idx__ownership__help)
|
||||||
opts="discover import sync ticker entity search cross-holders concentration flow changes graph resolve releases help"
|
opts="discover import sync ticker entity search cross-holders concentration flow changes movements graph resolve releases help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1948,6 +2135,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__ownership__help__movements)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__ownership__help__releases)
|
idx__ownership__help__releases)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -2098,6 +2299,56 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__ownership__movements)
|
||||||
|
opts="-o -q -v -h --from --to --holder --action --source --pages --output --no-color --quiet --verbose --provider --offline --no-cache --help [SYMBOL]"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--from)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--to)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--holder)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--action)
|
||||||
|
COMPREPLY=($(compgen -W "buy sell transfer cross corporate-action" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--source)
|
||||||
|
COMPREPLY=($(compgen -W "idx ksei" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--pages)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__ownership__releases)
|
idx__ownership__releases)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -2392,8 +2643,8 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__stocks)
|
idx__stockbit)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help login status logout help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2418,6 +2669,214 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stockbit__help)
|
||||||
|
opts="login status logout help"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__stockbit__help__help)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__stockbit__help__login)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__stockbit__help__logout)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__stockbit__help__status)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__stockbit__login)
|
||||||
|
opts="-o -q -v -h --no-browser --port --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--port)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__stockbit__logout)
|
||||||
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__stockbit__status)
|
||||||
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__stocks)
|
||||||
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help quote intraday orderbook actions calendar history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
idx__stocks__actions)
|
||||||
|
opts="-o -q -v -h --kind --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--kind)
|
||||||
|
COMPREPLY=($(compgen -W "dividend stock-dividend bonus rights warrant split reverse-split meeting expose ipo tender" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__stocks__bandar)
|
idx__stocks__bandar)
|
||||||
opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
|
opts="-o -q -v -h --date --from --to --investor --board --top --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -2514,6 +2973,44 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__calendar)
|
||||||
|
opts="-o -q -v -h --from --to --kind --output --no-color --quiet --verbose --provider --offline --no-cache --help"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--from)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--to)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--kind)
|
||||||
|
COMPREPLY=($(compgen -W "dividend stock-dividend bonus rights warrant split reverse-split meeting expose ipo tender" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__stocks__compare)
|
idx__stocks__compare)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOLS>..."
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -2691,7 +3188,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
idx__stocks__help)
|
idx__stocks__help)
|
||||||
opts="quote intraday history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
opts="quote intraday orderbook actions calendar history technical growth valuation risk fundamental broker bandar flow profile financials earnings sentiment insights news screen compare help"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -2704,6 +3201,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help__actions)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__stocks__help__bandar)
|
idx__stocks__help__bandar)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -2732,6 +3243,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help__calendar)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__stocks__help__compare)
|
idx__stocks__help__compare)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -2886,6 +3411,20 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__help__orderbook)
|
||||||
|
opts=""
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__stocks__help__profile)
|
idx__stocks__help__profile)
|
||||||
opts=""
|
opts=""
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then
|
||||||
|
|
@ -3000,7 +3539,7 @@ _idx() {
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
--history-provider)
|
--history-provider)
|
||||||
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}"))
|
COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
--output)
|
--output)
|
||||||
|
|
@ -3104,6 +3643,36 @@ _idx() {
|
||||||
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
|
idx__stocks__orderbook)
|
||||||
|
opts="-o -q -v -h --depth --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
|
||||||
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
case "${prev}" in
|
||||||
|
--depth)
|
||||||
|
COMPREPLY=($(compgen -f "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
-o)
|
||||||
|
COMPREPLY=($(compgen -W "auto table json jsonl" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
--provider)
|
||||||
|
COMPREPLY=($(compgen -W "yahoo msn stockbit" -- "${cur}"))
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
COMPREPLY=()
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
idx__stocks__profile)
|
idx__stocks__profile)
|
||||||
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
|
opts="-o -q -v -h --output --no-color --quiet --verbose --provider --offline --no-cache --help <SYMBOL>"
|
||||||
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
|
||||||
|
|
@ -3254,7 +3823,7 @@ _idx() {
|
||||||
fi
|
fi
|
||||||
case "${prev}" in
|
case "${prev}" in
|
||||||
--history-provider)
|
--history-provider)
|
||||||
COMPREPLY=($(compgen -W "auto yahoo msn" -- "${cur}"))
|
COMPREPLY=($(compgen -W "auto yahoo msn stockbit" -- "${cur}"))
|
||||||
return 0
|
return 0
|
||||||
;;
|
;;
|
||||||
--output)
|
--output)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
{"code":"AUTHERROR","error":true,"message":"auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 2
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
{"code":"INVALIDINPUT","error":true,"message":"invalid input: to (2026-11-01) is before from (2026-11-10)"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 2
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: invalid input: to (2026-11-01) is before from (2026-11-10)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 4
|
exit: 4
|
||||||
--- stdout
|
--- stdout
|
||||||
--- stderr
|
--- stderr
|
||||||
{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit"}
|
{"code":"UPSTREAMBLOCKED","error":true,"message":"upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
exit: 4
|
exit: 4
|
||||||
--- stdout
|
--- stdout
|
||||||
--- stderr
|
--- stderr
|
||||||
Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; `--provider msn` serves quotes without Stockbit
|
Error: upstream blocked: stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a block. Try again later or from another network; the msn provider (`--provider msn`, or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 2
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
{"code":"INVALIDINPUT","error":true,"message":"invalid input: holder 'RUDI' is not a holder id; ids are digits, shown in the HOLDER ID column and as holder_id in JSON"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 2
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: invalid input: holder 'RUDI' is not a holder id; ids are digits, shown in the HOLDER ID column and as holder_id in JSON
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
{"code":"AUTHERROR","error":true,"message":"auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
{"code":"AUTHERROR","error":true,"message":"auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 4
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: auth error: no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 5
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
{"code":"UNSUPPORTED","error":true,"message":"unsupported: Stockbit history covers 5d to 1y of daily candles, not 5y at 1d; use --history-provider yahoo"}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 5
|
||||||
|
--- stdout
|
||||||
|
--- stderr
|
||||||
|
Error: unsupported: Stockbit history covers 5d to 1y of daily candles, not 5y at 1d; use --history-provider yahoo
|
||||||
|
|
@ -20,6 +20,7 @@ Commands:
|
||||||
concentration Rank tickers by ownership concentration
|
concentration Rank tickers by ownership concentration
|
||||||
flow Show Bing institutional flow for a ticker
|
flow Show Bing institutional flow for a ticker
|
||||||
changes Diff two KSEI releases
|
changes Diff two KSEI releases
|
||||||
|
movements Insider and major holder buys and sells, as reported (Stockbit login)
|
||||||
graph Ownership network graph
|
graph Ownership network graph
|
||||||
resolve Manual entity resolution workflow
|
resolve Manual entity resolution workflow
|
||||||
releases List imported KSEI releases
|
releases List imported KSEI releases
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ Commands:
|
||||||
config Manage configuration
|
config Manage configuration
|
||||||
cache Manage local cache
|
cache Manage local cache
|
||||||
ownership Ownership intelligence (bootstrap with `ownership sync`)
|
ownership Ownership intelligence (bootstrap with `ownership sync`)
|
||||||
|
stockbit Your Stockbit session (opt-in: order book, live quotes)
|
||||||
completions Generate shell completions
|
completions Generate shell completions
|
||||||
version Show idx-cli version
|
version Show idx-cli version
|
||||||
help Print this message or the help of the given subcommand(s)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ Usage: idx stocks [OPTIONS] <COMMAND>
|
||||||
Commands:
|
Commands:
|
||||||
quote Get real-time stock quotes
|
quote Get real-time stock quotes
|
||||||
intraday Today's session price by the minute (Stockbit)
|
intraday Today's session price by the minute (Stockbit)
|
||||||
|
orderbook Order book depth and foreign flow (Stockbit login)
|
||||||
|
actions Dividends, rights, splits and other corporate actions of a stock (Stockbit login)
|
||||||
|
calendar Upcoming and recent corporate actions across the market (Stockbit login)
|
||||||
history Get historical OHLC data
|
history Get historical OHLC data
|
||||||
technical Run technical analysis on a stock
|
technical Run technical analysis on a stock
|
||||||
growth Run growth analysis on a stock
|
growth Run growth analysis on a stock
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
{"movements":[{"symbol":"BBCA","date":"2026-03-25","holder":"HENDRA TANUMIHARDJA","holder_id":"1000000439","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":193206,"shares_after":341139,"shares_change":147933,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":1032868206},{"symbol":"BBCA","date":"2026-03-25","holder":"A. WIDODO MULYONO","holder_id":"1000000438","action":"buy","source":"idx","locality":"Local","shares_before":440838,"shares_after":681407,"shares_change":240569,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":1679652758},{"symbol":"BBCA","date":"2026-03-25","holder":"GREGORY HENDRA LEMBONG","holder_id":"1000000437","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":1531282,"shares_after":2666921,"shares_change":1135639,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":7929031498},{"symbol":"BBCA","date":"2026-03-25","holder":"SANTOSO","holder_id":"1000000209","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":3269028,"shares_after":3764062,"shares_change":495034,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":3456327388},{"symbol":"BBCA","date":"2026-03-25","holder":"TONNY KUSNADI","holder_id":"14421","action":"buy","source":"idx","locality":"Local","roles":["commissioner"],"shares_before":7502058,"shares_after":7819950,"shares_change":317892,"percentage_before_bps":1,"percentage_after_bps":1,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":2219521944},{"symbol":"BBCA","date":"2026-03-25","holder":"FRENGKY CHANDRA KUSUMA","holder_id":"14418","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":2429926,"shares_after":2835916,"shares_change":405990,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":2834622180},{"symbol":"BBCA","date":"2026-03-25","holder":"HARYANTO TIARA BUDIMAN","holder_id":"14417","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":1057378,"shares_after":1436806,"shares_change":379428,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":2649166296},{"symbol":"BBCA","date":"2026-03-25","holder":"VERA EVE LIM","holder_id":"14416","action":"buy","source":"idx","locality":"Local","roles":["director"],"shares_before":2731601,"shares_after":3281460,"shares_change":549859,"percentage_before_bps":0,"percentage_after_bps":0,"percentage_change_bps":0,"price":6982,"price_estimated":false,"value":3839115538}],"more":true}
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Shareholder movements for BBCA.JK (8)
|
||||||
|
┌────────────┬────────┬────────────────────────┬──────────────┬────────┬────────────┬────────┬───────┬───────┬────────┬────────────┐
|
||||||
|
│ DATE ┆ SYMBOL ┆ HOLDER ┆ ROLE ┆ ACTION ┆ SHARES ┆ STAKE ┆ PRICE ┆ VALUE ┆ SOURCE ┆ HOLDER ID │
|
||||||
|
╞════════════╪════════╪════════════════════════╪══════════════╪════════╪════════════╪════════╪═══════╪═══════╪════════╪════════════╡
|
||||||
|
│ 2026-03-25 ┆ BBCA ┆ HENDRA TANUMIHARDJA ┆ director ┆ buy ┆ +147,933 ┆ <0.01% ┆ 6,982 ┆ 1.0B ┆ IDX ┆ 1000000439 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-25 ┆ BBCA ┆ A. WIDODO MULYONO ┆ - ┆ buy ┆ +240,569 ┆ <0.01% ┆ 6,982 ┆ 1.7B ┆ IDX ┆ 1000000438 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-25 ┆ BBCA ┆ GREGORY HENDRA LEMBONG ┆ director ┆ buy ┆ +1,135,639 ┆ <0.01% ┆ 6,982 ┆ 7.9B ┆ IDX ┆ 1000000437 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-25 ┆ BBCA ┆ SANTOSO ┆ director ┆ buy ┆ +495,034 ┆ <0.01% ┆ 6,982 ┆ 3.5B ┆ IDX ┆ 1000000209 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-25 ┆ BBCA ┆ TONNY KUSNADI ┆ commissioner ┆ buy ┆ +317,892 ┆ 0.01% ┆ 6,982 ┆ 2.2B ┆ IDX ┆ 14421 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-25 ┆ BBCA ┆ FRENGKY CHANDRA KUSUMA ┆ director ┆ buy ┆ +405,990 ┆ <0.01% ┆ 6,982 ┆ 2.8B ┆ IDX ┆ 14418 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-25 ┆ BBCA ┆ HARYANTO TIARA BUDIMAN ┆ director ┆ buy ┆ +379,428 ┆ <0.01% ┆ 6,982 ┆ 2.6B ┆ IDX ┆ 14417 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-25 ┆ BBCA ┆ VERA EVE LIM ┆ director ┆ buy ┆ +549,859 ┆ <0.01% ┆ 6,982 ┆ 3.8B ┆ IDX ┆ 14416 │
|
||||||
|
└────────────┴────────┴────────────────────────┴──────────────┴────────┴────────────┴────────┴───────┴───────┴────────┴────────────┘
|
||||||
|
IDX rows are insider disclosures (directors, commissioners and other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated price. Rows appear days after the change.
|
||||||
|
More rows exist: raise --pages (up to 10) or narrow --from/--to.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
{"movements":[{"symbol":"ATLA","date":"2026-10-30","holder":"RUDI R. SUTANTRA","holder_id":"1000004481","action":"sell","source":"ksei","locality":"Local","shares_before":2530254200,"shares_after":2520254200,"shares_change":-10000000,"percentage_before_bps":4081,"percentage_after_bps":4065,"percentage_change_bps":-16,"price":21,"price_estimated":true,"value":210000000},{"symbol":"FUJI","date":"2026-10-15","holder":"JEFRI NEDI","holder_id":"1000006017","action":"sell","source":"ksei","locality":"Local","shares_before":109431600,"shares_after":109251600,"shares_change":-180000,"percentage_before_bps":842,"percentage_after_bps":840,"percentage_change_bps":-1,"price":298,"price_estimated":true,"value":53640000},{"symbol":"ATLA","date":"2026-10-15","holder":"RUDI R. SUTANTRA","holder_id":"1000004481","action":"sell","source":"ksei","locality":"Local","shares_before":2638254200,"shares_after":2610254200,"shares_change":-28000000,"percentage_before_bps":4256,"percentage_after_bps":4210,"percentage_change_bps":-45,"price":29,"price_estimated":true,"value":812000000}],"more":false}
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Shareholder movements across IDX (3)
|
||||||
|
┌────────────┬────────┬──────────────────┬──────┬────────┬─────────────┬────────────────┬───────┬─────────┬────────┬────────────┐
|
||||||
|
│ DATE ┆ SYMBOL ┆ HOLDER ┆ ROLE ┆ ACTION ┆ SHARES ┆ STAKE ┆ PRICE ┆ VALUE ┆ SOURCE ┆ HOLDER ID │
|
||||||
|
╞════════════╪════════╪══════════════════╪══════╪════════╪═════════════╪════════════════╪═══════╪═════════╪════════╪════════════╡
|
||||||
|
│ 2026-10-30 ┆ ATLA ┆ RUDI R. SUTANTRA ┆ - ┆ sell ┆ -10,000,000 ┆ 40.65% (-0.16) ┆ ~21 ┆ ~210.0M ┆ KSEI ┆ 1000004481 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-15 ┆ FUJI ┆ JEFRI NEDI ┆ - ┆ sell ┆ -180,000 ┆ 8.40% (-0.01) ┆ ~298 ┆ ~53.6M ┆ KSEI ┆ 1000006017 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-15 ┆ ATLA ┆ RUDI R. SUTANTRA ┆ - ┆ sell ┆ -28,000,000 ┆ 42.10% (-0.45) ┆ ~29 ┆ ~812.0M ┆ KSEI ┆ 1000004481 │
|
||||||
|
└────────────┴────────┴──────────────────┴──────┴────────┴─────────────┴────────────────┴───────┴─────────┴────────┴────────────┘
|
||||||
|
IDX rows are insider disclosures (directors, commissioners and other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated price. Rows appear days after the change.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
File diff suppressed because one or more lines are too long
37
crates/idx-cli/tests/golden/ownership-movements.table.golden
Normal file
37
crates/idx-cli/tests/golden/ownership-movements.table.golden
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Shareholder movements across IDX (14)
|
||||||
|
┌────────────┬────────┬───────────────────────────────────────┬──────┬───────────────────┬──────────────┬────────────────┬───────┬─────────┬────────┬────────────┐
|
||||||
|
│ DATE ┆ SYMBOL ┆ HOLDER ┆ ROLE ┆ ACTION ┆ SHARES ┆ STAKE ┆ PRICE ┆ VALUE ┆ SOURCE ┆ HOLDER ID │
|
||||||
|
╞════════════╪════════╪═══════════════════════════════════════╪══════╪═══════════════════╪══════════════╪════════════════╪═══════╪═════════╪════════╪════════════╡
|
||||||
|
│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +2,500,000 ┆ 6.66% (+0.07) ┆ 120 ┆ 300.0M ┆ IDX ┆ 1000006115 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +2,900,000 ┆ 6.59% (+0.08) ┆ 122 ┆ 353.8M ┆ IDX ┆ 1000006115 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +1,200,000 ┆ 6.45% (+0.03) ┆ 123 ┆ 147.6M ┆ IDX ┆ 1000006115 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +838,300 ┆ 6.42% (+0.02) ┆ 119 ┆ 99.8M ┆ IDX ┆ 1000006115 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-02 ┆ PEGE ┆ OPTIMUS VISION GLOBAL ┆ - ┆ buy ┆ +23,500 ┆ 6.40% ┆ 118 ┆ 2.8M ┆ IDX ┆ 1000006115 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-30 ┆ ATLA ┆ RUDI R. SUTANTRA ┆ - ┆ sell ┆ -10,000,000 ┆ 40.65% (-0.16) ┆ ~21 ┆ ~210.0M ┆ KSEI ┆ 1000004481 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-30 ┆ BUVA ┆ NUSANTARA UTAMA INVESTAMA ┆ - ┆ rights issue ┆ 0 ┆ 61.54% (-0.97) ┆ ~353 ┆ - ┆ KSEI ┆ 12606 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-30 ┆ SGER ┆ WELLY THOMAS ┆ - ┆ transfer ┆ +55,624,994 ┆ 8.61% (+0.36) ┆ ~794 ┆ ~44.2B ┆ KSEI ┆ 14074 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-29 ┆ ENRG ┆ CGS INTERNATIONAL SEKURITAS INDONESIA ┆ - ┆ cross ┆ -499,999,997 ┆ 7.78% (-1.25) ┆ 715 ┆ 357.5B ┆ IDX ┆ 13898 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-28 ┆ ENRG ┆ MAYBANK SEKURITAS INDONESIA ┆ - ┆ cross ┆ +62,500,000 ┆ 6.13% (+0.16) ┆ ~724 ┆ ~45.2B ┆ IDX ┆ 12202 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-28 ┆ VISI ┆ TRINUGRAHA THOHIR HARMONI ┆ - ┆ private placement ┆ +24,144,900 ┆ 8.45% (+0.76) ┆ 994 ┆ 24.0B ┆ IDX ┆ 1000000448 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-15 ┆ MBSS ┆ WIBOWO GROUP CAPITAL ┆ - ┆ tender offer ┆ +132,838,200 ┆ 90.09% (+7.59) ┆ 2,915 ┆ 387.2B ┆ IDX ┆ 1000005952 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-15 ┆ FUJI ┆ JEFRI NEDI ┆ - ┆ sell ┆ -180,000 ┆ 8.40% (-0.01) ┆ ~298 ┆ ~53.6M ┆ KSEI ┆ 1000006017 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-15 ┆ ATLA ┆ RUDI R. SUTANTRA ┆ - ┆ sell ┆ -28,000,000 ┆ 42.10% (-0.45) ┆ ~29 ┆ ~812.0M ┆ KSEI ┆ 1000004481 │
|
||||||
|
└────────────┴────────┴───────────────────────────────────────┴──────┴───────────────────┴──────────────┴────────────────┴───────┴─────────┴────────┴────────────┘
|
||||||
|
IDX rows are insider disclosures (directors, commissioners and other insiders); KSEI rows are changes of holders at 5% and above. ~ marks Stockbit's estimated price. Rows appear days after the change.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,83 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Corporate actions for BBCA.JK
|
||||||
|
┌────────────┬──────────┬────────────┬────────────┬────────────┬────────────────────┐
|
||||||
|
│ DATE ┆ KIND ┆ EX ┆ RECORD ┆ PAY ┆ DETAIL │
|
||||||
|
╞════════════╪══════════╪════════════╪════════════╪════════════╪════════════════════╡
|
||||||
|
│ 2026-08-28 ┆ dividend ┆ 2026-08-31 ┆ 2026-09-01 ┆ 2026-09-16 ┆ Rp 25 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-06-15 ┆ dividend ┆ 2026-06-17 ┆ 2026-06-18 ┆ 2026-06-26 ┆ Rp 20 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-27 ┆ dividend ┆ 2026-03-30 ┆ 2026-03-31 ┆ 2026-04-08 ┆ Rp 281 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2025-12-02 ┆ dividend ┆ 2025-12-03 ┆ 2025-12-04 ┆ 2025-12-22 ┆ Rp 55 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2025-03-20 ┆ dividend ┆ 2025-03-21 ┆ 2025-03-24 ┆ 2025-04-11 ┆ Rp 250 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2024-11-20 ┆ dividend ┆ 2024-11-21 ┆ 2024-11-22 ┆ 2024-12-11 ┆ Rp 50 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2024-03-22 ┆ dividend ┆ 2024-03-25 ┆ 2024-03-26 ┆ 2024-04-04 ┆ Rp 227.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2023-12-01 ┆ dividend ┆ 2023-12-04 ┆ 2023-12-05 ┆ 2023-12-20 ┆ Rp 42.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2023-03-28 ┆ dividend ┆ 2023-03-29 ┆ 2023-03-30 ┆ 2023-04-14 ┆ Rp 170 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2022-12-01 ┆ dividend ┆ 2022-12-02 ┆ 2022-12-05 ┆ 2022-12-20 ┆ Rp 35 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2022-03-25 ┆ dividend ┆ 2022-03-28 ┆ 2022-03-29 ┆ 2022-04-19 ┆ Rp 120 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-11-16 ┆ dividend ┆ 2021-11-17 ┆ 2021-11-18 ┆ 2021-12-07 ┆ Rp 25 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-04-07 ┆ dividend ┆ 2021-04-08 ┆ 2021-04-09 ┆ 2021-04-28 ┆ Rp 432 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2020-12-07 ┆ dividend ┆ 2020-12-08 ┆ 2020-12-10 ┆ 2020-12-22 ┆ Rp 98 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2020-04-20 ┆ dividend ┆ 2020-04-21 ┆ 2020-04-22 ┆ 2020-05-11 ┆ Rp 455 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2019-12-05 ┆ dividend ┆ 2019-12-06 ┆ 2019-12-09 ┆ 2019-12-20 ┆ Rp 100 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2019-04-23 ┆ dividend ┆ 2019-04-24 ┆ 2019-04-25 ┆ 2019-04-30 ┆ Rp 255 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2018-11-30 ┆ dividend ┆ 2018-12-03 ┆ 2018-12-04 ┆ 2018-12-21 ┆ Rp 85 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2018-04-12 ┆ dividend ┆ 2018-04-13 ┆ 2018-04-17 ┆ 2018-04-30 ┆ Rp 175 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2017-11-28 ┆ dividend ┆ 2017-11-29 ┆ 2017-12-04 ┆ 2017-12-20 ┆ Rp 80 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2017-04-13 ┆ dividend ┆ 2017-04-17 ┆ 2017-04-20 ┆ 2017-04-28 ┆ Rp 130 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2016-11-30 ┆ dividend ┆ 2016-12-01 ┆ 2016-12-05 ┆ 2016-12-22 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2016-04-14 ┆ dividend ┆ 2016-04-15 ┆ 2016-04-19 ┆ 2016-04-29 ┆ Rp 105 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2015-11-12 ┆ dividend ┆ 2015-11-13 ┆ 2015-11-17 ┆ 2015-12-09 ┆ Rp 55 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2015-04-16 ┆ dividend ┆ 2015-04-17 ┆ 2015-04-21 ┆ 2015-05-13 ┆ Rp 98 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2014-12-04 ┆ dividend ┆ 2014-12-05 ┆ 2014-12-09 ┆ 2014-12-23 ┆ Rp 50 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2014-04-29 ┆ dividend ┆ 2014-04-30 ┆ 2014-05-05 ┆ 2014-05-20 ┆ Rp 75 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2013-11-28 ┆ dividend ┆ 2013-11-29 ┆ 2013-12-03 ┆ 2013-12-17 ┆ Rp 45 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2013-05-28 ┆ dividend ┆ 2013-05-29 ┆ 2013-05-31 ┆ 2013-06-17 ┆ Rp 71 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2012-12-03 ┆ dividend ┆ 2012-12-04 ┆ 2012-12-06 ┆ 2012-12-20 ┆ Rp 43.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2012-06-08 ┆ dividend ┆ 2012-06-11 ┆ 2012-06-13 ┆ 2012-06-27 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2011-12-06 ┆ dividend ┆ 2011-12-07 ┆ 2011-12-09 ┆ 2011-12-23 ┆ Rp 43.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2011-06-06 ┆ dividend ┆ 2011-06-07 ┆ 2011-06-09 ┆ 2011-06-23 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2010-11-19 ┆ dividend ┆ 2010-11-22 ┆ 2010-11-24 ┆ 2010-12-09 ┆ Rp 42.5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2010-05-31 ┆ dividend ┆ 2010-06-01 ┆ 2010-06-03 ┆ 2010-06-17 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2009-11-12 ┆ dividend ┆ 2009-11-13 ┆ 2009-11-17 ┆ 2009-12-02 ┆ Rp 40 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2009-06-09 ┆ dividend ┆ 2009-06-10 ┆ 2009-06-12 ┆ 2009-06-26 ┆ Rp 65 per share │
|
||||||
|
└────────────┴──────────┴────────────┴────────────┴────────────┴────────────────────┘
|
||||||
|
DATE is the cum date for dividends, bonuses, rights and splits (buy by that day to be entitled); for other actions, the event's first day.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
4
crates/idx-cli/tests/golden/stockbit-actions.json.golden
Normal file
4
crates/idx-cli/tests/golden/stockbit-actions.json.golden
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"symbol":"BBCA.JK","kind":"expose","date":"2026-09-09","time":"16:00","venue":"Public Expose Live 2026 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2026-03-12","record_date":"2026-02-09","time":"14:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 10310"},{"symbol":"BBCA.JK","kind":"expose","date":"2025-09-11","time":"16:00","venue":"Public Expose Live 2025 yang tercantum pada website idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2025-03-12","record_date":"2025-02-07","time":"09:30","venue":"Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta, 10310"},{"symbol":"BBCA.JK","kind":"expose","date":"2024-08-28","time":"15:00","venue":"Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan pendaftaran peserta di website www.idx.co.id"},{"symbol":"BBCA.JK","kind":"meeting","date":"2024-03-14","record_date":"2024-02-07","time":"09:30","venue":"Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta Selatan"},{"symbol":"BBCA.JK","kind":"expose","date":"2023-11-29","time":"10:00","venue":"Public Expose Live 2023"},{"symbol":"BBCA.JK","kind":"meeting","date":"2023-03-16","record_date":"2023-02-13","time":"09:30","venue":"Menara BCA"},{"symbol":"BBCA.JK","kind":"expose","date":"2022-09-14","time":"10:00","venue":"Public Expose Live 2022"},{"symbol":"BBCA.JK","kind":"meeting","date":"2022-03-17","record_date":"2022-02-14","time":"09:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2021-10-12","cum_date":"2021-10-12","ex_date":"2021-10-13","record_date":"2021-10-14","ratio":{"old":1,"new":5},"factor":5.0},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-09-23","record_date":"2021-08-27","time":"10:00","venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta"},{"symbol":"BBCA.JK","kind":"expose","date":"2021-09-08","time":"15:00","venue":"Public Expose Live 2021"},{"symbol":"BBCA.JK","kind":"meeting","date":"2021-03-29","record_date":"2021-02-24","time":"09:00","venue":"Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Jakarta"},{"symbol":"BBCA.JK","kind":"meeting","date":"2020-07-30","record_date":"2020-07-03","time":"10:00","venue":"Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. Jakarta"},{"symbol":"BBCA.JK","kind":"split","date":"2008-01-25","cum_date":"2008-01-25","ex_date":"2008-01-28","record_date":"2008-01-30","ratio":{"old":1,"new":2},"factor":2.0},{"symbol":"BBCA.JK","kind":"split","date":"2004-06-07","cum_date":"2004-06-07","ex_date":"2004-06-08","record_date":"2004-06-10","ratio":{"old":1,"new":2},"factor":2.0}]
|
||||||
|
--- stderr
|
||||||
43
crates/idx-cli/tests/golden/stockbit-actions.table.golden
Normal file
43
crates/idx-cli/tests/golden/stockbit-actions.table.golden
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Corporate actions for BBCA.JK
|
||||||
|
┌────────────┬───────────────┬────────────┬────────────┬─────┬────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DATE ┆ KIND ┆ EX ┆ RECORD ┆ PAY ┆ DETAIL │
|
||||||
|
╞════════════╪═══════════════╪════════════╪════════════╪═════╪════════════════════════════════════════════════════════════════════════════╡
|
||||||
|
│ 2026-09-09 ┆ public expose ┆ - ┆ - ┆ - ┆ 16:00 WIB, Public Expose Live 2026 yang tercantum pada website idx.co.i... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-03-12 ┆ meeting ┆ - ┆ 2026-02-09 ┆ - ┆ 14:00 WIB, Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 1... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2025-09-11 ┆ public expose ┆ - ┆ - ┆ - ┆ 16:00 WIB, Public Expose Live 2025 yang tercantum pada website idx.co.i... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2025-03-12 ┆ meeting ┆ - ┆ 2025-02-07 ┆ - ┆ 09:30 WIB, Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2024-08-28 ┆ public expose ┆ - ┆ - ┆ - ┆ 15:00 WIB, Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2024-03-14 ┆ meeting ┆ - ┆ 2024-02-07 ┆ - ┆ 09:30 WIB, Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2023-11-29 ┆ public expose ┆ - ┆ - ┆ - ┆ 10:00 WIB, Public Expose Live 2023 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2023-03-16 ┆ meeting ┆ - ┆ 2023-02-13 ┆ - ┆ 09:30 WIB, Menara BCA │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2022-09-14 ┆ public expose ┆ - ┆ - ┆ - ┆ 10:00 WIB, Public Expose Live 2022 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2022-03-17 ┆ meeting ┆ - ┆ 2022-02-14 ┆ - ┆ 09:00 WIB, Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-10-12 ┆ split ┆ 2021-10-13 ┆ 2021-10-14 ┆ - ┆ 1 becomes 5 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-09-23 ┆ meeting ┆ - ┆ 2021-08-27 ┆ - ┆ 10:00 WIB, Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-09-08 ┆ public expose ┆ - ┆ - ┆ - ┆ 15:00 WIB, Public Expose Live 2021 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2021-03-29 ┆ meeting ┆ - ┆ 2021-02-24 ┆ - ┆ 09:00 WIB, Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Ja... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2020-07-30 ┆ meeting ┆ - ┆ 2020-07-03 ┆ - ┆ 10:00 WIB, Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. ... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2008-01-25 ┆ split ┆ 2008-01-28 ┆ 2008-01-30 ┆ - ┆ 1 becomes 2 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2004-06-07 ┆ split ┆ 2004-06-08 ┆ 2004-06-10 ┆ - ┆ 1 becomes 2 │
|
||||||
|
└────────────┴───────────────┴────────────┴────────────┴─────┴────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
DATE is the cum date for dividends, bonuses, rights and splits (buy by that day to be entitled); for other actions, the event's first day.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"symbol":"AUTO.JK","kind":"expose","date":"2026-11-05","time":"10:00","venue":"Zoom Webinar"},{"symbol":"BATA.JK","kind":"meeting","date":"2026-11-05","record_date":"2026-10-09","time":"14:00","venue":"Gedung Ventura, lantai 7. Jl. Raden Ajeng Kartini No.26, Cilandak Barat, Jakarta Selatan"},{"symbol":"AMAG.JK","kind":"expose","date":"2026-11-06","time":"15:00","venue":"Virtual Zoom Webinar, di PT Asuransi Multi Artha Guna Tbk, Gedung TCC Batavia Tower One, Jalan KH Mas Mansyur Kav 126, Jakarta Pusat"},{"symbol":"BRIS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Jakarta Pusat, melalui fasilitas Electronic General Meeting System KSEI (eASY.KSEI) dalam tautan https//akses.ksei.co.id"},{"symbol":"GIAA.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"Secara elektronik melalui sistem eASY.KSEI (https//akses.ksei.co.id/)"},{"symbol":"INPS.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-07","time":"14:00","venue":"Gedung Inprase, Jl. Sunter Garden Raya Blok D8 No. 3G-3H, Sunter Agung, Tanjung Priok, Jakarta Utara, DKI Jakarta 14350"},{"symbol":"KDTN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"14:00","venue":"PT Puri Sentul Permai Tbk diumumkan dan sesuai iklan)"},{"symbol":"MTFN.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-10-12","time":"10:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5, Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"MTFN.JK","kind":"expose","date":"2026-11-06","time":"11:00","venue":"Kantor Taman E3.3, Unit D3A, Lantai 5 Jl. DR. Ide Anak Agung Gde Agung, Kuningan Jakarta Selatan"},{"symbol":"ULTJ.JK","kind":"meeting","date":"2026-11-06","record_date":"2026-08-31","time":"10:00","venue":"Kantor Perseroan, Jalan Raya Cimareme No 131 Bandung Barat, Jawa Barat Indonesia"}]
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Corporate actions from 2026-11-05 to 2026-11-06 (10)
|
||||||
|
┌────────────┬────────┬───────────────┬────┬────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DATE ┆ SYMBOL ┆ KIND ┆ EX ┆ DETAIL │
|
||||||
|
╞════════════╪════════╪═══════════════╪════╪════════════════════════════════════════════════════════════════════════════╡
|
||||||
|
│ 2026-11-05 ┆ AUTO ┆ public expose ┆ - ┆ 10:00 WIB, Zoom Webinar │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-05 ┆ BATA ┆ meeting ┆ - ┆ 14:00 WIB, Gedung Ventura, lantai 7. Jl. Raden Ajeng Kartini No.26, Cil... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ AMAG ┆ public expose ┆ - ┆ 15:00 WIB, Virtual Zoom Webinar, di PT Asuransi Multi Artha Guna Tbk, G... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ BRIS ┆ meeting ┆ - ┆ 10:00 WIB, Jakarta Pusat, melalui fasilitas Electronic General Meeting ... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ GIAA ┆ meeting ┆ - ┆ 14:00 WIB, Secara elektronik melalui sistem eASY.KSEI (https//akses.kse... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ INPS ┆ meeting ┆ - ┆ 14:00 WIB, Gedung Inprase, Jl. Sunter Garden Raya Blok D8 No. 3G-3H, Su... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ KDTN ┆ meeting ┆ - ┆ 14:00 WIB, PT Puri Sentul Permai Tbk diumumkan dan sesuai iklan) │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ MTFN ┆ meeting ┆ - ┆ 10:00 WIB, Kantor Taman E3.3, Unit D3A, Lantai 5, Jl. DR. Ide Anak Agun... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ MTFN ┆ public expose ┆ - ┆ 11:00 WIB, Kantor Taman E3.3, Unit D3A, Lantai 5 Jl. DR. Ide Anak Agung... │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ ULTJ ┆ meeting ┆ - ┆ 10:00 WIB, Kantor Perseroan, Jalan Raya Cimareme No 131 Bandung Barat, ... │
|
||||||
|
└────────────┴────────┴───────────────┴────┴────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
DATE is the cum date for dividends, bonuses, rights and splits (buy by that day to be entitled); for other actions, the event's first day.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"symbol":"DOOH.JK","kind":"tender","date":"2026-10-05","pay_date":"2026-11-09","price":148,"period":{"start":"2026-10-05","end":"2026-11-03"},"shares":2624548624,"percentage_bps":3391,"name":"Era Media Sejahtera Tbk."},{"symbol":"NAYZ.JK","kind":"tender","date":"2026-10-05","pay_date":"2026-11-10","price":75,"period":{"start":"2026-10-05","end":"2026-11-03"},"shares":1169011472,"percentage_bps":4584,"name":"Hassana Boga Sejahtera Tbk."},{"symbol":"AUTO.JK","kind":"tender","date":"2026-10-13","pay_date":"2026-11-18","price":3600,"period":{"start":"2026-10-13","end":"2026-11-11"},"shares":238442293,"percentage_bps":495,"name":"Astra Otoparts Tbk."},{"symbol":"MGLV.JK","kind":"rights","date":"2026-11-04","cum_date":"2026-11-04","ex_date":"2026-11-05","record_date":"2026-11-06","ratio":{"old":100,"new":15},"factor":1.15,"price":8880,"period":{"start":"2026-11-10","end":"2026-11-23"}},{"symbol":"JPFA.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-12","cash_per_share":90.0},{"symbol":"PTPS.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-18","cash_per_share":2.0},{"symbol":"UNTR.JK","kind":"dividend","date":"2026-11-05","cum_date":"2026-11-05","ex_date":"2026-11-06","record_date":"2026-11-09","pay_date":"2026-11-23","cash_per_share":430.0},{"symbol":"CNMA.JK","kind":"dividend","date":"2026-11-06","cum_date":"2026-11-06","ex_date":"2026-11-09","record_date":"2026-11-10","pay_date":"2026-11-19","cash_per_share":4.0},{"symbol":"SMSM.JK","kind":"dividend","date":"2026-11-09","cum_date":"2026-11-09","ex_date":"2026-11-10","record_date":"2026-11-11","pay_date":"2026-11-26","cash_per_share":45.0},{"symbol":"AVIA.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-19","cash_per_share":12.0},{"symbol":"BWPT.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-25","cash_per_share":1.0},{"symbol":"ITMG.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-18","cash_per_share":1191.09},{"symbol":"MEDC.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-12-02","cash_per_share":21.65},{"symbol":"SSMS.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-12-02","cash_per_share":52.49},{"symbol":"TAPG.JK","kind":"dividend","date":"2026-11-10","cum_date":"2026-11-10","ex_date":"2026-11-11","record_date":"2026-11-12","pay_date":"2026-11-27","cash_per_share":70.0},{"symbol":"IFII.JK","kind":"dividend","date":"2026-11-11","cum_date":"2026-11-11","ex_date":"2026-11-12","record_date":"2026-11-13","pay_date":"2026-11-26","cash_per_share":5.0},{"symbol":"MIDI.JK","kind":"dividend","date":"2026-11-11","cum_date":"2026-11-11","ex_date":"2026-11-12","record_date":"2026-11-13","pay_date":"2026-12-01","cash_per_share":3.8}]
|
||||||
|
--- stderr
|
||||||
43
crates/idx-cli/tests/golden/stockbit-calendar.table.golden
Normal file
43
crates/idx-cli/tests/golden/stockbit-calendar.table.golden
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
Corporate actions from 2026-11-03 to 2026-11-12, with offers opened earlier and still open (17)
|
||||||
|
┌────────────┬────────┬──────────────┬────────────┬──────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ DATE ┆ SYMBOL ┆ KIND ┆ EX ┆ DETAIL │
|
||||||
|
╞════════════╪════════╪══════════════╪════════════╪══════════════════════════════════════════════════════════════════════════╡
|
||||||
|
│ 2026-10-05 ┆ DOOH ┆ tender offer ┆ - ┆ Rp 148, for 2,624,548,624 shares (33.91%), open 2026-10-05 to 2026-11-03 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-05 ┆ NAYZ ┆ tender offer ┆ - ┆ Rp 75, for 1,169,011,472 shares (45.84%), open 2026-10-05 to 2026-11-03 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-13 ┆ AUTO ┆ tender offer ┆ - ┆ Rp 3,600, for 238,442,293 shares (4.95%), open 2026-10-13 to 2026-11-11 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-04 ┆ MGLV ┆ rights ┆ 2026-11-05 ┆ 15 rights per 100 held, at Rp 8,880, trading 2026-11-10 to 2026-11-23 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-05 ┆ JPFA ┆ dividend ┆ 2026-11-06 ┆ Rp 90 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-05 ┆ PTPS ┆ dividend ┆ 2026-11-06 ┆ Rp 2 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-05 ┆ UNTR ┆ dividend ┆ 2026-11-06 ┆ Rp 430 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-06 ┆ CNMA ┆ dividend ┆ 2026-11-09 ┆ Rp 4 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-09 ┆ SMSM ┆ dividend ┆ 2026-11-10 ┆ Rp 45 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ AVIA ┆ dividend ┆ 2026-11-11 ┆ Rp 12 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ BWPT ┆ dividend ┆ 2026-11-11 ┆ Rp 1 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ ITMG ┆ dividend ┆ 2026-11-11 ┆ Rp 1,191.09 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ MEDC ┆ dividend ┆ 2026-11-11 ┆ Rp 21.65 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ SSMS ┆ dividend ┆ 2026-11-11 ┆ Rp 52.49 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-10 ┆ TAPG ┆ dividend ┆ 2026-11-11 ┆ Rp 70 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-11 ┆ IFII ┆ dividend ┆ 2026-11-12 ┆ Rp 5 per share │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-11-11 ┆ MIDI ┆ dividend ┆ 2026-11-12 ┆ Rp 3.8 per share │
|
||||||
|
└────────────┴────────┴──────────────┴────────────┴──────────────────────────────────────────────────────────────────────────┘
|
||||||
|
DATE is the cum date for dividends, bonuses, rights and splits (buy by that day to be entitled); for other actions, the event's first day.
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
|
|
@ -8,5 +8,5 @@ exit: 0
|
||||||
│ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │
|
│ MABA.JK ┆ 50 ┆ +0 ┆ +0.00% ┆ 0 ┆ - ┆ - ┆ close ┆ - │
|
||||||
└─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘
|
└─────────┴───────┴──────┴────────┴─────────────┴─────┴───────┴────────┴───────┘
|
||||||
Note: MABA.JK: Stockbit lists the stock as suspended
|
Note: MABA.JK: Stockbit lists the stock as suspended
|
||||||
Source: Stockbit's public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
|
Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
|
||||||
--- stderr
|
--- stderr
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"date":"2026-09-17","open":6400,"high":6400,"low":6325,"close":6350,"volume":61812000},{"date":"2026-09-18","open":6325,"high":6350,"low":6225,"close":6300,"volume":176753700},{"date":"2026-09-21","open":6300,"high":6300,"low":6225,"close":6225,"volume":69914100},{"date":"2026-09-22","open":6225,"high":6250,"low":6175,"close":6200,"volume":93211200},{"date":"2026-09-23","open":6200,"high":6300,"low":6200,"close":6300,"volume":96826100},{"date":"2026-09-24","open":6250,"high":6275,"low":6225,"close":6225,"volume":75648600},{"date":"2026-09-25","open":6225,"high":6275,"low":6200,"close":6250,"volume":89447400},{"date":"2026-09-28","open":6225,"high":6250,"low":6175,"close":6225,"volume":112107600},{"date":"2026-09-29","open":6150,"high":6225,"low":6125,"close":6150,"volume":149783400},{"date":"2026-09-30","open":6100,"high":6150,"low":6050,"close":6075,"volume":221335700},{"date":"2026-10-01","open":6050,"high":6150,"low":6000,"close":6000,"volume":118044900},{"date":"2026-10-02","open":6000,"high":6150,"low":5975,"close":6100,"volume":131920300},{"date":"2026-10-05","open":6100,"high":6175,"low":6075,"close":6175,"volume":89905700},{"date":"2026-10-06","open":6175,"high":6200,"low":6100,"close":6100,"volume":152173400},{"date":"2026-10-07","open":6075,"high":6100,"low":5975,"close":6050,"volume":162690800},{"date":"2026-10-08","open":6100,"high":6150,"low":6000,"close":6000,"volume":113140300},{"date":"2026-10-09","open":6025,"high":6075,"low":5975,"close":6050,"volume":87098600},{"date":"2026-10-12","open":6050,"high":6100,"low":5950,"close":5950,"volume":97965100},{"date":"2026-10-13","open":5925,"high":5950,"low":5850,"close":5950,"volume":122716900},{"date":"2026-10-14","open":6000,"high":6125,"low":5950,"close":6125,"volume":136303100},{"date":"2026-10-15","open":6075,"high":6125,"low":6025,"close":6075,"volume":134763200},{"date":"2026-10-16","open":5825,"high":5825,"low":5175,"close":5175,"volume":1241098200}]
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
History for BBCA.JK
|
||||||
|
┌────────────┬───────┬───────┬───────┬───────┬───────────────┐
|
||||||
|
│ DATE ┆ OPEN ┆ HIGH ┆ LOW ┆ CLOSE ┆ VOLUME │
|
||||||
|
╞════════════╪═══════╪═══════╪═══════╪═══════╪═══════════════╡
|
||||||
|
│ 2026-09-17 ┆ 6,400 ┆ 6,400 ┆ 6,325 ┆ 6,350 ┆ 61,812,000 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-09-18 ┆ 6,325 ┆ 6,350 ┆ 6,225 ┆ 6,300 ┆ 176,753,700 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-09-21 ┆ 6,300 ┆ 6,300 ┆ 6,225 ┆ 6,225 ┆ 69,914,100 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-09-22 ┆ 6,225 ┆ 6,250 ┆ 6,175 ┆ 6,200 ┆ 93,211,200 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-09-23 ┆ 6,200 ┆ 6,300 ┆ 6,200 ┆ 6,300 ┆ 96,826,100 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-09-24 ┆ 6,250 ┆ 6,275 ┆ 6,225 ┆ 6,225 ┆ 75,648,600 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-09-25 ┆ 6,225 ┆ 6,275 ┆ 6,200 ┆ 6,250 ┆ 89,447,400 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-09-28 ┆ 6,225 ┆ 6,250 ┆ 6,175 ┆ 6,225 ┆ 112,107,600 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-09-29 ┆ 6,150 ┆ 6,225 ┆ 6,125 ┆ 6,150 ┆ 149,783,400 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-09-30 ┆ 6,100 ┆ 6,150 ┆ 6,050 ┆ 6,075 ┆ 221,335,700 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-01 ┆ 6,050 ┆ 6,150 ┆ 6,000 ┆ 6,000 ┆ 118,044,900 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-02 ┆ 6,000 ┆ 6,150 ┆ 5,975 ┆ 6,100 ┆ 131,920,300 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-05 ┆ 6,100 ┆ 6,175 ┆ 6,075 ┆ 6,175 ┆ 89,905,700 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-06 ┆ 6,175 ┆ 6,200 ┆ 6,100 ┆ 6,100 ┆ 152,173,400 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-07 ┆ 6,075 ┆ 6,100 ┆ 5,975 ┆ 6,050 ┆ 162,690,800 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-08 ┆ 6,100 ┆ 6,150 ┆ 6,000 ┆ 6,000 ┆ 113,140,300 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-09 ┆ 6,025 ┆ 6,075 ┆ 5,975 ┆ 6,050 ┆ 87,098,600 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-12 ┆ 6,050 ┆ 6,100 ┆ 5,950 ┆ 5,950 ┆ 97,965,100 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-13 ┆ 5,925 ┆ 5,950 ┆ 5,850 ┆ 5,950 ┆ 122,716,900 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-14 ┆ 6,000 ┆ 6,125 ┆ 5,950 ┆ 6,125 ┆ 136,303,100 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-15 ┆ 6,075 ┆ 6,125 ┆ 6,025 ┆ 6,075 ┆ 134,763,200 │
|
||||||
|
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
|
||||||
|
│ 2026-10-16 ┆ 5,825 ┆ 5,825 ┆ 5,175 ┆ 5,175 ┆ 1,241,098,200 │
|
||||||
|
└────────────┴───────┴───────┴───────┴───────┴───────────────┘
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
{"symbol":"BBCA.JK","name":"Bank Central Asia Tbk.","price":5175,"change":-900,"change_pct":-14.81,"prev_close":6075,"open":5825,"high":5825,"low":5175,"volume":1241098200,"value":6474934315000,"frequency":185072,"foreign_buy":1616461977500,"foreign_sell":3945955065000,"foreign_net":-2329493087500,"bids":[],"offers":[{"price":5175,"shares":264462500,"orders":3284},{"price":5200,"shares":10053400,"orders":337},{"price":5225,"shares":3711400,"orders":396}],"market_status":"STATE_NAME_MARKET_CLOSED","session_note":null}
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
BBCA.JK Bank Central Asia Tbk. 5,175 -900 (-14.81%)
|
||||||
|
prev close 6,075 · open 5,825 · high 5,825 · low 5,175 · volume 1,241,098,200 · value 6,474,934,315,000 · 185,072 trades
|
||||||
|
foreign buy 1,616,461,977,500 · sell 3,945,955,065,000 · net -2,329,493,087,500
|
||||||
|
market closed: the book as the market left it
|
||||||
|
┌────────┬──────┬─────┬───────┬───────────┬────────┐
|
||||||
|
│ ORDERS ┆ LOTS ┆ BID ┆ OFFER ┆ LOTS ┆ ORDERS │
|
||||||
|
╞════════╪══════╪═════╪═══════╪═══════════╪════════╡
|
||||||
|
│ ┆ ┆ ┆ 5,175 ┆ 2,644,625 ┆ 3,284 │
|
||||||
|
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||||
|
│ ┆ ┆ ┆ 5,200 ┆ 100,534 ┆ 337 │
|
||||||
|
├╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌┼╌╌╌╌╌┼╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┤
|
||||||
|
│ ┆ ┆ ┆ 5,225 ┆ 37,114 ┆ 396 │
|
||||||
|
└────────┴──────┴─────┴───────┴───────────┴────────┘
|
||||||
|
Source: your Stockbit session; for personal, non-commercial use (Stockbit's terms).
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
[{"symbol":"BBCA.JK","price":5175,"change":-900,"change_pct":-14.81,"volume":1241098200,"market_cap":null,"week52_high":null,"week52_low":null,"week52_position":null,"range_signal":null,"prev_close":6075,"avg_volume":null,"as_of":null,"behind_session":false,"market_status":"close"}]
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
┌─────────┬───────┬──────┬─────────┬───────────────┬─────┬───────┬────────┬───────┐
|
||||||
|
│ SYMBOL ┆ PRICE ┆ CHG ┆ CHG% ┆ VOLUME ┆ BID ┆ OFFER ┆ MARKET ┆ AS OF │
|
||||||
|
╞═════════╪═══════╪══════╪═════════╪═══════════════╪═════╪═══════╪════════╪═══════╡
|
||||||
|
│ BBCA.JK ┆ 5,175 ┆ -900 ┆ -14.81% ┆ 1,241,098,200 ┆ - ┆ - ┆ close ┆ - │
|
||||||
|
└─────────┴───────┴──────┴─────────┴───────────────┴─────┴───────┴────────┴───────┘
|
||||||
|
Source: Stockbit, your session when logged in (`idx stockbit status`), else its public page, delayed at least 10 minutes (Stockbit's terms); bid/offer only while the market is open.
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
{"logged_in":false,"access":{"present":false,"expires_at":null,"expires_in_secs":null},"refresh":{"present":false,"expires_at":null,"expires_in_secs":null},"session_file":"<ROOT>/config/idx/stockbit-session.json"}
|
||||||
|
--- stderr
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
exit: 0
|
||||||
|
--- stdout
|
||||||
|
logged in: no
|
||||||
|
access token: none
|
||||||
|
refresh token: none
|
||||||
|
session file: <ROOT>/config/idx/stockbit-session.json
|
||||||
|
Run `idx stockbit login` to add one.
|
||||||
|
--- stderr
|
||||||
|
|
@ -30,6 +30,8 @@ directories = "5"
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
fastrand = "2"
|
fastrand = "2"
|
||||||
|
# Decodes a Stockbit session JWT's expiry (`exp`); never verifies or signs.
|
||||||
|
base64 = "0.22"
|
||||||
rusqlite = { workspace = true, optional = true }
|
rusqlite = { workspace = true, optional = true }
|
||||||
quick-xml = { version = "0.37", optional = true }
|
quick-xml = { version = "0.37", optional = true }
|
||||||
sha2 = { workspace = true, optional = true }
|
sha2 = { workspace = true, optional = true }
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ use crate::api::history_provider;
|
||||||
use crate::api::types::{Fundamentals, Interval, Period};
|
use crate::api::types::{Fundamentals, Interval, Period};
|
||||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerRange};
|
||||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
|
use crate::config::HistoryProviderKind;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use crate::market::HistorySource;
|
use crate::market::HistorySource;
|
||||||
use bandar::{BandarReport, analyze_bandar, check_top};
|
use bandar::{BandarReport, analyze_bandar, check_top};
|
||||||
|
|
@ -110,8 +111,16 @@ impl Analysis<'_> {
|
||||||
Ok(BandarScreen { reports, failures })
|
Ok(BandarScreen { reports, failures })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where `technical` reads its year of daily history from, for an
|
||||||
|
/// optional `--history-provider` override.
|
||||||
|
pub fn technical_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
|
||||||
|
self.idx
|
||||||
|
.market()
|
||||||
|
.history_source(mode, &Period::OneYear, &Interval::Day)
|
||||||
|
}
|
||||||
|
|
||||||
/// Technical analysis over one year of daily history from `source`
|
/// Technical analysis over one year of daily history from `source`
|
||||||
/// (see `Market::history_source`).
|
/// (see `technical_source`).
|
||||||
pub fn technical(
|
pub fn technical(
|
||||||
&self,
|
&self,
|
||||||
symbol: &str,
|
symbol: &str,
|
||||||
|
|
@ -119,10 +128,10 @@ impl Analysis<'_> {
|
||||||
policy: CachePolicy,
|
policy: CachePolicy,
|
||||||
) -> Result<Fetched<TechnicalReport>, IdxError> {
|
) -> Result<Fetched<TechnicalReport>, IdxError> {
|
||||||
let idx = self.idx;
|
let idx = self.idx;
|
||||||
let (provider_kind, provider) =
|
|
||||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
|
||||||
let bucket = format!("{}-technical", provider_kind.as_str());
|
|
||||||
let resolved = idx.resolve(symbol)?;
|
let resolved = idx.resolve(symbol)?;
|
||||||
|
idx.market().with_history_fallback(source, |source| {
|
||||||
|
let provider = history_provider(source.provider, idx.config.verbose);
|
||||||
|
let bucket = format!("{}-technical", source.provider.as_str());
|
||||||
cached_fetch(
|
cached_fetch(
|
||||||
&idx.cache,
|
&idx.cache,
|
||||||
CacheSpec {
|
CacheSpec {
|
||||||
|
|
@ -130,12 +139,13 @@ impl Analysis<'_> {
|
||||||
key: &resolved,
|
key: &resolved,
|
||||||
ttl_secs: idx.config.quote_ttl,
|
ttl_secs: idx.config.quote_ttl,
|
||||||
subject: &resolved,
|
subject: &resolved,
|
||||||
source: provider_kind.as_str(),
|
source: source.provider.as_str(),
|
||||||
},
|
},
|
||||||
policy,
|
policy,
|
||||||
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|
|| provider.history(&resolved, &Period::OneYear, &Interval::Day),
|
||||||
|history| build_technical_report(&resolved, &history),
|
|history| build_technical_report(&resolved, &history),
|
||||||
)
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn growth(
|
pub fn growth(
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
//! Fixture-backed provider for tests, enabled by the `testing` feature and
|
||||||
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
//! `IDX_USE_MOCK_PROVIDER`. Release builds do not contain it.
|
||||||
|
|
||||||
use super::types::{Bar, Fundamentals, Interval, Intraday, Period, Quote};
|
use super::stockbit::{corpaction, exodus, movement};
|
||||||
|
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||||
use super::{
|
use super::{
|
||||||
BrokerSummaryProvider, FundamentalsProvider, HistoryProvider, IntradayProvider, QuoteProvider,
|
BrokerSummaryProvider, CorporateActionProvider, FundamentalsProvider, HistoryProvider,
|
||||||
|
IntradayProvider, OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
|
||||||
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
TradingSummaryProvider, idx, ipot, msn, stockbit, yahoo,
|
||||||
};
|
};
|
||||||
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
use crate::broker::types::{BrokerBoard, BrokerInvestor, BrokerQuery};
|
||||||
use crate::config::ProviderKind;
|
use crate::config::ProviderKind;
|
||||||
|
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{MovementPage, MovementQuery};
|
||||||
|
|
||||||
pub(crate) fn enabled() -> bool {
|
pub(crate) fn enabled() -> bool {
|
||||||
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
std::env::var("IDX_USE_MOCK_PROVIDER").is_ok()
|
||||||
|
|
@ -135,22 +139,32 @@ impl HistoryProvider for MockProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stockbit symbol pages from `tests/fixtures/stockbit_symbol_<code>.html`;
|
/// Stockbit from fixtures. Without a session (none stored or seeded through
|
||||||
/// a code without a fixture is Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS`
|
/// `IDX_STOCKBIT_REFRESH_TOKEN`): the public page,
|
||||||
/// (e.g. `403`) answers that HTTP status instead, and `IDX_MOCK_ERROR` fails
|
/// `tests/fixtures/stockbit_symbol_<code>.html`. With one: the session API,
|
||||||
/// as for the other providers.
|
/// `tests/fixtures/stockbit_exodus_orderbook_<code>.json` and
|
||||||
|
/// `stockbit_exodus_chart_candle_<code>_<window>.json`, with the market state
|
||||||
|
/// from `stockbit_exodus_market_session.json`. A code without a fixture is
|
||||||
|
/// Stockbit's 404. `IDX_MOCK_STOCKBIT_STATUS` (e.g. `403`) answers that HTTP
|
||||||
|
/// status instead (`401` on the session API: Stockbit refused the session),
|
||||||
|
/// and `IDX_MOCK_ERROR` fails as for the other providers.
|
||||||
pub struct MockStockbitProvider;
|
pub struct MockStockbitProvider;
|
||||||
|
|
||||||
impl MockStockbitProvider {
|
impl MockStockbitProvider {
|
||||||
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
|
fn failure(&self, symbol: &str) -> Option<IdxError> {
|
||||||
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
if std::env::var("IDX_MOCK_ERROR").is_ok() {
|
||||||
return Err(IdxError::ProviderUnavailable);
|
return Some(IdxError::ProviderUnavailable);
|
||||||
}
|
}
|
||||||
if let Ok(status) = std::env::var("IDX_MOCK_STOCKBIT_STATUS") {
|
let status = std::env::var("IDX_MOCK_STOCKBIT_STATUS").ok()?;
|
||||||
return Err(stockbit::status_error(
|
Some(stockbit::status_error(
|
||||||
status.parse().unwrap_or(500),
|
status.parse().unwrap_or(500),
|
||||||
symbol,
|
symbol,
|
||||||
));
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot(&self, symbol: &str) -> Result<Intraday, IdxError> {
|
||||||
|
if let Some(err) = self.failure(symbol) {
|
||||||
|
return Err(err);
|
||||||
}
|
}
|
||||||
let code = stockbit::stockbit_code(symbol)?;
|
let code = stockbit::stockbit_code(symbol)?;
|
||||||
let path = format!(
|
let path = format!(
|
||||||
|
|
@ -162,10 +176,41 @@ impl MockStockbitProvider {
|
||||||
Err(_) => Err(stockbit::status_error(404, symbol)),
|
Err(_) => Err(stockbit::status_error(404, symbol)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A session API fixture, or the error a request would meet.
|
||||||
|
fn exodus(&self, symbol: &str, name: &str) -> Result<String, IdxError> {
|
||||||
|
if !stockbit::has_session() {
|
||||||
|
return Err(IdxError::AuthError(stockbit::auth::NO_SESSION.to_string()));
|
||||||
|
}
|
||||||
|
if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("401") {
|
||||||
|
return Err(IdxError::AuthError(
|
||||||
|
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// The session API's own block, as `exodus::fetch` reports it.
|
||||||
|
if std::env::var("IDX_MOCK_STOCKBIT_STATUS").as_deref() == Ok("403") {
|
||||||
|
return Err(stockbit::exodus::blocked());
|
||||||
|
}
|
||||||
|
if let Some(err) = self.failure(symbol) {
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
std::fs::read_to_string(format!("tests/fixtures/stockbit_exodus_{name}"))
|
||||||
|
.map_err(|_| IdxError::SymbolNotFound(symbol.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn book(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||||
|
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
|
||||||
|
let body = self.exodus(symbol, &format!("orderbook_{code}.json"))?;
|
||||||
|
let state = self.exodus(symbol, "market_session.json")?;
|
||||||
|
exodus::parse_orderbook(symbol, &body, exodus::parse_market_status(&state))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl QuoteProvider for MockStockbitProvider {
|
impl QuoteProvider for MockStockbitProvider {
|
||||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||||
|
if stockbit::has_session() {
|
||||||
|
return self.book(symbol).map(|book| exodus::quote_from_book(&book));
|
||||||
|
}
|
||||||
let mut quote = self.snapshot(symbol)?.quote;
|
let mut quote = self.snapshot(symbol)?.quote;
|
||||||
// Quotes are judged against the clock (behind the session, a week
|
// Quotes are judged against the clock (behind the session, a week
|
||||||
// old); a fixed trade time would make their output age.
|
// old); a fixed trade time would make their output age.
|
||||||
|
|
@ -186,6 +231,89 @@ impl IntradayProvider for MockStockbitProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OrderBookProvider for MockStockbitProvider {
|
||||||
|
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||||
|
self.book(symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `stockbit_exodus_corpaction_<type>.json` for the market,
|
||||||
|
/// `..._<type>_<code>.json` for one stock; a stock without a fixture has none
|
||||||
|
/// of that kind, as Stockbit answers.
|
||||||
|
impl CorporateActionProvider for MockStockbitProvider {
|
||||||
|
fn corporate_actions(
|
||||||
|
&self,
|
||||||
|
kind: ActionKind,
|
||||||
|
symbol: Option<&str>,
|
||||||
|
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||||
|
let subject = symbol.unwrap_or("corporate actions");
|
||||||
|
let name = match symbol {
|
||||||
|
Some(symbol) => format!(
|
||||||
|
"corpaction_{}_{}.json",
|
||||||
|
corpaction::path_type(kind),
|
||||||
|
stockbit::stockbit_code(symbol)?.to_ascii_lowercase()
|
||||||
|
),
|
||||||
|
None => format!("corpaction_{}.json", corpaction::path_type(kind)),
|
||||||
|
};
|
||||||
|
let body = match self.exodus(subject, &name) {
|
||||||
|
Err(IdxError::SymbolNotFound(_)) if symbol.is_some() => {
|
||||||
|
r#"{"data":{"none":[]}}"#.to_string()
|
||||||
|
}
|
||||||
|
other => other?,
|
||||||
|
};
|
||||||
|
corpaction::parse_corporate_actions(kind, &body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `stockbit_exodus_majorholder[_<code>][_p<page>].json` (no suffix for page
|
||||||
|
/// 1); a missing fixture is an empty page, as Stockbit answers past the end.
|
||||||
|
/// The query's filters apply to the fixture's rows, as Stockbit applies them.
|
||||||
|
impl ShareholderMovementProvider for MockStockbitProvider {
|
||||||
|
fn shareholder_movements(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<MovementPage, IdxError> {
|
||||||
|
let subject = query.symbol.as_deref().unwrap_or("shareholder movements");
|
||||||
|
// `IDX_MOCK_MOVEMENTS_FAIL_PAGE=N` rate-limits page N alone.
|
||||||
|
if std::env::var("IDX_MOCK_MOVEMENTS_FAIL_PAGE").ok() == Some(page.to_string()) {
|
||||||
|
return Err(IdxError::RateLimited);
|
||||||
|
}
|
||||||
|
let mut name = "majorholder".to_string();
|
||||||
|
if let Some(symbol) = &query.symbol {
|
||||||
|
name.push('_');
|
||||||
|
name.push_str(&stockbit::stockbit_code(symbol)?.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
name.push_str(&format!("_p{page}"));
|
||||||
|
}
|
||||||
|
let body = match self.exodus(subject, &format!("{name}.json")) {
|
||||||
|
Err(IdxError::SymbolNotFound(_)) if query.symbol.is_some() || page > 1 => {
|
||||||
|
r#"{"data":{"is_more":false,"movement":[]}}"#.to_string()
|
||||||
|
}
|
||||||
|
other => other?,
|
||||||
|
};
|
||||||
|
let mut parsed = movement::parse_movements(&body)?;
|
||||||
|
parsed.movements.retain(|m| query.admits(m));
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryProvider for MockStockbitProvider {
|
||||||
|
fn history(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<Vec<Bar>, IdxError> {
|
||||||
|
let window = exodus::history_window(period, interval)
|
||||||
|
.ok_or_else(|| exodus::unsupported_window(period, interval))?;
|
||||||
|
let code = stockbit::stockbit_code(symbol)?.to_ascii_lowercase();
|
||||||
|
let body = self.exodus(symbol, &format!("chart_candle_{code}_{window}.json"))?;
|
||||||
|
exodus::daily_for_period(&body, period)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from>
|
/// Broker summaries from `tests/fixtures/ipot_brokersummary_<code>_<from>
|
||||||
/// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an
|
/// [_<to>][_<investor>_<board>].html`; a query without a fixture reads as an
|
||||||
/// unknown code.
|
/// unknown code.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use crate::config::{HistoryProviderKind, ProviderKind};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use types::{
|
use types::{
|
||||||
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
Bar, CompanyProfile, EarningsReport, FinancialStatements, Fundamentals, InsightData, Interval,
|
||||||
Intraday, NewsItem, Period, Quote, SentimentData,
|
Intraday, NewsItem, OrderBook, Period, Quote, SentimentData,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
|
/// IDX trades in Western Indonesia Time (WIB, UTC+7, no DST). Providers stamp
|
||||||
|
|
@ -72,6 +72,34 @@ pub trait IntradayProvider {
|
||||||
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError>;
|
fn intraday(&self, symbol: &str) -> Result<Intraday, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The order book of one IDX stock: needs the user's Stockbit session.
|
||||||
|
pub trait OrderBookProvider {
|
||||||
|
/// `symbol` is resolved, e.g. `BBCA.JK`.
|
||||||
|
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corporate actions of one kind: needs the user's Stockbit session.
|
||||||
|
pub trait CorporateActionProvider {
|
||||||
|
/// One stock's history (`symbol` resolved, e.g. `BBCA.JK`), or the
|
||||||
|
/// market's announced and recent actions with `None`.
|
||||||
|
fn corporate_actions(
|
||||||
|
&self,
|
||||||
|
kind: crate::corporate::types::ActionKind,
|
||||||
|
symbol: Option<&str>,
|
||||||
|
) -> Result<Vec<crate::corporate::types::CorporateAction>, IdxError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shareholder movements: needs the user's Stockbit session.
|
||||||
|
pub trait ShareholderMovementProvider {
|
||||||
|
/// Page `page` (from 1) of `query`, whose `symbol` is resolved
|
||||||
|
/// (`BBCA.JK`) or `None` for the market.
|
||||||
|
fn shareholder_movements(
|
||||||
|
&self,
|
||||||
|
query: &crate::movements::types::MovementQuery,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<crate::movements::types::MovementPage, IdxError>;
|
||||||
|
}
|
||||||
|
|
||||||
pub trait FundamentalsProvider {
|
pub trait FundamentalsProvider {
|
||||||
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
fn fundamentals(&self, symbol: &str) -> Result<Fundamentals, IdxError>;
|
||||||
}
|
}
|
||||||
|
|
@ -242,9 +270,12 @@ fn mock_market(_provider: ProviderKind) -> Option<Box<dyn MarketDataProvider>> {
|
||||||
|
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
|
fn mock_history(provider: ProviderKind, verbose: bool) -> Option<Box<dyn HistoryProvider>> {
|
||||||
Some(Box::new(
|
match provider {
|
||||||
|
ProviderKind::Stockbit => Some(Box::new(mock::MockStockbitProvider)),
|
||||||
|
_ => Some(Box::new(
|
||||||
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
|
mock::MockProvider::from_fixtures_with_history_verbose(provider, verbose),
|
||||||
))
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "testing"))]
|
#[cfg(not(feature = "testing"))]
|
||||||
|
|
@ -285,7 +316,7 @@ fn build_selected_provider(
|
||||||
news: Some(Box::new(msn::MsnProvider::new(verbose))),
|
news: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||||
screener: Some(Box::new(msn::MsnProvider::new(verbose))),
|
screener: Some(Box::new(msn::MsnProvider::new(verbose))),
|
||||||
},
|
},
|
||||||
// Quotes only; history falls back to Yahoo (see `history_source`).
|
// Quotes only; history needs a session (see `history_source`).
|
||||||
ProviderKind::Stockbit => SelectedProvider {
|
ProviderKind::Stockbit => SelectedProvider {
|
||||||
kind: ProviderKind::Stockbit,
|
kind: ProviderKind::Stockbit,
|
||||||
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
|
market: mock.unwrap_or_else(|| Box::new(stockbit::StockbitProvider::new())),
|
||||||
|
|
@ -328,6 +359,30 @@ pub fn intraday_provider() -> Box<dyn IntradayProvider> {
|
||||||
Box::new(stockbit::StockbitProvider::new())
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn orderbook_provider() -> Box<dyn OrderBookProvider> {
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
if mock::enabled() {
|
||||||
|
return Box::new(mock::MockStockbitProvider);
|
||||||
|
}
|
||||||
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn corporate_action_provider() -> Box<dyn CorporateActionProvider> {
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
if mock::enabled() {
|
||||||
|
return Box::new(mock::MockStockbitProvider);
|
||||||
|
}
|
||||||
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shareholder_movement_provider() -> Box<dyn ShareholderMovementProvider> {
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
if mock::enabled() {
|
||||||
|
return Box::new(mock::MockStockbitProvider);
|
||||||
|
}
|
||||||
|
Box::new(stockbit::StockbitProvider::new())
|
||||||
|
}
|
||||||
|
|
||||||
/// Today in WIB, the date IDX trades on.
|
/// Today in WIB, the date IDX trades on.
|
||||||
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||||
#[cfg(feature = "testing")]
|
#[cfg(feature = "testing")]
|
||||||
|
|
@ -338,40 +393,44 @@ pub(crate) fn today_wib() -> chrono::NaiveDate {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The provider that serves history for `provider` under `history_mode`.
|
/// The provider that serves history for `provider` under `history_mode`.
|
||||||
pub fn history_source(provider: ProviderKind, history_mode: HistoryProviderKind) -> ProviderKind {
|
/// `auto` uses Stockbit when it is the selected provider, the user has a
|
||||||
|
/// session and the window is one of its daily charts; otherwise Yahoo, whose
|
||||||
|
/// candles are full OHLCV for any window.
|
||||||
|
pub fn history_source(
|
||||||
|
provider: ProviderKind,
|
||||||
|
history_mode: HistoryProviderKind,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
stockbit_session: bool,
|
||||||
|
) -> ProviderKind {
|
||||||
match history_mode {
|
match history_mode {
|
||||||
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
|
HistoryProviderKind::Yahoo => ProviderKind::Yahoo,
|
||||||
HistoryProviderKind::Msn => ProviderKind::Msn,
|
HistoryProviderKind::Msn => ProviderKind::Msn,
|
||||||
HistoryProviderKind::Auto => match provider {
|
HistoryProviderKind::Stockbit => ProviderKind::Stockbit,
|
||||||
ProviderKind::Yahoo | ProviderKind::Msn | ProviderKind::Stockbit => ProviderKind::Yahoo,
|
HistoryProviderKind::Auto => {
|
||||||
},
|
if provider == ProviderKind::Stockbit
|
||||||
}
|
&& stockbit_session
|
||||||
}
|
&& stockbit::exodus::history_window(period, interval).is_some()
|
||||||
|
|
||||||
/// Resolves a history provider based on the selected market data provider and
|
|
||||||
/// history provider strategy.
|
|
||||||
///
|
|
||||||
/// `history_mode=auto` keeps using Yahoo for IDX history because Yahoo provides
|
|
||||||
/// full OHLCV candles. Explicit `msn` opts into MSN's price-only chart feed.
|
|
||||||
pub fn history_provider(
|
|
||||||
provider: ProviderKind,
|
|
||||||
history_mode: HistoryProviderKind,
|
|
||||||
verbose: bool,
|
|
||||||
) -> Result<(ProviderKind, Box<dyn HistoryProvider>), IdxError> {
|
|
||||||
let resolved = history_source(provider, history_mode);
|
|
||||||
|
|
||||||
if mock_enabled()
|
|
||||||
&& let Some(mock) = mock_history(resolved, verbose)
|
|
||||||
{
|
{
|
||||||
return Ok((resolved, mock));
|
ProviderKind::Stockbit
|
||||||
|
} else {
|
||||||
|
ProviderKind::Yahoo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match resolved {
|
/// The history provider for a source from `history_source`.
|
||||||
ProviderKind::Yahoo => Ok((resolved, Box::new(yahoo::YahooProvider::new(verbose)))),
|
pub fn history_provider(source: ProviderKind, verbose: bool) -> Box<dyn HistoryProvider> {
|
||||||
ProviderKind::Msn => Ok((resolved, Box::new(msn::MsnProvider::new(verbose)))),
|
if mock_enabled()
|
||||||
ProviderKind::Stockbit => Err(IdxError::Unsupported(
|
&& let Some(mock) = mock_history(source, verbose)
|
||||||
"Stockbit serves no price history; use --history-provider yahoo or msn".to_string(),
|
{
|
||||||
)),
|
return mock;
|
||||||
|
}
|
||||||
|
match source {
|
||||||
|
ProviderKind::Yahoo => Box::new(yahoo::YahooProvider::new(verbose)),
|
||||||
|
ProviderKind::Msn => Box::new(msn::MsnProvider::new(verbose)),
|
||||||
|
ProviderKind::Stockbit => Box::new(stockbit::StockbitProvider::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -472,4 +531,72 @@ mod tests {
|
||||||
"unsupported: screen: command requires --provider msn"
|
"unsupported: screen: command requires --provider msn"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_history_uses_stockbit_only_with_a_session_and_a_daily_window() {
|
||||||
|
use super::history_source;
|
||||||
|
use crate::api::types::{Interval, Period};
|
||||||
|
use crate::config::HistoryProviderKind::{Auto, Stockbit, Yahoo};
|
||||||
|
|
||||||
|
let source = |provider, mode, period, interval, session| {
|
||||||
|
history_source(provider, mode, &period, &interval, session)
|
||||||
|
};
|
||||||
|
let year = (Period::OneYear, Interval::Day);
|
||||||
|
assert_eq!(
|
||||||
|
source(
|
||||||
|
ProviderKind::Stockbit,
|
||||||
|
Auto,
|
||||||
|
year.0.clone(),
|
||||||
|
year.1.clone(),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
ProviderKind::Stockbit
|
||||||
|
);
|
||||||
|
// No session, a window Stockbit charts weekly, or another provider.
|
||||||
|
assert_eq!(
|
||||||
|
source(
|
||||||
|
ProviderKind::Stockbit,
|
||||||
|
Auto,
|
||||||
|
year.0.clone(),
|
||||||
|
year.1.clone(),
|
||||||
|
false
|
||||||
|
),
|
||||||
|
ProviderKind::Yahoo
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source(
|
||||||
|
ProviderKind::Stockbit,
|
||||||
|
Auto,
|
||||||
|
Period::FiveYears,
|
||||||
|
Interval::Day,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
ProviderKind::Yahoo
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source(
|
||||||
|
ProviderKind::Msn,
|
||||||
|
Auto,
|
||||||
|
year.0.clone(),
|
||||||
|
year.1.clone(),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
ProviderKind::Yahoo
|
||||||
|
);
|
||||||
|
// An explicit choice wins either way.
|
||||||
|
assert_eq!(
|
||||||
|
source(
|
||||||
|
ProviderKind::Msn,
|
||||||
|
Stockbit,
|
||||||
|
year.0.clone(),
|
||||||
|
year.1.clone(),
|
||||||
|
false
|
||||||
|
),
|
||||||
|
ProviderKind::Stockbit
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
source(ProviderKind::Stockbit, Yahoo, year.0, year.1, true),
|
||||||
|
ProviderKind::Yahoo
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
698
crates/idx-core/src/api/stockbit/auth.rs
Normal file
698
crates/idx-core/src/api/stockbit/auth.rs
Normal file
|
|
@ -0,0 +1,698 @@
|
||||||
|
//! The user's own Stockbit session: two bearer JWTs, where they are kept, and
|
||||||
|
//! how they are refreshed. Opt-in; nothing here runs without a session.
|
||||||
|
//!
|
||||||
|
//! - The **access** token (about a day) authorises every
|
||||||
|
//! `exodus.stockbit.com` request.
|
||||||
|
//! - The **refresh** token (about a week) is spent on `POST /login/refresh`,
|
||||||
|
//! which answers a new access token *and a new refresh token*, retiring the
|
||||||
|
//! one presented. So exactly one party may refresh a session: two idx
|
||||||
|
//! processes refresh under a file lock and re-read what the other wrote, and
|
||||||
|
//! a browser sharing the session gets logged out once idx refreshes.
|
||||||
|
//!
|
||||||
|
//! The session lives in `<config dir>/stockbit-session.json` (mode 0600).
|
||||||
|
//! `IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` seed fields the
|
||||||
|
//! file lacks, for servers. Tokens never reach logs, errors or cache keys.
|
||||||
|
//! The token shapes follow fzn0x/idx (MIT).
|
||||||
|
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
pub const ENV_REFRESH: &str = "IDX_STOCKBIT_REFRESH_TOKEN";
|
||||||
|
pub const ENV_ACCESS: &str = "IDX_STOCKBIT_ACCESS_TOKEN";
|
||||||
|
const SESSION_FILE: &str = "stockbit-session.json";
|
||||||
|
const LOCK_FILE: &str = "stockbit-session.lock";
|
||||||
|
/// Refresh this long before the access token expires, so no request races
|
||||||
|
/// the expiry.
|
||||||
|
const REFRESH_BUFFER_SECS: i64 = 60 * 60;
|
||||||
|
|
||||||
|
pub(crate) const NO_SESSION: &str =
|
||||||
|
"no Stockbit session; run `idx stockbit login` (or set IDX_STOCKBIT_REFRESH_TOKEN)";
|
||||||
|
|
||||||
|
/// The persisted token pair. Expiries are read from the tokens themselves.
|
||||||
|
#[derive(Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Session {
|
||||||
|
#[serde(default)]
|
||||||
|
pub access: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub refresh: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Session {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Session")
|
||||||
|
.field("access", &(!self.access.is_empty()).then_some("<redacted>"))
|
||||||
|
.field(
|
||||||
|
"refresh",
|
||||||
|
&(!self.refresh.is_empty()).then_some("<redacted>"),
|
||||||
|
)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.access.is_empty() && self.refresh.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token's presence and expiry, never its value.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TokenInfo {
|
||||||
|
pub present: bool,
|
||||||
|
/// When the token expires; `null` when absent or not a JWT.
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
/// Seconds until expiry, negative once expired.
|
||||||
|
pub expires_in_secs: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenInfo {
|
||||||
|
fn of(token: &str, now: i64) -> Self {
|
||||||
|
let present = !token.is_empty();
|
||||||
|
let exp = present.then(|| token_expiry(token)).flatten();
|
||||||
|
Self {
|
||||||
|
present,
|
||||||
|
expires_at: exp.and_then(|e| DateTime::from_timestamp(e, 0)),
|
||||||
|
expires_in_secs: exp.map(|e| e - now),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `idx stockbit status` and the MCP `stockbit_status` tool report.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SessionStatus {
|
||||||
|
/// A refresh or access token is stored or seeded (live or not).
|
||||||
|
pub logged_in: bool,
|
||||||
|
pub access: TokenInfo,
|
||||||
|
pub refresh: TokenInfo,
|
||||||
|
pub session_file: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the session and idx's browser profile live: beside `config.toml`,
|
||||||
|
/// or in the other default location when that is where they are.
|
||||||
|
///
|
||||||
|
/// On macOS the config directory depends on the environment: a shell that
|
||||||
|
/// sets `XDG_CONFIG_HOME=~/.config` and an app started from the Dock, which
|
||||||
|
/// does not, resolve to different places, and the CLI and an MCP client must
|
||||||
|
/// still find one session. A custom `XDG_CONFIG_HOME` has no other location,
|
||||||
|
/// so a run isolated with one never reaches the user's session.
|
||||||
|
pub fn session_dir() -> Result<PathBuf, IdxError> {
|
||||||
|
let config = crate::config::config_path()?;
|
||||||
|
let primary = config
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?
|
||||||
|
.to_path_buf();
|
||||||
|
let other = if cfg!(target_os = "macos") {
|
||||||
|
std::env::var_os("HOME").and_then(|home| other_default(&primary, Path::new(&home)))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(choose_dir(primary, other))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The second default config location, when `primary` is one of the two.
|
||||||
|
fn other_default(primary: &Path, home: &Path) -> Option<PathBuf> {
|
||||||
|
let dot_config = home.join(".config/idx");
|
||||||
|
let library = home.join("Library/Application Support/idx");
|
||||||
|
if primary == dot_config {
|
||||||
|
Some(library)
|
||||||
|
} else if primary == library {
|
||||||
|
Some(dot_config)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory holding a session, else a browser profile, else `primary`.
|
||||||
|
fn choose_dir(primary: PathBuf, other: Option<PathBuf>) -> PathBuf {
|
||||||
|
let Some(other) = other else {
|
||||||
|
return primary;
|
||||||
|
};
|
||||||
|
for entry in [SESSION_FILE, super::browser::PROFILE_DIR] {
|
||||||
|
if primary.join(entry).exists() {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
if other.join(entry).exists() {
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
primary
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads, writes and refreshes the session.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
seed: Session,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
/// The session in `session_dir`, seeded from the environment.
|
||||||
|
pub fn open() -> Result<Self, IdxError> {
|
||||||
|
let env = |name| {
|
||||||
|
std::env::var(name)
|
||||||
|
.map(|v| normalize_token(&v))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
Ok(Self::at(
|
||||||
|
session_dir()?,
|
||||||
|
Session {
|
||||||
|
access: env(ENV_ACCESS),
|
||||||
|
refresh: env(ENV_REFRESH),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn at(dir: PathBuf, seed: Session) -> Self {
|
||||||
|
Self { dir, seed }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> PathBuf {
|
||||||
|
self.dir.join(SESSION_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file's tokens, each missing one taken from the seed.
|
||||||
|
pub fn load(&self) -> Session {
|
||||||
|
let mut session = std::fs::read_to_string(self.path())
|
||||||
|
.ok()
|
||||||
|
.and_then(|raw| serde_json::from_str::<Session>(&raw).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
session.access = normalize_token(&session.access);
|
||||||
|
session.refresh = normalize_token(&session.refresh);
|
||||||
|
if session.access.is_empty() {
|
||||||
|
session.access.clone_from(&self.seed.access);
|
||||||
|
}
|
||||||
|
if session.refresh.is_empty() {
|
||||||
|
session.refresh.clone_from(&self.seed.refresh);
|
||||||
|
}
|
||||||
|
session
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_session(&self) -> bool {
|
||||||
|
!self.load().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes the session atomically, readable by the owner only.
|
||||||
|
pub fn save(&self, session: &Session) -> Result<(), IdxError> {
|
||||||
|
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session: {e}"));
|
||||||
|
std::fs::create_dir_all(&self.dir).map_err(io)?;
|
||||||
|
let tmp = self.dir.join(format!("{SESSION_FILE}.tmp"));
|
||||||
|
let body = serde_json::to_vec_pretty(session)
|
||||||
|
.map_err(|e| IdxError::Io(format!("stockbit session: {e}")))?;
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create(true).truncate(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||||
|
let mut file = options.open(&tmp).map_err(io)?;
|
||||||
|
std::io::Write::write_all(&mut file, &body).map_err(io)?;
|
||||||
|
file.sync_all().map_err(io)?;
|
||||||
|
drop(file);
|
||||||
|
std::fs::rename(&tmp, self.path()).map_err(io)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the stored session; false when there was none. Seeds from the
|
||||||
|
/// environment are not affected.
|
||||||
|
pub fn clear(&self) -> Result<bool, IdxError> {
|
||||||
|
match std::fs::remove_file(self.path()) {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||||
|
Err(e) => Err(IdxError::Io(format!("stockbit session: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self, now: i64) -> SessionStatus {
|
||||||
|
let session = self.load();
|
||||||
|
SessionStatus {
|
||||||
|
logged_in: !session.is_empty(),
|
||||||
|
access: TokenInfo::of(&session.access, now),
|
||||||
|
refresh: TokenInfo::of(&session.refresh, now),
|
||||||
|
session_file: self.path().display().to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An access token good for at least `REFRESH_BUFFER_SECS`, refreshing
|
||||||
|
/// with `refresher` (refresh token in, response body out) when it is not.
|
||||||
|
pub fn access(
|
||||||
|
&self,
|
||||||
|
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
|
||||||
|
now: i64,
|
||||||
|
) -> Result<String, IdxError> {
|
||||||
|
let current = self.load();
|
||||||
|
if current.is_empty() {
|
||||||
|
return Err(IdxError::AuthError(NO_SESSION.to_string()));
|
||||||
|
}
|
||||||
|
if fresh(¤t.access, now) {
|
||||||
|
return Ok(current.access);
|
||||||
|
}
|
||||||
|
self.locked(|| {
|
||||||
|
// Another process may have refreshed while this one waited.
|
||||||
|
let session = self.load();
|
||||||
|
if fresh(&session.access, now) {
|
||||||
|
return Ok(session.access);
|
||||||
|
}
|
||||||
|
match self.rotate(&session, refresher) {
|
||||||
|
Ok(access) => Ok(access),
|
||||||
|
// A failed refresh must not block a token that still works.
|
||||||
|
Err(_) if token_expiry(&session.access).is_some_and(|exp| now < exp) => {
|
||||||
|
Ok(session.access)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new access token after the server rejected `rejected`, unless
|
||||||
|
/// another process already replaced it.
|
||||||
|
pub fn after_rejection(
|
||||||
|
&self,
|
||||||
|
rejected: &str,
|
||||||
|
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
|
||||||
|
) -> Result<String, IdxError> {
|
||||||
|
self.locked(|| {
|
||||||
|
let session = self.load();
|
||||||
|
if !session.access.is_empty() && session.access != rejected {
|
||||||
|
return Ok(session.access);
|
||||||
|
}
|
||||||
|
self.rotate(&session, refresher)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spends the refresh token and stores the new pair.
|
||||||
|
fn rotate(
|
||||||
|
&self,
|
||||||
|
session: &Session,
|
||||||
|
refresher: &dyn Fn(&str) -> Result<String, IdxError>,
|
||||||
|
) -> Result<String, IdxError> {
|
||||||
|
if session.refresh.is_empty() {
|
||||||
|
return Err(IdxError::AuthError(
|
||||||
|
"the Stockbit session has no refresh token; run `idx stockbit login`".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let body = refresher(&session.refresh)?;
|
||||||
|
let next = parse_refresh(&body, session)?;
|
||||||
|
self.save(&next)?;
|
||||||
|
Ok(next.access)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `f` holding the session lock, so only one process spends the
|
||||||
|
/// rotating refresh token at a time.
|
||||||
|
fn locked<T>(&self, f: impl FnOnce() -> Result<T, IdxError>) -> Result<T, IdxError> {
|
||||||
|
let io = |e: std::io::Error| IdxError::Io(format!("stockbit session lock: {e}"));
|
||||||
|
std::fs::create_dir_all(&self.dir).map_err(io)?;
|
||||||
|
let lock = lock_file(&self.dir.join(LOCK_FILE)).map_err(io)?;
|
||||||
|
lock.lock().map_err(io)?;
|
||||||
|
let result = f();
|
||||||
|
drop(lock);
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_file(path: &Path) -> std::io::Result<File> {
|
||||||
|
OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh(access: &str, now: i64) -> bool {
|
||||||
|
token_expiry(access).is_some_and(|exp| now < exp - REFRESH_BUFFER_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips whitespace and a leading `Bearer `.
|
||||||
|
pub fn normalize_token(raw: &str) -> String {
|
||||||
|
let t = raw.trim();
|
||||||
|
t.strip_prefix("Bearer ")
|
||||||
|
.or_else(|| t.strip_prefix("bearer "))
|
||||||
|
.unwrap_or(t)
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A JWT's `exp` (Unix seconds), read without verifying the signature: idx
|
||||||
|
/// only carries these tokens.
|
||||||
|
pub fn token_expiry(token: &str) -> Option<i64> {
|
||||||
|
let payload = token.split('.').nth(1)?;
|
||||||
|
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||||
|
.decode(payload.trim_end_matches('='))
|
||||||
|
.ok()?;
|
||||||
|
serde_json::from_slice::<Value>(&bytes)
|
||||||
|
.ok()?
|
||||||
|
.get("exp")?
|
||||||
|
.as_i64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Three dot-separated base64url parts with a JSON header (`eyJ`).
|
||||||
|
fn looks_like_jwt(value: &str) -> bool {
|
||||||
|
let mut parts = value.split('.');
|
||||||
|
matches!(parts.next(), Some(h) if h.starts_with("eyJ"))
|
||||||
|
&& parts.next().is_some_and(|p| !p.is_empty())
|
||||||
|
&& parts.next().is_some_and(|p| !p.is_empty())
|
||||||
|
&& parts.next().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The JWT under key `slot` anywhere in `value`: a bare string or an object
|
||||||
|
/// with `token`. Stockbit's envelopes have moved between versions.
|
||||||
|
fn find_token(value: &Value, slot: &str) -> Option<String> {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
let direct = map.get(slot).and_then(|hit| {
|
||||||
|
hit.as_str()
|
||||||
|
.or_else(|| hit.get("token").and_then(Value::as_str))
|
||||||
|
.filter(|t| looks_like_jwt(t))
|
||||||
|
.map(str::to_string)
|
||||||
|
});
|
||||||
|
direct.or_else(|| map.values().find_map(|child| find_token(child, slot)))
|
||||||
|
}
|
||||||
|
Value::Array(items) => items.iter().find_map(|child| find_token(child, slot)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The new pair from a `/login/refresh` response; a response that rotates
|
||||||
|
/// only the access token keeps the presented refresh token.
|
||||||
|
pub fn parse_refresh(body: &str, previous: &Session) -> Result<Session, IdxError> {
|
||||||
|
let value: Value = serde_json::from_str(body)
|
||||||
|
.map_err(|e| IdxError::ParseError(format!("stockbit refresh response: {e}")))?;
|
||||||
|
let access = find_token(&value, "access").ok_or_else(|| {
|
||||||
|
IdxError::ParseError("stockbit refresh response carried no access token".to_string())
|
||||||
|
})?;
|
||||||
|
Ok(Session {
|
||||||
|
access,
|
||||||
|
refresh: find_token(&value, "refresh").unwrap_or_else(|| previous.refresh.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session from what a user pastes: the `credentialStorage` cookie of
|
||||||
|
/// stockbit.com (percent-encoded JSON), that JSON decoded, or a bare refresh
|
||||||
|
/// token.
|
||||||
|
pub fn session_from_paste(raw: &str) -> Option<Session> {
|
||||||
|
let raw = raw.trim();
|
||||||
|
let decoded = percent_decode(raw);
|
||||||
|
for candidate in [decoded.as_str(), raw] {
|
||||||
|
if let Ok(value) = serde_json::from_str::<Value>(candidate) {
|
||||||
|
let session = Session {
|
||||||
|
access: find_token(&value, "access").unwrap_or_default(),
|
||||||
|
refresh: find_token(&value, "refresh").unwrap_or_default(),
|
||||||
|
};
|
||||||
|
if !session.is_empty() {
|
||||||
|
return Some(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let token = normalize_token(raw);
|
||||||
|
looks_like_jwt(&token).then(|| Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `%XX` escapes decoded; anything else kept as is.
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == b'%'
|
||||||
|
&& let Some(hex) = s.get(i + 1..i + 3)
|
||||||
|
&& let Ok(byte) = u8::from_str_radix(hex, 16)
|
||||||
|
{
|
||||||
|
out.push(byte);
|
||||||
|
i += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The two default locations pair up; anything else stands alone, so
|
||||||
|
/// a run with its own `XDG_CONFIG_HOME` cannot reach the user's session.
|
||||||
|
#[test]
|
||||||
|
fn only_the_two_default_locations_pair_up() {
|
||||||
|
let home = Path::new("/Users/me");
|
||||||
|
let dot_config = home.join(".config/idx");
|
||||||
|
let library = home.join("Library/Application Support/idx");
|
||||||
|
assert_eq!(other_default(&dot_config, home), Some(library.clone()));
|
||||||
|
assert_eq!(other_default(&library, home), Some(dot_config));
|
||||||
|
assert_eq!(other_default(Path::new("/tmp/run/idx"), home), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session anywhere beats a profile, a profile beats nothing, and with
|
||||||
|
/// neither the primary location is used.
|
||||||
|
#[test]
|
||||||
|
fn the_session_is_found_in_either_location() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"idx-session-dir-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
fastrand::u32(..)
|
||||||
|
));
|
||||||
|
let (primary, other) = (root.join("primary"), root.join("other"));
|
||||||
|
std::fs::create_dir_all(&primary).unwrap();
|
||||||
|
std::fs::create_dir_all(&other).unwrap();
|
||||||
|
let choose = || choose_dir(primary.clone(), Some(other.clone()));
|
||||||
|
|
||||||
|
assert_eq!(choose(), primary);
|
||||||
|
assert_eq!(choose_dir(primary.clone(), None), primary);
|
||||||
|
|
||||||
|
std::fs::create_dir(other.join(super::super::browser::PROFILE_DIR)).unwrap();
|
||||||
|
assert_eq!(choose(), other, "a profile says where the user signs in");
|
||||||
|
|
||||||
|
std::fs::write(primary.join(SESSION_FILE), "{}").unwrap();
|
||||||
|
assert_eq!(choose(), primary, "a session beats a profile");
|
||||||
|
|
||||||
|
std::fs::remove_file(primary.join(SESSION_FILE)).unwrap();
|
||||||
|
std::fs::write(other.join(SESSION_FILE), "{}").unwrap();
|
||||||
|
assert_eq!(choose(), other);
|
||||||
|
|
||||||
|
std::fs::write(primary.join(SESSION_FILE), "{}").unwrap();
|
||||||
|
assert_eq!(choose(), primary, "with two, the primary one wins");
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
std::fs::read_to_string(format!(
|
||||||
|
"{}/tests/fixtures/{name}",
|
||||||
|
env!("CARGO_MANIFEST_DIR")
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unsigned JWT expiring at `exp`.
|
||||||
|
fn jwt(exp: i64, tag: &str) -> String {
|
||||||
|
let enc = |v: &str| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(v);
|
||||||
|
format!(
|
||||||
|
"{}.{}.{}",
|
||||||
|
enc(r#"{"alg":"none"}"#),
|
||||||
|
enc(&format!(r#"{{"exp":{exp},"tag":"{tag}"}}"#)),
|
||||||
|
enc("sig")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_body(access: &str, refresh: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"data":{{"access":{{"token":"{access}"}},"refresh":{{"token":"{refresh}"}}}}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TempDir(PathBuf);
|
||||||
|
impl TempDir {
|
||||||
|
fn new(name: &str) -> Self {
|
||||||
|
let dir = std::env::temp_dir()
|
||||||
|
.join(format!("idx-stockbit-auth-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
Self(dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW: i64 = 1_800_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reads_the_pair_from_a_refresh_response_and_the_cookie() {
|
||||||
|
let pair = parse_refresh(
|
||||||
|
&fixture("stockbit_exodus_login_refresh_constructed.json"),
|
||||||
|
&Session::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(token_expiry(&pair.access), Some(1_792_385_008));
|
||||||
|
assert!(looks_like_jwt(&pair.refresh));
|
||||||
|
|
||||||
|
let pasted = session_from_paste(&fixture(
|
||||||
|
"stockbit_exodus_credential_storage_cookie_constructed.txt",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert!(looks_like_jwt(&pasted.access) && looks_like_jwt(&pasted.refresh));
|
||||||
|
|
||||||
|
let bare = jwt(NOW, "r");
|
||||||
|
assert_eq!(
|
||||||
|
session_from_paste(&format!(" Bearer {bare}\n")),
|
||||||
|
Some(Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: bare
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(session_from_paste("not a token"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refresh_without_a_new_refresh_token_keeps_the_old_one() {
|
||||||
|
let previous = Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: jwt(NOW, "old"),
|
||||||
|
};
|
||||||
|
let access = jwt(NOW + 86_400, "a");
|
||||||
|
let next =
|
||||||
|
parse_refresh(&format!(r#"{{"data":{{"access":"{access}"}}}}"#), &previous).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
next,
|
||||||
|
Session {
|
||||||
|
access,
|
||||||
|
refresh: previous.refresh
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fresh_access_token_is_used_without_refreshing() {
|
||||||
|
let dir = TempDir::new("fresh");
|
||||||
|
let store = SessionStore::at(dir.0.clone(), Session::default());
|
||||||
|
let access = jwt(NOW + 2 * REFRESH_BUFFER_SECS, "a");
|
||||||
|
store
|
||||||
|
.save(&Session {
|
||||||
|
access: access.clone(),
|
||||||
|
refresh: jwt(NOW + 7 * 86_400, "r"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
|
||||||
|
assert_eq!(store.access(&refresher, NOW).unwrap(), access);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stale_access_token_rotates_both_tokens_and_persists_them() {
|
||||||
|
let dir = TempDir::new("rotate");
|
||||||
|
let store = SessionStore::at(dir.0.clone(), Session::default());
|
||||||
|
let old_refresh = jwt(NOW + 7 * 86_400, "r1");
|
||||||
|
store
|
||||||
|
.save(&Session {
|
||||||
|
access: jwt(NOW + 60, "a1"),
|
||||||
|
refresh: old_refresh.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let (new_access, new_refresh) = (jwt(NOW + 86_400, "a2"), jwt(NOW + 8 * 86_400, "r2"));
|
||||||
|
let calls = Cell::new(0);
|
||||||
|
let refresher = |presented: &str| {
|
||||||
|
calls.set(calls.get() + 1);
|
||||||
|
assert_eq!(presented, old_refresh);
|
||||||
|
Ok(refresh_body(&new_access, &new_refresh))
|
||||||
|
};
|
||||||
|
assert_eq!(store.access(&refresher, NOW).unwrap(), new_access);
|
||||||
|
assert_eq!(calls.get(), 1);
|
||||||
|
// The rotated pair is what the next process reads.
|
||||||
|
assert_eq!(
|
||||||
|
store.load(),
|
||||||
|
Session {
|
||||||
|
access: new_access,
|
||||||
|
refresh: new_refresh
|
||||||
|
}
|
||||||
|
);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = std::fs::metadata(store.path())
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rejected_token_already_replaced_by_another_process_is_not_refreshed_again() {
|
||||||
|
let dir = TempDir::new("rejected");
|
||||||
|
let store = SessionStore::at(dir.0.clone(), Session::default());
|
||||||
|
let replaced = jwt(NOW + 86_400, "theirs");
|
||||||
|
store
|
||||||
|
.save(&Session {
|
||||||
|
access: replaced.clone(),
|
||||||
|
refresh: jwt(NOW + 86_400, "r"),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let refresher = |_: &str| -> Result<String, IdxError> { panic!("must not refresh") };
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.after_rejection(&jwt(NOW, "mine"), &refresher)
|
||||||
|
.unwrap(),
|
||||||
|
replaced
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_session_is_an_auth_error_and_the_file_beats_the_seed() {
|
||||||
|
let dir = TempDir::new("seed");
|
||||||
|
let empty = SessionStore::at(dir.0.clone(), Session::default());
|
||||||
|
let refresher = |_: &str| -> Result<String, IdxError> { unreachable!() };
|
||||||
|
assert!(matches!(
|
||||||
|
empty.access(&refresher, NOW),
|
||||||
|
Err(IdxError::AuthError(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let seeded = SessionStore::at(
|
||||||
|
dir.0.clone(),
|
||||||
|
Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: jwt(NOW, "env"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(seeded.has_session());
|
||||||
|
let stored = jwt(NOW, "file");
|
||||||
|
seeded
|
||||||
|
.save(&Session {
|
||||||
|
access: String::new(),
|
||||||
|
refresh: stored.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seeded.load().refresh, stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_and_debug_never_show_a_token() {
|
||||||
|
let dir = TempDir::new("status");
|
||||||
|
let token = jwt(NOW + 100, "secret");
|
||||||
|
let store = SessionStore::at(
|
||||||
|
dir.0.clone(),
|
||||||
|
Session {
|
||||||
|
access: token.clone(),
|
||||||
|
refresh: String::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let status = store.status(NOW);
|
||||||
|
assert!(status.logged_in);
|
||||||
|
assert_eq!(status.access.expires_in_secs, Some(100));
|
||||||
|
assert!(!status.refresh.present);
|
||||||
|
let shown = format!(
|
||||||
|
"{} {:?}",
|
||||||
|
serde_json::to_string(&status).unwrap(),
|
||||||
|
store.load()
|
||||||
|
);
|
||||||
|
assert!(!shown.contains(&token), "{shown}");
|
||||||
|
}
|
||||||
|
}
|
||||||
1478
crates/idx-core/src/api/stockbit/browser.rs
Normal file
1478
crates/idx-core/src/api/stockbit/browser.rs
Normal file
File diff suppressed because it is too large
Load diff
437
crates/idx-core/src/api/stockbit/corpaction.rs
Normal file
437
crates/idx-core/src/api/stockbit/corpaction.rs
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
//! Corporate actions from Stockbit's session API:
|
||||||
|
//! `GET /corpaction/{type}?symbol={CODE}` (one stock's history) or without
|
||||||
|
//! `symbol` (the market, about a year back and the announced ones ahead; the
|
||||||
|
//! `limit` the web app sends is not honoured by every type).
|
||||||
|
//!
|
||||||
|
//! Each type names its fields with its own prefix (`dividend_`,
|
||||||
|
//! `rightissue_`, `wrant_`, `rups_`, `puexp_`, `tender_`, ...), bonus rows
|
||||||
|
//! reuse `stocksplit_*`, and the list sits under a key that is not always the
|
||||||
|
//! path's (`tenderoffer` → `tender`, `reversesplit` → `stock_reverse`). So
|
||||||
|
//! the entitlement kinds are read by field suffix (`_cumdate`, `_exdate`,
|
||||||
|
//! ...), which also covers the two kinds no row was recorded for yet
|
||||||
|
//! (`stock_dividend`, `reversesplit`, both empty on 2026-11-03). Placeholders
|
||||||
|
//! (`""`, `0`, `0001-01-01 00:00:00`) read as absent; a row without a symbol
|
||||||
|
//! or a date is dropped.
|
||||||
|
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
use crate::corporate::types::{ActionKind, CorporateAction, DateRange, Ratio};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// Stockbit's path segment for `kind`.
|
||||||
|
pub fn path_type(kind: ActionKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
ActionKind::Dividend => "dividend",
|
||||||
|
ActionKind::StockDividend => "stock_dividend",
|
||||||
|
ActionKind::Bonus => "bonus",
|
||||||
|
ActionKind::Rights => "rightissue",
|
||||||
|
ActionKind::Warrant => "warrant",
|
||||||
|
ActionKind::Split => "stocksplit",
|
||||||
|
ActionKind::ReverseSplit => "reversesplit",
|
||||||
|
ActionKind::Meeting => "rups",
|
||||||
|
ActionKind::PublicExpose => "pubex",
|
||||||
|
ActionKind::Ipo => "ipo",
|
||||||
|
ActionKind::TenderOffer => "tenderoffer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The actions of one `kind` in a `/corpaction/{type}` response, in the
|
||||||
|
/// response's order.
|
||||||
|
pub fn parse_corporate_actions(
|
||||||
|
kind: ActionKind,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||||
|
let bad = |what: String| IdxError::ParseError(format!("stockbit corporate actions: {what}"));
|
||||||
|
let root: Value = serde_json::from_str(body).map_err(|e| bad(e.to_string()))?;
|
||||||
|
let rows = match root.get("data") {
|
||||||
|
Some(Value::Object(data)) => match data.values().find_map(Value::as_array) {
|
||||||
|
Some(rows) => rows.as_slice(),
|
||||||
|
// No rows reads `{"dividend": null, "today": "..."}`: a null list
|
||||||
|
// beside scalars. An object in their place is a new layout.
|
||||||
|
None if !data.values().any(Value::is_object) => &[],
|
||||||
|
None => return Err(bad("no list in data".to_string())),
|
||||||
|
},
|
||||||
|
Some(Value::Array(rows)) => rows.as_slice(),
|
||||||
|
Some(Value::Null) => &[],
|
||||||
|
_ => return Err(bad("no data".to_string())),
|
||||||
|
};
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_object)
|
||||||
|
.filter_map(|row| parse_row(kind, row))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_row(kind: ActionKind, row: &Map<String, Value>) -> Option<CorporateAction> {
|
||||||
|
let code = text(row.get("company_symbol"))?.to_ascii_uppercase();
|
||||||
|
if !code.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let symbol = format!("{code}.JK");
|
||||||
|
let mut action = if kind.is_entitlement() {
|
||||||
|
entitlement(kind, symbol, row)?
|
||||||
|
} else {
|
||||||
|
event(kind, symbol, row)?
|
||||||
|
};
|
||||||
|
if matches!(kind, ActionKind::Ipo | ActionKind::TenderOffer) {
|
||||||
|
action.name = text(row.get("company_name")).map(str::to_string);
|
||||||
|
}
|
||||||
|
Some(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dividends, bonuses, rights and splits: cum/ex/record/pay dates, a ratio
|
||||||
|
/// and an adjustment factor.
|
||||||
|
fn entitlement(
|
||||||
|
kind: ActionKind,
|
||||||
|
symbol: String,
|
||||||
|
row: &Map<String, Value>,
|
||||||
|
) -> Option<CorporateAction> {
|
||||||
|
let cum = date(suffixed(row, "cumdate"));
|
||||||
|
let ex = date(suffixed(row, "exdate"));
|
||||||
|
let mut action = CorporateAction::new(symbol, kind, cum.or(ex)?);
|
||||||
|
action.cum_date = cum;
|
||||||
|
action.ex_date = ex;
|
||||||
|
action.record_date = date(suffixed(row, "recdate"));
|
||||||
|
action.pay_date = date(suffixed(row, "paydate")).or_else(|| date(suffixed(row, "paymentdate")));
|
||||||
|
action.ratio = ratio(row);
|
||||||
|
action.factor = number(suffixed(row, "factor"));
|
||||||
|
match kind {
|
||||||
|
ActionKind::Dividend => {
|
||||||
|
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"));
|
||||||
|
action.period = range(suffixed(row, "trading_start"), suffixed(row, "trading_end"));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Some(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Warrants, meetings, exposes, IPOs and tender offers.
|
||||||
|
fn event(kind: ActionKind, symbol: String, row: &Map<String, Value>) -> Option<CorporateAction> {
|
||||||
|
let get = |key: &str| row.get(key);
|
||||||
|
let mut action;
|
||||||
|
match kind {
|
||||||
|
ActionKind::Warrant => {
|
||||||
|
let trading = range(get("wrant_trading_from"), get("wrant_trading_end"));
|
||||||
|
let exercise = range(get("wrant_exc_from"), get("wrant_exc_end"));
|
||||||
|
let first = date(get("wrant_trading_from")).or(exercise.map(|r| r.start))?;
|
||||||
|
action = CorporateAction::new(symbol, kind, first);
|
||||||
|
action.period = trading;
|
||||||
|
action.exercise = exercise;
|
||||||
|
action.price = whole(get("wrant_exc_price"));
|
||||||
|
}
|
||||||
|
ActionKind::Meeting | ActionKind::PublicExpose => {
|
||||||
|
let prefix = if kind == ActionKind::Meeting {
|
||||||
|
"rups"
|
||||||
|
} else {
|
||||||
|
"puexp"
|
||||||
|
};
|
||||||
|
let field = |name: &str| row.get(&format!("{prefix}_{name}"));
|
||||||
|
action = CorporateAction::new(symbol, kind, date(field("date"))?);
|
||||||
|
action.time = text(field("time")).map(clock);
|
||||||
|
action.venue = text(field("venue")).map(squeeze);
|
||||||
|
if kind == ActionKind::Meeting {
|
||||||
|
action.record_date = date(field("eligible_date"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ActionKind::Ipo => {
|
||||||
|
action = CorporateAction::new(symbol, kind, date(get("ipo_listing_date"))?);
|
||||||
|
action.price = whole(row.get("ipo_price").and_then(|p| p.get("final")));
|
||||||
|
let detail = row.get("ipo_data_detail");
|
||||||
|
let detail = |key: &str| detail.and_then(|d| d.get(key));
|
||||||
|
action.shares = count(detail("shares"));
|
||||||
|
action.period = range(detail("offering_start"), detail("offering_end"));
|
||||||
|
}
|
||||||
|
ActionKind::TenderOffer => {
|
||||||
|
let period = range(get("tender_start"), get("tender_end"));
|
||||||
|
let first = date(get("tender_start")).or(date(get("tender_end")))?;
|
||||||
|
action = CorporateAction::new(symbol, kind, first);
|
||||||
|
action.period = period;
|
||||||
|
action.pay_date = date(get("tender_paydate"));
|
||||||
|
action.price = whole(get("tender_price"));
|
||||||
|
action.shares = count(get("tender_shares"));
|
||||||
|
action.percentage_bps =
|
||||||
|
number(get("tender_percentage")).map(|p| (p * 100.0).round() as i64);
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
}
|
||||||
|
Some(action)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of the one field named `<prefix>_<suffix>`; the shortest name
|
||||||
|
/// wins should several end so.
|
||||||
|
fn suffixed<'a>(row: &'a Map<String, Value>, suffix: &str) -> Option<&'a Value> {
|
||||||
|
let tail = format!("_{suffix}");
|
||||||
|
row.iter()
|
||||||
|
.filter(|(key, _)| key.ends_with(&tail))
|
||||||
|
.min_by_key(|(key, _)| key.len())
|
||||||
|
.map(|(_, value)| value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `old`/`new` fields, else the `"6 : 4"` text.
|
||||||
|
fn ratio(row: &Map<String, Value>) -> Option<Ratio> {
|
||||||
|
let pair = |old: Option<u64>, new: Option<u64>| {
|
||||||
|
Some(Ratio {
|
||||||
|
old: old?,
|
||||||
|
new: new?,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
pair(count(suffixed(row, "old")), count(suffixed(row, "new"))).or_else(|| {
|
||||||
|
let text = text(suffixed(row, "ratio"))?;
|
||||||
|
let (old, new) = text.split_once(':')?;
|
||||||
|
pair(
|
||||||
|
old.trim().replace(',', "").parse().ok().filter(|n| *n > 0),
|
||||||
|
new.trim().replace(',', "").parse().ok().filter(|n| *n > 0),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(value: Option<&Value>) -> Option<&str> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `YYYY-MM-DD`, optionally followed by a time; year 1 is a placeholder.
|
||||||
|
fn date(value: Option<&Value>) -> Option<NaiveDate> {
|
||||||
|
let text = text(value)?;
|
||||||
|
let day = text.get(..10)?;
|
||||||
|
NaiveDate::parse_from_str(day, "%Y-%m-%d")
|
||||||
|
.ok()
|
||||||
|
.filter(|d| d.year() >= 1900)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn range(start: Option<&Value>, end: Option<&Value>) -> Option<DateRange> {
|
||||||
|
let (start, end) = (date(start)?, date(end)?);
|
||||||
|
(start <= end).then_some(DateRange { start, end })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A positive number from a JSON number or a display string (`"1,191.09"`,
|
||||||
|
/// `"Rp 25"`); zero is a placeholder.
|
||||||
|
fn number(value: Option<&Value>) -> Option<f64> {
|
||||||
|
let n = match value? {
|
||||||
|
Value::Number(n) => n.as_f64()?,
|
||||||
|
Value::String(s) => {
|
||||||
|
let cleaned: String = s
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches("Rp")
|
||||||
|
.chars()
|
||||||
|
.filter(|c| *c != ',' && !c.is_whitespace())
|
||||||
|
.collect();
|
||||||
|
cleaned.parse().ok()?
|
||||||
|
}
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
(n.is_finite() && n > 0.0).then_some(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whole(value: Option<&Value>) -> Option<i64> {
|
||||||
|
number(value).map(|n| n.round() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(value: Option<&Value>) -> Option<u64> {
|
||||||
|
number(value).map(|n| n.round() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn round_to(value: f64, places: i32) -> f64 {
|
||||||
|
let scale = 10f64.powi(places);
|
||||||
|
(value * scale).round() / scale
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `13:00:00` → `13:00`; anything else as given.
|
||||||
|
fn clock(raw: &str) -> String {
|
||||||
|
match raw.split(':').collect::<Vec<_>>().as_slice() {
|
||||||
|
[h, m, s] if s.len() == 2 && h.len() <= 2 && m.len() == 2 => format!("{h}:{m}"),
|
||||||
|
_ => raw.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn squeeze(raw: &str) -> String {
|
||||||
|
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
std::fs::read_to_string(format!(
|
||||||
|
"{}/tests/fixtures/stockbit_exodus_corpaction_{name}.json",
|
||||||
|
env!("CARGO_MANIFEST_DIR")
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn day(s: &str) -> NaiveDate {
|
||||||
|
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find<'a>(actions: &'a [CorporateAction], symbol: &str) -> &'a CorporateAction {
|
||||||
|
actions.iter().find(|a| a.symbol == symbol).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_dividends_keep_cents_and_dates() {
|
||||||
|
let bbca =
|
||||||
|
parse_corporate_actions(ActionKind::Dividend, &fixture("dividend_bbca")).unwrap();
|
||||||
|
assert_eq!(bbca.len(), 37);
|
||||||
|
let latest = &bbca[0];
|
||||||
|
assert_eq!(latest.symbol, "BBCA.JK");
|
||||||
|
assert_eq!(latest.date, day("2026-08-28"));
|
||||||
|
assert_eq!(
|
||||||
|
(latest.ex_date, latest.record_date, latest.pay_date),
|
||||||
|
(
|
||||||
|
Some(day("2026-08-31")),
|
||||||
|
Some(day("2026-09-01")),
|
||||||
|
Some(day("2026-09-16"))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(latest.cash_per_share, Some(25.0));
|
||||||
|
assert_eq!(latest.ratio, None);
|
||||||
|
|
||||||
|
let market = parse_corporate_actions(ActionKind::Dividend, &fixture("dividend")).unwrap();
|
||||||
|
assert_eq!(find(&market, "ITMG.JK").cash_per_share, Some(1191.09));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_bonus_reads_split_named_fields() {
|
||||||
|
let bonus = parse_corporate_actions(ActionKind::Bonus, &fixture("bonus")).unwrap();
|
||||||
|
let ppgl = find(&bonus, "PPGL.JK");
|
||||||
|
assert_eq!(ppgl.date, day("2026-11-02"));
|
||||||
|
assert_eq!(ppgl.ex_date, Some(day("2026-11-03")));
|
||||||
|
assert_eq!(ppgl.pay_date, Some(day("2026-11-25")));
|
||||||
|
assert_eq!(ppgl.ratio, Some(Ratio { old: 6, new: 4 }));
|
||||||
|
assert_eq!(ppgl.factor, Some(1.6667));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_rights_and_split() {
|
||||||
|
let rights = parse_corporate_actions(ActionKind::Rights, &fixture("rightissue")).unwrap();
|
||||||
|
let bmas = find(&rights, "BMAS.JK");
|
||||||
|
assert_eq!(bmas.date, day("2026-11-23"));
|
||||||
|
assert_eq!(bmas.ratio, Some(Ratio { old: 655, new: 104 }));
|
||||||
|
assert_eq!(bmas.price, Some(350));
|
||||||
|
assert_eq!(
|
||||||
|
bmas.period,
|
||||||
|
Some(DateRange {
|
||||||
|
start: day("2026-11-27"),
|
||||||
|
end: day("2026-12-03")
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let split =
|
||||||
|
parse_corporate_actions(ActionKind::Split, &fixture("stocksplit_bbca")).unwrap();
|
||||||
|
assert_eq!(split[0].date, day("2021-10-12"));
|
||||||
|
assert_eq!(split[0].ratio, Some(Ratio { old: 1, new: 5 }));
|
||||||
|
assert_eq!(split[0].factor, Some(5.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_events() {
|
||||||
|
let warrants = parse_corporate_actions(ActionKind::Warrant, &fixture("warrant")).unwrap();
|
||||||
|
let brna = find(&warrants, "BRNA.JK");
|
||||||
|
assert_eq!(brna.date, day("2026-10-16"));
|
||||||
|
assert_eq!(brna.price, Some(800));
|
||||||
|
assert_eq!(brna.exercise.map(|r| r.end), Some(day("2029-10-15")));
|
||||||
|
|
||||||
|
let meetings = parse_corporate_actions(ActionKind::Meeting, &fixture("rups")).unwrap();
|
||||||
|
let arti = find(&meetings, "ARTI.JK");
|
||||||
|
assert_eq!(arti.date, day("2026-12-09"));
|
||||||
|
assert_eq!(arti.time.as_deref(), Some("10:30"));
|
||||||
|
assert_eq!(arti.record_date, Some(day("2026-11-12")));
|
||||||
|
|
||||||
|
let exposes = parse_corporate_actions(ActionKind::PublicExpose, &fixture("pubex")).unwrap();
|
||||||
|
assert_eq!(find(&exposes, "MEJA.JK").time.as_deref(), Some("13:00"));
|
||||||
|
|
||||||
|
let tenders =
|
||||||
|
parse_corporate_actions(ActionKind::TenderOffer, &fixture("tenderoffer")).unwrap();
|
||||||
|
let auto = find(&tenders, "AUTO.JK");
|
||||||
|
assert_eq!(auto.date, day("2026-10-13"));
|
||||||
|
assert_eq!(auto.price, Some(3600));
|
||||||
|
assert_eq!(auto.shares, Some(238_442_293));
|
||||||
|
assert_eq!(auto.percentage_bps, Some(495));
|
||||||
|
assert_eq!(auto.pay_date, Some(day("2026-11-18")));
|
||||||
|
assert_eq!(auto.name.as_deref(), Some("Astra Otoparts Tbk."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_ipo_drops_placeholders() {
|
||||||
|
let ipos = parse_corporate_actions(ActionKind::Ipo, &fixture("ipo")).unwrap();
|
||||||
|
let rans = find(&ipos, "RANS.JK");
|
||||||
|
assert_eq!(rans.price, Some(170));
|
||||||
|
assert_eq!(rans.shares, Some(2_525_000_000));
|
||||||
|
assert_eq!(rans.period.map(|r| r.start), Some(day("2026-07-02")));
|
||||||
|
// An ETF listed without offer terms: no price, no period.
|
||||||
|
let xkgs = find(&ipos, "XKGS.JK");
|
||||||
|
assert_eq!((xkgs.price, xkgs.period, xkgs.shares), (None, None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_empty_lists_and_odd_keys() {
|
||||||
|
for (kind, body) in [
|
||||||
|
(
|
||||||
|
ActionKind::StockDividend,
|
||||||
|
r#"{"data":{"stock_dividend":[]}}"#,
|
||||||
|
),
|
||||||
|
(ActionKind::ReverseSplit, r#"{"data":{"stock_reverse":[]}}"#),
|
||||||
|
(ActionKind::Dividend, r#"{"data":null}"#),
|
||||||
|
(
|
||||||
|
ActionKind::Dividend,
|
||||||
|
r#"{"data":{"dividend":null,"today":"2026-11-04"}}"#,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(parse_corporate_actions(kind, body).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
assert!(parse_corporate_actions(ActionKind::Dividend, r#"{"message":"x"}"#).is_err());
|
||||||
|
let moved = r#"{"data":{"dividend":{"rows":[]},"today":"2026-11-04"}}"#;
|
||||||
|
assert!(parse_corporate_actions(ActionKind::Dividend, moved).is_err());
|
||||||
|
// A reverse split named like a split, read by suffix.
|
||||||
|
let body = r#"{"data":{"stock_reverse":[{"company_symbol":"ABCD",
|
||||||
|
"reverse_cumdate":"2026-12-01","reverse_exdate":"2026-12-02",
|
||||||
|
"reverse_old":"10","reverse_new":"1","reverse_factor":"0.1"}]}}"#;
|
||||||
|
let rows = parse_corporate_actions(ActionKind::ReverseSplit, body).unwrap();
|
||||||
|
assert_eq!(rows[0].ratio, Some(Ratio { old: 10, new: 1 }));
|
||||||
|
assert_eq!(rows[0].factor, Some(0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dividend not in IDR names its currency; one in IDR leaves it out.
|
||||||
|
/// Without a cum date, the action sits on its ex date.
|
||||||
|
#[test]
|
||||||
|
fn test_parse_dividend_currency_and_missing_cum_date() {
|
||||||
|
let body = r#"{"data":{"dividend":[
|
||||||
|
{"company_symbol":"ABCD","dividend_cumdate":"2026-11-11",
|
||||||
|
"dividend_exdate":"2026-11-12","dividend_value":"0.0012",
|
||||||
|
"dividend_currency":"CURRENCY_USD"},
|
||||||
|
{"company_symbol":"EFGH","dividend_cumdate":"0001-01-01 00:00:00",
|
||||||
|
"dividend_exdate":"2026-11-12","dividend_value":"5",
|
||||||
|
"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));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_numbers_and_placeholders() {
|
||||||
|
assert_eq!(number(Some(&Value::from("1,191.09"))), Some(1191.09));
|
||||||
|
assert_eq!(number(Some(&Value::from("Rp 25"))), Some(25.0));
|
||||||
|
assert_eq!(number(Some(&Value::from(0))), None);
|
||||||
|
assert_eq!(date(Some(&Value::from("0001-01-01 00:00:00"))), None);
|
||||||
|
assert_eq!(
|
||||||
|
date(Some(&Value::from("2026-07-02 00:00:00"))),
|
||||||
|
Some(day("2026-07-02"))
|
||||||
|
);
|
||||||
|
assert_eq!(clock("09:00:00"), "09:00");
|
||||||
|
assert_eq!(clock("10:30"), "10:30");
|
||||||
|
}
|
||||||
|
}
|
||||||
708
crates/idx-core/src/api/stockbit/exodus.rs
Normal file
708
crates/idx-core/src/api/stockbit/exodus.rs
Normal file
|
|
@ -0,0 +1,708 @@
|
||||||
|
//! Stockbit's own market-data API, `https://exodus.stockbit.com`, with the
|
||||||
|
//! user's session (see `auth`). Opt-in: used only when a session is stored.
|
||||||
|
//!
|
||||||
|
//! - `GET /company-price-feed/v2/orderbook/companies/{CODE}`: last price,
|
||||||
|
//! OHLC, volume (shares), value, frequency, foreign buy/sell/net (IDR, used
|
||||||
|
//! only when `has_foreign_bs` is true) and the depth ladder
|
||||||
|
//! `bid[]`/`offer[]` of `{price, que_num, volume}` (shares).
|
||||||
|
//! - `GET /company-price-feed/market-time/session`: the market's state,
|
||||||
|
//! `data.detail.regular.state_name`: `STATE_NAME_SESSION_2` while trading,
|
||||||
|
//! `STATE_NAME_BREAK`, `STATE_NAME_MARKET_CLOSED` (recorded). The `fca`
|
||||||
|
//! (full call auction) board beside it keeps its own state and is not read.
|
||||||
|
//! - `GET /charts/{CODE}/daily?timeframe=1m|3m|1y&chart_type=PRICE_CHART_TYPE_CANDLE`:
|
||||||
|
//! daily candles, `formatted_date` `YYYY-MM-DD`, string OHLC, `value` the
|
||||||
|
//! close and `volume` in lots.
|
||||||
|
//! - `POST /login/refresh` with the refresh token as bearer rotates the pair.
|
||||||
|
//!
|
||||||
|
//! A 401 (or a JSON 403) spends one refresh and retries once. Requests share
|
||||||
|
//! the public page's pacing. Endpoints and units were mapped by fzn0x/idx
|
||||||
|
//! (MIT); `tests/fixtures/stockbit_exodus_*` are its recordings.
|
||||||
|
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use chrono::{Months, NaiveDate};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::auth::SessionStore;
|
||||||
|
use super::parse::number;
|
||||||
|
use super::{USER_AGENT, pace, stockbit_code};
|
||||||
|
use super::{corpaction, movement};
|
||||||
|
use crate::api::types::{BookLevel, Interval, Ohlc, OrderBook, Period, Quote};
|
||||||
|
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
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`,
|
||||||
|
/// `STATE_NAME_MARKET_CLOSED`, auctions) hides bid/offer in quotes, as on the
|
||||||
|
/// public page.
|
||||||
|
const TRADING_PREFIX: &str = "STATE_NAME_SESSION_";
|
||||||
|
|
||||||
|
/// Whether Stockbit's market state is a trading session.
|
||||||
|
pub fn is_trading(state: &str) -> bool {
|
||||||
|
state
|
||||||
|
.strip_prefix(TRADING_PREFIX)
|
||||||
|
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||||
|
}
|
||||||
|
/// The market's state changes a few times a day; one read serves this long.
|
||||||
|
const MARKET_STATE_TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
pub(crate) struct Exodus {
|
||||||
|
agent: ureq::Agent,
|
||||||
|
store: SessionStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Reply {
|
||||||
|
status: u16,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reply {
|
||||||
|
/// The server refused the access token: 401, or a 403 with a JSON body
|
||||||
|
/// (a CDN block is an HTML page).
|
||||||
|
fn rejected(&self) -> bool {
|
||||||
|
self.status == 401
|
||||||
|
|| (self.status == 403 && serde_json::from_str::<Value>(&self.body).is_ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Exodus {
|
||||||
|
pub(crate) fn new(store: SessionStore) -> Self {
|
||||||
|
let agent = ureq::Agent::config_builder()
|
||||||
|
.timeout_connect(Some(Duration::from_secs(5)))
|
||||||
|
.timeout_recv_body(Some(Duration::from_secs(10)))
|
||||||
|
.timeout_global(Some(Duration::from_secs(20)))
|
||||||
|
.http_status_as_error(false)
|
||||||
|
.build()
|
||||||
|
.into();
|
||||||
|
Self { agent, store }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn store(&self) -> &SessionStore {
|
||||||
|
&self.store
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One authorised request (the market's state), refreshing the session
|
||||||
|
/// when needed: proves Stockbit accepts it.
|
||||||
|
pub(crate) fn verify(&self) -> Result<(), IdxError> {
|
||||||
|
self.get("/company-price-feed/market-time/session", "market session")
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn orderbook(&self, resolved: &str) -> Result<OrderBook, IdxError> {
|
||||||
|
let code = stockbit_code(resolved)?;
|
||||||
|
let body = self.get(
|
||||||
|
&format!("/company-price-feed/v2/orderbook/companies/{code}"),
|
||||||
|
resolved,
|
||||||
|
)?;
|
||||||
|
parse_orderbook(resolved, &body, self.market_status())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn quote(&self, resolved: &str) -> Result<Quote, IdxError> {
|
||||||
|
self.orderbook(resolved).map(|book| quote_from_book(&book))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Daily candles covering `period`, oldest first.
|
||||||
|
pub(crate) fn daily(
|
||||||
|
&self,
|
||||||
|
resolved: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
let window =
|
||||||
|
history_window(period, interval).ok_or_else(|| unsupported_window(period, interval))?;
|
||||||
|
let code = stockbit_code(resolved)?;
|
||||||
|
let body = self.get(
|
||||||
|
&format!("/charts/{code}/daily?timeframe={window}&chart_type=PRICE_CHART_TYPE_CANDLE"),
|
||||||
|
resolved,
|
||||||
|
)?;
|
||||||
|
daily_for_period(&body, period)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Corporate actions of `kind`: one stock's (`resolved`) or the market's.
|
||||||
|
pub(crate) fn corporate_actions(
|
||||||
|
&self,
|
||||||
|
kind: ActionKind,
|
||||||
|
resolved: Option<&str>,
|
||||||
|
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||||
|
let path = format!("/corpaction/{}", corpaction::path_type(kind));
|
||||||
|
let (path, subject) = match resolved {
|
||||||
|
Some(resolved) => (
|
||||||
|
format!("{path}?symbol={}", stockbit_code(resolved)?),
|
||||||
|
resolved.to_string(),
|
||||||
|
),
|
||||||
|
None => (path, "corporate actions".to_string()),
|
||||||
|
};
|
||||||
|
let body = self.get(&path, &subject)?;
|
||||||
|
corpaction::parse_corporate_actions(kind, &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page `page` of shareholder movements: one stock's (`query.symbol`,
|
||||||
|
/// resolved) or the market's.
|
||||||
|
pub(crate) fn shareholder_movements(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<MovementPage, IdxError> {
|
||||||
|
let code = query.symbol.as_deref().map(stockbit_code).transpose()?;
|
||||||
|
let subject = query
|
||||||
|
.symbol
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "shareholder movements".to_string());
|
||||||
|
let body = self.get(
|
||||||
|
&movement::request_path(query, code.as_deref(), page),
|
||||||
|
&subject,
|
||||||
|
)?;
|
||||||
|
movement::parse_movements(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The market's state, read at most every `MARKET_STATE_TTL` per process.
|
||||||
|
/// Unknown on any failure, which hides bid/offer.
|
||||||
|
fn market_status(&self) -> Option<String> {
|
||||||
|
static CACHE: Mutex<Option<(Instant, Option<String>)>> = Mutex::new(None);
|
||||||
|
let mut cached = CACHE.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
if let Some((at, state)) = cached.as_ref()
|
||||||
|
&& at.elapsed() < MARKET_STATE_TTL
|
||||||
|
{
|
||||||
|
return state.clone();
|
||||||
|
}
|
||||||
|
let state = self
|
||||||
|
.get("/company-price-feed/market-time/session", "market session")
|
||||||
|
.ok()
|
||||||
|
.and_then(|body| parse_market_status(&body));
|
||||||
|
*cached = Some((Instant::now(), state.clone()));
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
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)?;
|
||||||
|
if reply.rejected() {
|
||||||
|
let token = self.store.after_rejection(&token, &refresher)?;
|
||||||
|
reply = self.send(path, &token)?;
|
||||||
|
if reply.rejected() {
|
||||||
|
return Err(IdxError::AuthError(
|
||||||
|
"Stockbit rejected the session; run `idx stockbit login` again".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
classify(reply, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send(&self, path: &str, token: &str) -> Result<Reply, IdxError> {
|
||||||
|
pace();
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.get(format!("{BASE_URL}{path}"))
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Origin", "https://stockbit.com")
|
||||||
|
.header("Referer", "https://stockbit.com/")
|
||||||
|
.header("Authorization", format!("Bearer {token}"))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
|
||||||
|
read_reply(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /login/refresh`; the response body carries the new pair.
|
||||||
|
fn refresh(&self, refresh: &str) -> Result<String, IdxError> {
|
||||||
|
pace();
|
||||||
|
let response = self
|
||||||
|
.agent
|
||||||
|
.post(format!("{BASE_URL}{REFRESH_PATH}"))
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Origin", "https://stockbit.com")
|
||||||
|
.header("Referer", "https://stockbit.com/")
|
||||||
|
.header("Authorization", format!("Bearer {refresh}"))
|
||||||
|
.send("{}")
|
||||||
|
.map_err(|e| IdxError::Http(format!("stockbit refresh: {e}")))?;
|
||||||
|
let reply = read_reply(response)?;
|
||||||
|
match reply.status {
|
||||||
|
200..=299 => Ok(reply.body),
|
||||||
|
401 | 403 => Err(IdxError::AuthError(
|
||||||
|
"Stockbit rejected the refresh token (expired or revoked); run `idx stockbit \
|
||||||
|
login` again"
|
||||||
|
.to_string(),
|
||||||
|
)),
|
||||||
|
status => Err(IdxError::Http(format!("stockbit refresh: HTTP {status}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_reply(response: ureq::http::Response<ureq::Body>) -> Result<Reply, IdxError> {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response
|
||||||
|
.into_body()
|
||||||
|
.read_to_string()
|
||||||
|
.map_err(|e| IdxError::Http(format!("stockbit api: {e}")))?;
|
||||||
|
Ok(Reply { status, body })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
chrono::Utc::now().timestamp()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-rejected reply as data or the error its status means.
|
||||||
|
fn classify(reply: Reply, subject: &str) -> Result<String, IdxError> {
|
||||||
|
match reply.status {
|
||||||
|
200..=299 => Ok(reply.body),
|
||||||
|
404 if error_type(&reply.body).as_deref() == Some("NOT_FOUND") => {
|
||||||
|
Err(IdxError::SymbolNotFound(subject.to_string()))
|
||||||
|
}
|
||||||
|
429 => Err(IdxError::RateLimited),
|
||||||
|
403 => Err(blocked()),
|
||||||
|
status => Err(IdxError::Http(format!(
|
||||||
|
"stockbit api: HTTP {status}{}",
|
||||||
|
message(&reply.body)
|
||||||
|
.map(|m| format!(": {m}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_type(body: &str) -> Option<String> {
|
||||||
|
serde_json::from_str::<Value>(body)
|
||||||
|
.ok()?
|
||||||
|
.get("error_type")?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message(body: &str) -> Option<String> {
|
||||||
|
serde_json::from_str::<Value>(body)
|
||||||
|
.ok()?
|
||||||
|
.get("message")?
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data(body: &str, what: &str) -> Result<Value, IdxError> {
|
||||||
|
let mut value: Value = serde_json::from_str(body)
|
||||||
|
.map_err(|e| IdxError::ParseError(format!("stockbit {what}: {e}")))?;
|
||||||
|
match value.get_mut("data") {
|
||||||
|
Some(data) => Ok(data.take()),
|
||||||
|
None => Err(IdxError::ParseError(format!("stockbit {what}: no data"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn whole(value: Option<&Value>) -> Option<i64> {
|
||||||
|
value.and_then(number).map(|n| n.round() as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count(value: Option<&Value>) -> Option<u64> {
|
||||||
|
value
|
||||||
|
.and_then(number)
|
||||||
|
.filter(|n| *n >= 0.0)
|
||||||
|
.map(|n| n.round() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The order book and session figures; `market_status` comes from the
|
||||||
|
/// market-time endpoint.
|
||||||
|
pub fn parse_orderbook(
|
||||||
|
resolved: &str,
|
||||||
|
body: &str,
|
||||||
|
market_status: Option<String>,
|
||||||
|
) -> Result<OrderBook, IdxError> {
|
||||||
|
let d = data(body, "order book")?;
|
||||||
|
let price = whole(d.get("lastprice").or_else(|| d.get("close")))
|
||||||
|
.ok_or_else(|| IdxError::ParseError("stockbit order book: no last price".to_string()))?;
|
||||||
|
let levels = |key: &str| -> Vec<BookLevel> {
|
||||||
|
d.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|rows| {
|
||||||
|
rows.iter()
|
||||||
|
.filter_map(|row| {
|
||||||
|
Some(BookLevel {
|
||||||
|
price: whole(row.get("price")).filter(|p| *p > 0)?,
|
||||||
|
shares: count(row.get("volume")).unwrap_or(0),
|
||||||
|
orders: count(row.get("que_num")).unwrap_or(0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let listing = d.get("status").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
let volume = count(d.get("volume"));
|
||||||
|
let session_note = if !listing.is_empty() && !listing.eq_ignore_ascii_case("active") {
|
||||||
|
Some(format!(
|
||||||
|
"Stockbit lists the stock as {}",
|
||||||
|
listing.to_ascii_lowercase()
|
||||||
|
))
|
||||||
|
} else if volume == Some(0) {
|
||||||
|
Some("no trades this session".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
// Stockbit flags whether `fbuy`/`fsell`/`fnet` are current. While the
|
||||||
|
// flag was off (2026-11-03, in session) they were the previous session's
|
||||||
|
// figures (equal to IPOT's for 2026-11-02 for BBCA, BBRI and TLKM), so
|
||||||
|
// they are dropped rather than shown beside today's prices.
|
||||||
|
let foreign_current = d.get("has_foreign_bs").and_then(Value::as_bool) == Some(true);
|
||||||
|
let foreign = |key: &str| foreign_current.then(|| whole(d.get(key))).flatten();
|
||||||
|
Ok(OrderBook {
|
||||||
|
symbol: resolved.to_string(),
|
||||||
|
name: d.get("name").and_then(Value::as_str).map(str::to_string),
|
||||||
|
price,
|
||||||
|
change: whole(d.get("change")),
|
||||||
|
change_pct: d.get("percentage_change").and_then(number),
|
||||||
|
prev_close: whole(d.get("previous")),
|
||||||
|
open: whole(d.get("open")),
|
||||||
|
high: whole(d.get("high")),
|
||||||
|
low: whole(d.get("low")),
|
||||||
|
volume,
|
||||||
|
value: count(d.get("value")),
|
||||||
|
frequency: count(d.get("frequency")),
|
||||||
|
foreign_buy: foreign("fbuy"),
|
||||||
|
foreign_sell: foreign("fsell"),
|
||||||
|
foreign_net: foreign("fnet"),
|
||||||
|
bids: levels("bid"),
|
||||||
|
offers: levels("offer"),
|
||||||
|
market_status,
|
||||||
|
session_note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A quote from the order book; bid/offer only while the market is open.
|
||||||
|
/// Its `market_status` is the public page's vocabulary, `open` or `close`,
|
||||||
|
/// so a quote reads the same with or without a login; the order book keeps
|
||||||
|
/// Stockbit's state verbatim.
|
||||||
|
pub fn quote_from_book(book: &OrderBook) -> Quote {
|
||||||
|
let open = book.market_status.as_deref().is_some_and(is_trading);
|
||||||
|
let best = |levels: &[BookLevel]| open.then(|| levels.first().map(|l| l.price)).flatten();
|
||||||
|
Quote {
|
||||||
|
symbol: book.symbol.clone(),
|
||||||
|
price: book.price,
|
||||||
|
change: book.change,
|
||||||
|
change_pct: book.change_pct,
|
||||||
|
volume: book.volume,
|
||||||
|
market_cap: None,
|
||||||
|
week52_high: None,
|
||||||
|
week52_low: None,
|
||||||
|
week52_position: None,
|
||||||
|
range_signal: None,
|
||||||
|
prev_close: book.prev_close,
|
||||||
|
avg_volume: None,
|
||||||
|
as_of: None,
|
||||||
|
behind_session: false,
|
||||||
|
bid: best(&book.bids),
|
||||||
|
offer: best(&book.offers),
|
||||||
|
market_status: book
|
||||||
|
.market_status
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| if open { "open" } else { "close" }.to_string()),
|
||||||
|
session_note: book.session_note.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_market_status(body: &str) -> Option<String> {
|
||||||
|
data(body, "market session")
|
||||||
|
.ok()?
|
||||||
|
.pointer("/detail/regular/state_name")?
|
||||||
|
.as_str()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chart window holding `period` of daily candles: Stockbit picks the
|
||||||
|
/// bar size from the window (`1w` is hourly, `3y`/`5y` weekly), so only the
|
||||||
|
/// daily windows qualify.
|
||||||
|
pub fn history_window(period: &Period, interval: &Interval) -> Option<&'static str> {
|
||||||
|
match (interval, period) {
|
||||||
|
(Interval::Day, Period::FiveDays | Period::OneMonth) => Some("1m"),
|
||||||
|
(Interval::Day, Period::ThreeMonths) => Some("3m"),
|
||||||
|
(Interval::Day, Period::SixMonths | Period::OneYear) => Some("1y"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unsupported_window(period: &Period, interval: &Interval) -> IdxError {
|
||||||
|
IdxError::Unsupported(format!(
|
||||||
|
"Stockbit history covers 5d to 1y of daily candles, not {} at {}; use \
|
||||||
|
--history-provider yahoo",
|
||||||
|
period.as_str(),
|
||||||
|
interval.as_str()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The candles of `period` from a daily chart response.
|
||||||
|
pub fn daily_for_period(body: &str, period: &Period) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
Ok(trim_to_period(parse_daily(body)?, period))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Daily candles, oldest first. Volume arrives in lots and is returned in
|
||||||
|
/// shares, as from the other providers.
|
||||||
|
pub fn parse_daily(body: &str) -> Result<Vec<Ohlc>, IdxError> {
|
||||||
|
let d = data(body, "chart")?;
|
||||||
|
let rows = d
|
||||||
|
.get("prices")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or_else(|| IdxError::ParseError("stockbit chart: no prices".to_string()))?;
|
||||||
|
let mut bars = Vec::with_capacity(rows.len());
|
||||||
|
for row in rows {
|
||||||
|
let Some(close) = whole(row.get("value")) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(date) = row
|
||||||
|
.get("formatted_date")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|d| NaiveDate::parse_from_str(d.trim(), "%Y-%m-%d").ok())
|
||||||
|
else {
|
||||||
|
return Err(IdxError::ParseError(format!(
|
||||||
|
"stockbit chart: row without a daily date: {row}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
bars.push(Ohlc {
|
||||||
|
date,
|
||||||
|
open: whole(row.get("open")).unwrap_or(close),
|
||||||
|
high: whole(row.get("high")).unwrap_or(close),
|
||||||
|
low: whole(row.get("low")).unwrap_or(close),
|
||||||
|
close,
|
||||||
|
volume: count(row.get("volume")).unwrap_or(0).saturating_mul(100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bars.sort_by_key(|b| b.date);
|
||||||
|
Ok(bars)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The candles within `period` of the latest one (5 sessions for `5d`).
|
||||||
|
fn trim_to_period(mut bars: Vec<Ohlc>, period: &Period) -> Vec<Ohlc> {
|
||||||
|
let Some(last) = bars.last().map(|b| b.date) else {
|
||||||
|
return bars;
|
||||||
|
};
|
||||||
|
let months = match period {
|
||||||
|
Period::FiveDays => {
|
||||||
|
let start = bars.len().saturating_sub(5);
|
||||||
|
return bars.split_off(start);
|
||||||
|
}
|
||||||
|
Period::OneMonth => 1,
|
||||||
|
Period::ThreeMonths => 3,
|
||||||
|
Period::SixMonths => 6,
|
||||||
|
_ => 12,
|
||||||
|
};
|
||||||
|
match last.checked_sub_months(Months::new(months)) {
|
||||||
|
Some(start) => bars.into_iter().filter(|b| b.date > start).collect(),
|
||||||
|
None => bars,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 403 from the CDN in front of the session API: a block, not a refused
|
||||||
|
/// session, so nothing is retried.
|
||||||
|
pub(crate) fn blocked() -> IdxError {
|
||||||
|
IdxError::UpstreamBlocked(
|
||||||
|
"exodus.stockbit.com refused this client (HTTP 403 from its CDN); idx does not \
|
||||||
|
retry a block. Try again later or from another network"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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}");
|
||||||
|
}
|
||||||
|
// Paths another module builds come from its own builder, so the
|
||||||
|
// allowlist cannot drift from them.
|
||||||
|
let query = MovementQuery::default();
|
||||||
|
for path in [
|
||||||
|
movement::request_path(&query, None, 1),
|
||||||
|
movement::request_path(&query, Some("BBCA"), 3),
|
||||||
|
] {
|
||||||
|
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}",
|
||||||
|
env!("CARGO_MANIFEST_DIR")
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn order_book_carries_depth_in_shares_and_foreign_flow() {
|
||||||
|
let book = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(book.price, book.prev_close, book.change),
|
||||||
|
(5175, Some(6075), Some(-900))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(book.open, book.high, book.low),
|
||||||
|
(Some(5825), Some(5825), Some(5175))
|
||||||
|
);
|
||||||
|
assert_eq!(book.foreign_net, Some(-2_329_493_087_500));
|
||||||
|
// BBCA closed at its lower limit: no bids, offers best price first.
|
||||||
|
assert!(book.bids.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
book.offers[0],
|
||||||
|
BookLevel {
|
||||||
|
price: 5175,
|
||||||
|
shares: 264_462_500,
|
||||||
|
orders: 3284
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(book.offers.windows(2).all(|w| w[0].price < w[1].price));
|
||||||
|
assert_eq!(book.session_note, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quote_shows_the_best_levels_only_while_the_market_is_open() {
|
||||||
|
let trading = parse_market_status(&fixture("market_session_trading.json"));
|
||||||
|
let book = |state: Option<String>| {
|
||||||
|
parse_orderbook("BBCA.JK", &fixture("orderbook_bbca_in_session.json"), state).unwrap()
|
||||||
|
};
|
||||||
|
let open = quote_from_book(&book(trading));
|
||||||
|
assert_eq!((open.bid, open.offer), (Some(4850), Some(4860)));
|
||||||
|
assert_eq!(open.price, 4860);
|
||||||
|
assert_eq!(open.market_status.as_deref(), Some("open"));
|
||||||
|
for state in ["market_session_break.json", "market_session.json"] {
|
||||||
|
let shut = quote_from_book(&book(parse_market_status(&fixture(state))));
|
||||||
|
assert_eq!((shut.bid, shut.offer), (None, None), "{state}");
|
||||||
|
assert_eq!(shut.market_status.as_deref(), Some("close"), "{state}");
|
||||||
|
}
|
||||||
|
let unknown = quote_from_book(&book(None));
|
||||||
|
assert_eq!((unknown.bid, unknown.market_status), (None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn market_status_is_read_verbatim() {
|
||||||
|
for (name, state) in [
|
||||||
|
("market_session.json", "STATE_NAME_MARKET_CLOSED"),
|
||||||
|
("market_session_break.json", "STATE_NAME_BREAK"),
|
||||||
|
("market_session_trading.json", "STATE_NAME_SESSION_2"),
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_market_status(&fixture(name)).as_deref(), Some(state));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_numbered_sessions_are_trading() {
|
||||||
|
assert!(is_trading("STATE_NAME_SESSION_1"));
|
||||||
|
assert!(is_trading("STATE_NAME_SESSION_2"));
|
||||||
|
for state in [
|
||||||
|
"STATE_NAME_BREAK",
|
||||||
|
"STATE_NAME_MARKET_CLOSED",
|
||||||
|
"STATE_NAME_MARKET_OPEN",
|
||||||
|
"STATE_NAME_SESSION_",
|
||||||
|
"STATE_NAME_SESSION_PRE_OPENING",
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert!(!is_trading(state), "{state}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_flow_only_when_stockbit_marks_it_current() {
|
||||||
|
// In session (has_foreign_bs false) the figures were 2026-11-02's.
|
||||||
|
let live =
|
||||||
|
parse_orderbook("BBCA.JK", &fixture("orderbook_bbca_in_session.json"), None).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
(live.foreign_buy, live.foreign_sell, live.foreign_net),
|
||||||
|
(None, None, None)
|
||||||
|
);
|
||||||
|
assert_eq!(live.volume, Some(134_744_100));
|
||||||
|
let done = parse_orderbook("BBCA.JK", &fixture("orderbook_bbca.json"), None).unwrap();
|
||||||
|
assert_eq!(done.foreign_buy, Some(1_616_461_977_500));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_map_to_their_codes() {
|
||||||
|
let reply = |status, body: &str| Reply {
|
||||||
|
status,
|
||||||
|
body: body.to_string(),
|
||||||
|
};
|
||||||
|
assert!(matches!(
|
||||||
|
classify(reply(404, &fixture("orderbook_zzzz.json")), "ZZZZ.JK"),
|
||||||
|
Err(IdxError::SymbolNotFound(s)) if s == "ZZZZ.JK"
|
||||||
|
));
|
||||||
|
assert!(reply(401, &fixture("unauthorized.json")).rejected());
|
||||||
|
assert!(reply(403, &fixture("unauthorized.json")).rejected());
|
||||||
|
let blocked = reply(403, "<HTML>Request blocked.</HTML>");
|
||||||
|
assert!(!blocked.rejected());
|
||||||
|
assert!(matches!(
|
||||||
|
classify(blocked, "BBCA.JK"),
|
||||||
|
Err(IdxError::UpstreamBlocked(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
classify(reply(429, ""), "x"),
|
||||||
|
Err(IdxError::RateLimited)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn daily_candles_convert_lots_to_shares_and_trim_to_the_period() {
|
||||||
|
let year = parse_daily(&fixture("chart_candle_bbca_1y.json")).unwrap();
|
||||||
|
assert_eq!(year.len(), 240);
|
||||||
|
assert!(year.windows(2).all(|w| w[0].date < w[1].date));
|
||||||
|
let month = parse_daily(&fixture("chart_candle_bbca_1m.json")).unwrap();
|
||||||
|
let first = &month[0];
|
||||||
|
assert_eq!(first.date.to_string(), "2026-09-16");
|
||||||
|
assert_eq!(
|
||||||
|
(first.open, first.high, first.low, first.close),
|
||||||
|
(6350, 6425, 6325, 6325)
|
||||||
|
);
|
||||||
|
assert_eq!(first.volume, 72_157_400);
|
||||||
|
|
||||||
|
let six = trim_to_period(year.clone(), &Period::SixMonths);
|
||||||
|
assert_eq!(six.first().unwrap().date.to_string(), "2026-04-17");
|
||||||
|
assert_eq!(trim_to_period(month, &Period::FiveDays).len(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_daily_windows_map_to_a_chart() {
|
||||||
|
assert_eq!(history_window(&Period::OneYear, &Interval::Day), Some("1y"));
|
||||||
|
assert_eq!(history_window(&Period::TwoYears, &Interval::Day), None);
|
||||||
|
assert_eq!(history_window(&Period::OneYear, &Interval::Week), None);
|
||||||
|
assert_eq!(history_window(&Period::OneDay, &Interval::Day), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
723
crates/idx-core/src/api/stockbit/loopback.rs
Normal file
723
crates/idx-core/src/api/stockbit/loopback.rs
Normal file
|
|
@ -0,0 +1,723 @@
|
||||||
|
//! The local page `idx stockbit login` opens: a loopback server on
|
||||||
|
//! 127.0.0.1 that takes the pasted session, checks it, and reports back.
|
||||||
|
//!
|
||||||
|
//! Stockbit has no OAuth redirect that could hand idx a session, and the
|
||||||
|
//! browser will not give one site another site's cookie, so the page takes
|
||||||
|
//! the value two ways, both one click:
|
||||||
|
//! - **Read my clipboard**: the user copies `credentialStorage` in DevTools
|
||||||
|
//! once and clicks the button (`navigator.clipboard`, allowed on localhost).
|
||||||
|
//! - **Bookmarklet**: a link rendered with this run's port and secret that
|
||||||
|
//! reads `document.cookie` on stockbit.com and opens the page with the value
|
||||||
|
//! in the URL *fragment*, which browsers never send to the server.
|
||||||
|
//!
|
||||||
|
//! Safeguards: bound to 127.0.0.1, a random port, a per-run secret path (no
|
||||||
|
//! secret, no page), a short life, `no-store`, no referrer, and a strict CSP.
|
||||||
|
//! The session value is held in memory only on the way to `stockbit::login`;
|
||||||
|
//! it is never written to a log or the response.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{Ipv4Addr, TcpListener, TcpStream};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// Largest paste accepted (the cookie is a few KB).
|
||||||
|
const MAX_BODY: usize = 16 * 1024;
|
||||||
|
/// Requests time out instead of pinning a thread; short, because a browser
|
||||||
|
/// preconnect sends nothing at all.
|
||||||
|
const IO_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
/// Connections answered at once; beyond this, new ones are dropped.
|
||||||
|
const MAX_CONNECTIONS: usize = 16;
|
||||||
|
|
||||||
|
/// A running login page.
|
||||||
|
pub struct LoginPage {
|
||||||
|
listener: TcpListener,
|
||||||
|
port: u16,
|
||||||
|
secret: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginPage {
|
||||||
|
/// Binds 127.0.0.1 on `port` (0 picks a free one).
|
||||||
|
pub fn bind(port: u16) -> Result<Self, IdxError> {
|
||||||
|
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).map_err(|e| {
|
||||||
|
IdxError::Io(format!(
|
||||||
|
"cannot open the Stockbit login page on 127.0.0.1:{port}: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|e| IdxError::Io(e.to_string()))?
|
||||||
|
.port();
|
||||||
|
listener
|
||||||
|
.set_nonblocking(true)
|
||||||
|
.map_err(|e| IdxError::Io(e.to_string()))?;
|
||||||
|
Ok(Self {
|
||||||
|
listener,
|
||||||
|
port,
|
||||||
|
secret: random_secret(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn url(&self) -> String {
|
||||||
|
format!("http://127.0.0.1:{}/login/{}", self.port, self.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The port the page listens on.
|
||||||
|
pub fn port(&self) -> u16 {
|
||||||
|
self.port
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serves until `verify` accepts a paste or `timeout` passes. The error
|
||||||
|
/// `verify` returns for a refused paste is shown on the page.
|
||||||
|
///
|
||||||
|
/// Each connection gets its own thread: browsers open idle preconnects,
|
||||||
|
/// and answering them one at a time used to hold the page back for the
|
||||||
|
/// read timeout on every one. Pastes are checked one at a time, and none
|
||||||
|
/// after one is accepted: two at once (a double click, the bookmarklet
|
||||||
|
/// and the clipboard button) would each store a session, and the later
|
||||||
|
/// could overwrite the one Stockbit had just rotated.
|
||||||
|
pub fn wait<F>(&self, timeout: Duration, verify: F) -> Result<(), IdxError>
|
||||||
|
where
|
||||||
|
F: Fn(&str) -> Result<(), IdxError> + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
|
||||||
|
let accepted = std::sync::Mutex::new(false);
|
||||||
|
let verify = std::sync::Arc::new(move |paste: &str| {
|
||||||
|
let mut accepted = accepted.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
if !*accepted {
|
||||||
|
verify(paste)?;
|
||||||
|
*accepted = true;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
let page = std::sync::Arc::new(self.page());
|
||||||
|
let path = std::sync::Arc::new(format!("/login/{}", self.secret));
|
||||||
|
let busy = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
if done_rx.try_recv().is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match self.listener.accept() {
|
||||||
|
Ok((stream, _)) => {
|
||||||
|
// Browsers open a handful; anything beyond this is junk.
|
||||||
|
if busy.load(std::sync::atomic::Ordering::Relaxed) >= MAX_CONNECTIONS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
busy.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let (verify, page, path) = (verify.clone(), page.clone(), path.clone());
|
||||||
|
let busy = busy.clone();
|
||||||
|
let done = done_tx.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if serve_once(stream, &path, &page, verify.as_ref()) {
|
||||||
|
let _ = done.send(());
|
||||||
|
}
|
||||||
|
busy.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(IdxError::Io(format!(
|
||||||
|
"the Stockbit login page timed out after {}s; its URL was {} (re-run it, or pin the \
|
||||||
|
port with --port)",
|
||||||
|
timeout.as_secs(),
|
||||||
|
self.url()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The page, with this run's port and secret baked into the bookmarklet.
|
||||||
|
fn page(&self) -> String {
|
||||||
|
let target = self.url();
|
||||||
|
// Reads stockbit.com's session cookie and opens the page with it in
|
||||||
|
// the fragment (never sent to a server, never in a referrer).
|
||||||
|
let bookmarklet = format!(
|
||||||
|
"javascript:(()=>{{const m=document.cookie.match(/(?:^|;\\s*)credentialStorage=([^;]*)/);\
|
||||||
|
if(!m){{alert('No Stockbit session in this browser: log in to stockbit.com first.');return}}\
|
||||||
|
window.open('{target}#t='+encodeURIComponent(m[1]))}})()"
|
||||||
|
);
|
||||||
|
PAGE.replace("__TARGET__", &target)
|
||||||
|
.replace("__BOOKMARKLET__", &html_escape(&bookmarklet))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The forward to run where the browser is, for the page on `port`:
|
||||||
|
/// `ssh -L <port>:127.0.0.1:<port> <user>@<host>`.
|
||||||
|
///
|
||||||
|
/// The target comes from `SSH_CONNECTION` (`client_ip client_port server_ip
|
||||||
|
/// server_port`) when idx runs inside an SSH session, else from `USER` and
|
||||||
|
/// `HOSTNAME`, else it is left for the reader to fill in.
|
||||||
|
pub fn ssh_forward(port: u16) -> String {
|
||||||
|
let target = ssh_target().unwrap_or_else(|| "<this host>".to_string());
|
||||||
|
format!("ssh -L {port}:127.0.0.1:{port} {target}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ssh_target() -> Option<String> {
|
||||||
|
if let Ok(connection) = std::env::var("SSH_CONNECTION") {
|
||||||
|
let fields: Vec<&str> = connection.split_whitespace().collect();
|
||||||
|
if let Some(server) = fields.get(2) {
|
||||||
|
return Some(match std::env::var("USER") {
|
||||||
|
Ok(user) => format!("{user}@{server}"),
|
||||||
|
Err(_) => (*server).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let user = std::env::var("USER").ok()?;
|
||||||
|
let host = std::env::var("HOSTNAME").ok()?;
|
||||||
|
Some(format!("{user}@{host}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answers one request; true when a paste was accepted.
|
||||||
|
fn serve_once(
|
||||||
|
mut stream: TcpStream,
|
||||||
|
page_path: &str,
|
||||||
|
page: &str,
|
||||||
|
verify: &dyn Fn(&str) -> Result<(), IdxError>,
|
||||||
|
) -> bool {
|
||||||
|
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
|
||||||
|
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
|
||||||
|
let Some(request) = read_request(&mut stream) else {
|
||||||
|
// An idle preconnect or a malformed request: drop it quietly.
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match (request.method.as_str(), request.path.as_str()) {
|
||||||
|
("GET", p) if p == page_path => {
|
||||||
|
respond(
|
||||||
|
&mut stream,
|
||||||
|
200,
|
||||||
|
"text/html; charset=utf-8",
|
||||||
|
page.as_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
("POST", p) if p == page_path => match verify(&request.body) {
|
||||||
|
Ok(()) => {
|
||||||
|
respond(
|
||||||
|
&mut stream,
|
||||||
|
200,
|
||||||
|
"application/json",
|
||||||
|
br#"{"ok":true,"message":"Connected. You can close this tab."}"#,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let body = serde_json::json!({ "ok": false, "message": err.to_string() });
|
||||||
|
respond(
|
||||||
|
&mut stream,
|
||||||
|
200,
|
||||||
|
"application/json",
|
||||||
|
body.to_string().as_bytes(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
("GET", "/favicon.ico") => respond(&mut stream, 204, "text/plain", b""),
|
||||||
|
_ => respond(&mut stream, 404, "text/plain", b"not found"),
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_secret() -> String {
|
||||||
|
(0..4)
|
||||||
|
.map(|_| format!("{:08x}", fastrand::u32(..)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_escape(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Request {
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal HTTP/1.1 request: request line, headers, and a bounded body.
|
||||||
|
fn read_request(stream: &mut TcpStream) -> Option<Request> {
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
let mut chunk = [0u8; 2048];
|
||||||
|
let header_end = loop {
|
||||||
|
let n = stream.read(&mut chunk).ok()?;
|
||||||
|
if n == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
raw.extend_from_slice(&chunk[..n]);
|
||||||
|
if let Some(at) = find(&raw, b"\r\n\r\n") {
|
||||||
|
break at + 4;
|
||||||
|
}
|
||||||
|
if raw.len() > MAX_BODY {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let head = String::from_utf8_lossy(&raw[..header_end]).into_owned();
|
||||||
|
let mut lines = head.lines();
|
||||||
|
let mut request_line = lines.next()?.split_whitespace();
|
||||||
|
let method = request_line.next()?.to_string();
|
||||||
|
let path = request_line.next()?.split('?').next()?.to_string();
|
||||||
|
let length = lines
|
||||||
|
.filter_map(|line| line.split_once(':'))
|
||||||
|
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
|
||||||
|
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
.min(MAX_BODY);
|
||||||
|
let mut body = raw[header_end..].to_vec();
|
||||||
|
while body.len() < length {
|
||||||
|
let n = stream.read(&mut chunk).ok()?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&chunk[..n]);
|
||||||
|
}
|
||||||
|
body.truncate(length);
|
||||||
|
Some(Request {
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
body: String::from_utf8_lossy(&body).into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
|
haystack
|
||||||
|
.windows(needle.len())
|
||||||
|
.position(|window| window == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) {
|
||||||
|
let reason = match status {
|
||||||
|
200 => "OK",
|
||||||
|
204 => "No Content",
|
||||||
|
400 => "Bad Request",
|
||||||
|
_ => "Not Found",
|
||||||
|
};
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\
|
||||||
|
Content-Length: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\n\
|
||||||
|
X-Content-Type-Options: nosniff\r\n\
|
||||||
|
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; \
|
||||||
|
script-src 'unsafe-inline'; connect-src 'self'\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(head.as_bytes());
|
||||||
|
let _ = stream.write_all(body);
|
||||||
|
let _ = stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE: &str = r#"<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light dark">
|
||||||
|
<title>Connect Stockbit to idx</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
color-scheme:light dark;
|
||||||
|
--bg:#f4f5f8;--glow:rgba(47,92,240,.10);--panel:#fff;--panel-2:#f7f8fb;
|
||||||
|
--ink:#12141a;--muted:#6a7180;--line:#e5e8ef;--line-strong:#d2d7e2;
|
||||||
|
--accent:#2f5cf0;--accent-ink:#fff;
|
||||||
|
--ok:#0e7a48;--ok-bg:#eaf7f0;--ok-line:#bfe6d1;
|
||||||
|
--err:#b42318;--err-bg:#fdf2f1;--err-line:#f0cecb;
|
||||||
|
--shadow:0 1px 2px rgba(16,24,40,.05),0 18px 40px -20px rgba(16,24,40,.25)
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme:dark){:root{
|
||||||
|
--bg:#0b0c0f;--glow:rgba(122,162,255,.09);--panel:#14161b;--panel-2:#191c22;
|
||||||
|
--ink:#edeef2;--muted:#99a0b0;--line:#252932;--line-strong:#333a46;
|
||||||
|
--accent:#7aa2ff;--accent-ink:#0b0c0f;
|
||||||
|
--ok:#5fd39a;--ok-bg:#0f1f19;--ok-line:#1e4135;
|
||||||
|
--err:#ff8f86;--err-bg:#231413;--err-line:#4a2523;
|
||||||
|
--shadow:0 1px 2px rgba(0,0,0,.5),0 18px 44px -20px rgba(0,0,0,.9)
|
||||||
|
}}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;padding:clamp(20px,5vw,56px) 20px;background:var(--bg);
|
||||||
|
background-image:radial-gradient(900px 420px at 50% -180px,var(--glow),transparent 70%);
|
||||||
|
background-repeat:no-repeat;color:var(--ink);
|
||||||
|
font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
|
||||||
|
-webkit-font-smoothing:antialiased}
|
||||||
|
.card{max-width:640px;margin:0 auto;background:var(--panel);border:1px solid var(--line);
|
||||||
|
border-radius:20px;padding:clamp(24px,4.4vw,40px);box-shadow:var(--shadow)}
|
||||||
|
.brand{display:flex;align-items:center;gap:9px;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:20px}
|
||||||
|
.mark{display:grid;place-items:center;width:28px;height:28px;border-radius:9px;background:var(--accent);color:var(--accent-ink)}
|
||||||
|
.mark svg{width:16px;height:16px}
|
||||||
|
.sep{opacity:.45}
|
||||||
|
h1{font-size:clamp(22px,3.2vw,26px);line-height:1.25;letter-spacing:-.02em;margin:0 0 8px;text-wrap:balance}
|
||||||
|
.lede{color:var(--muted);margin:0 0 26px}
|
||||||
|
.steps{list-style:none;margin:0;padding:0;display:grid;gap:12px}
|
||||||
|
.step{display:flex;gap:14px;border:1px solid var(--line);background:var(--panel-2);border-radius:14px;padding:16px 18px}
|
||||||
|
.num{flex:0 0 auto;display:grid;place-items:center;width:24px;height:24px;margin-top:2px;border-radius:50%;
|
||||||
|
border:1px solid var(--line-strong);background:var(--panel);color:var(--muted);font-size:12px;font-weight:700}
|
||||||
|
.step h2{font-size:15px;letter-spacing:-.01em;margin:0 0 4px}
|
||||||
|
.step p{margin:0 0 13px;color:var(--muted);font-size:14px}
|
||||||
|
.step p:last-child{margin-bottom:0}
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:8px;border:1px solid transparent;border-radius:10px;
|
||||||
|
padding:10px 16px;font:inherit;font-size:14px;font-weight:600;line-height:1.2;cursor:pointer;
|
||||||
|
text-decoration:none;background:var(--accent);color:var(--accent-ink);
|
||||||
|
box-shadow:0 1px 2px rgba(16,24,40,.10);transition:filter .12s ease,transform .12s ease,background-color .12s ease}
|
||||||
|
.btn:hover{filter:brightness(1.07)}
|
||||||
|
.btn:active{transform:translateY(1px)}
|
||||||
|
.btn.ghost{background:var(--panel);color:var(--ink);border-color:var(--line-strong);box-shadow:none}
|
||||||
|
.btn.ghost:hover{background:var(--panel-2);filter:none}
|
||||||
|
.btn[disabled]{opacity:.55;cursor:progress;transform:none}
|
||||||
|
.hint{margin:13px 0 0;color:var(--muted);font-size:13px}
|
||||||
|
.manual{margin-top:14px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}
|
||||||
|
summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;
|
||||||
|
cursor:pointer;font-size:14px;font-weight:600;list-style:none}
|
||||||
|
summary::-webkit-details-marker{display:none}
|
||||||
|
summary svg{width:16px;height:16px;flex:0 0 auto;color:var(--muted);transition:transform .16s ease}
|
||||||
|
.manual[open] summary svg{transform:rotate(180deg)}
|
||||||
|
.manual-body{padding:0 18px 18px;border-top:1px solid var(--line)}
|
||||||
|
.mini{margin:15px 0;padding-left:19px;color:var(--muted);font-size:13.5px;line-height:1.75}
|
||||||
|
.mini li::marker{color:var(--line-strong)}
|
||||||
|
textarea{width:100%;min-height:84px;border:1px solid var(--line-strong);border-radius:10px;
|
||||||
|
background:var(--panel);color:var(--ink);padding:11px 12px;resize:vertical;
|
||||||
|
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
|
||||||
|
textarea::placeholder{color:var(--muted);opacity:.75}
|
||||||
|
.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
|
||||||
|
code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--panel-2);
|
||||||
|
border:1px solid var(--line);border-radius:6px;padding:1px 5px;white-space:nowrap}
|
||||||
|
kbd{font:12px ui-monospace,Menlo,monospace;background:var(--panel-2);border:1px solid var(--line-strong);
|
||||||
|
border-bottom-width:2px;border-radius:5px;padding:0 5px}
|
||||||
|
em{font-style:normal;color:var(--ink)}
|
||||||
|
.status{display:flex;align-items:center;gap:10px;margin:18px 0 0;padding:12px 14px;
|
||||||
|
border:1px solid transparent;border-radius:11px;font-size:14px;font-weight:600}
|
||||||
|
.status[hidden]{display:none}
|
||||||
|
.status.pending{color:var(--muted);background:var(--panel-2);border-color:var(--line)}
|
||||||
|
.status.pending::before{content:"";flex:0 0 auto;width:14px;height:14px;border-radius:50%;
|
||||||
|
border:2px solid var(--line-strong);border-top-color:var(--accent);animation:spin .7s linear infinite}
|
||||||
|
.status.err{color:var(--err);background:var(--err-bg);border-color:var(--err-line)}
|
||||||
|
.status.err::before{content:"!";flex:0 0 auto;display:grid;place-items:center;width:16px;height:16px;
|
||||||
|
border-radius:50%;background:var(--err);color:var(--err-bg);font-size:11px;font-weight:800}
|
||||||
|
@keyframes spin{to{transform:rotate(360deg)}}
|
||||||
|
@media (prefers-reduced-motion:reduce){
|
||||||
|
.status.pending::before{animation-duration:2.4s}
|
||||||
|
.btn{transition:none}
|
||||||
|
}
|
||||||
|
.done{padding:6px 0 10px;text-align:center}
|
||||||
|
.done[hidden]{display:none}
|
||||||
|
.done .check{display:grid;place-items:center;width:52px;height:52px;margin:0 auto 16px;
|
||||||
|
border-radius:50%;background:var(--ok-bg);border:1px solid var(--ok-line);color:var(--ok)}
|
||||||
|
.done .check svg{width:26px;height:26px}
|
||||||
|
.done h2{font-size:18px;letter-spacing:-.01em;margin:0 0 6px}
|
||||||
|
.done p{margin:0;color:var(--muted);font-size:14px}
|
||||||
|
.foot{margin-top:26px;padding-top:18px;border-top:1px solid var(--line);
|
||||||
|
color:var(--muted);font-size:13px;line-height:1.65}
|
||||||
|
.foot p{margin:0 0 8px}
|
||||||
|
.foot p:last-child{margin-bottom:0}
|
||||||
|
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
|
||||||
|
</style></head>
|
||||||
|
<body><main class="card">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.7 14.3 14.3 9.7"/><path d="M7.4 12.6l-1.3 1.3a3.6 3.6 0 0 0 5 5l1.3-1.3"/><path d="M16.6 11.4l1.3-1.3a3.6 3.6 0 0 0-5-5L11.6 6.4"/></svg></span>
|
||||||
|
<span>idx</span><span class="sep" aria-hidden="true">/</span><span>Stockbit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="head">
|
||||||
|
<h1>Connect your Stockbit account</h1>
|
||||||
|
<p class="lede">idx then reads live quotes and the order book with your own session. The session stays on this machine.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="app">
|
||||||
|
<ol class="steps">
|
||||||
|
<li class="step">
|
||||||
|
<span class="num" aria-hidden="true">1</span>
|
||||||
|
<div>
|
||||||
|
<h2>Sign in to Stockbit</h2>
|
||||||
|
<p>Open stockbit.com in this browser and sign in. If the tab is already signed in, go to the next step.</p>
|
||||||
|
<a class="btn ghost" href="https://stockbit.com/symbol/BBCA" target="_blank" rel="noreferrer">Open stockbit.com</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="step">
|
||||||
|
<span class="num" aria-hidden="true">2</span>
|
||||||
|
<div>
|
||||||
|
<h2>Send the session to idx</h2>
|
||||||
|
<p>While the Stockbit tab is open, click this button. It reads the session cookie in your own browser.</p>
|
||||||
|
<a class="btn" id="bookmarklet" href="__BOOKMARKLET__">Send my Stockbit session</a>
|
||||||
|
<p class="hint">If the browser blocks it, drag the button to your bookmarks bar, open stockbit.com, then click it there.</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<details class="manual" id="manual">
|
||||||
|
<summary><span>Or paste the cookie by hand</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
|
||||||
|
<div class="manual-body">
|
||||||
|
<ol class="mini">
|
||||||
|
<li>On stockbit.com, open DevTools with <kbd>F12</kbd>.</li>
|
||||||
|
<li>Go to <em>Application</em>, then <em>Cookies</em>, then <code>https://stockbit.com</code>.</li>
|
||||||
|
<li>Copy the value of <code>credentialStorage</code> and paste it below.</li>
|
||||||
|
</ol>
|
||||||
|
<textarea id="paste" placeholder="credentialStorage value" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" id="send">Connect</button>
|
||||||
|
<button class="btn ghost" id="clip">Paste from clipboard</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<p id="status" class="status" role="status" aria-live="polite" hidden></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="done" class="done" hidden aria-live="polite">
|
||||||
|
<span class="check" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m20 6.5-10.5 11L4 12"/></svg></span>
|
||||||
|
<h2>Connected</h2>
|
||||||
|
<p id="done-msg">You can close this tab and go back to idx.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<p>idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser that shares this session signs out once idx refreshes it.</p>
|
||||||
|
<p>This page stops listening after a few minutes.</p>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const target = "__TARGET__";
|
||||||
|
const app = document.getElementById('app');
|
||||||
|
const done = document.getElementById('done');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const manual = document.getElementById('manual');
|
||||||
|
const paste = document.getElementById('paste');
|
||||||
|
const head = document.querySelector('.head');
|
||||||
|
const buttons = [document.getElementById('send'), document.getElementById('clip')];
|
||||||
|
let busy = false;
|
||||||
|
|
||||||
|
function setStatus(text, kind) {
|
||||||
|
statusEl.hidden = !text;
|
||||||
|
statusEl.className = 'status' + (kind ? ' ' + kind : '');
|
||||||
|
statusEl.textContent = text || '';
|
||||||
|
}
|
||||||
|
function setBusy(value) {
|
||||||
|
busy = value;
|
||||||
|
for (const button of buttons) { button.disabled = value; }
|
||||||
|
}
|
||||||
|
function askForPaste(message) {
|
||||||
|
if (message) { setStatus(message, 'err'); }
|
||||||
|
manual.open = true;
|
||||||
|
paste.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send(value) {
|
||||||
|
const text = (value || '').trim();
|
||||||
|
if (busy) { return false; }
|
||||||
|
if (!text) { askForPaste('Paste the credentialStorage value first.'); return false; }
|
||||||
|
setBusy(true);
|
||||||
|
setStatus('Checking the session with Stockbit\u2026', 'pending');
|
||||||
|
try {
|
||||||
|
const response = await fetch(target, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
body: text
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.ok) {
|
||||||
|
setStatus('');
|
||||||
|
head.hidden = true;
|
||||||
|
app.hidden = true;
|
||||||
|
done.hidden = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setStatus(result.message, 'err');
|
||||||
|
} catch (error) {
|
||||||
|
setStatus('Could not reach idx: ' + error, 'err');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('send').addEventListener('click', () => send(paste.value));
|
||||||
|
document.getElementById('clip').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
send(await navigator.clipboard.readText());
|
||||||
|
} catch (error) {
|
||||||
|
askForPaste('The browser blocked the clipboard. Paste the value into the box instead.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
paste.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
send(paste.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (location.hash.startsWith('#t=')) {
|
||||||
|
const value = decodeURIComponent(location.hash.slice(3));
|
||||||
|
history.replaceState(null, '', location.pathname);
|
||||||
|
send(value).then((ok) => {
|
||||||
|
if (!ok) { paste.value = value; manual.open = true; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::io::{Read as _, Write as _};
|
||||||
|
use std::net::TcpStream;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn request(url: &str, method: &str, body: &str) -> (u16, String) {
|
||||||
|
let rest = url.trim_start_matches("http://");
|
||||||
|
let (authority, path) = rest.split_once('/').expect("path");
|
||||||
|
let mut stream = TcpStream::connect(authority).expect("connect");
|
||||||
|
// A server that never answers must fail the test, not hang it.
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"{method} /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Length: {}\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut raw = String::new();
|
||||||
|
stream.read_to_string(&mut raw).unwrap();
|
||||||
|
let status = raw
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.expect("status");
|
||||||
|
let body = raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string();
|
||||||
|
(status, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two pastes at once are checked one at a time, and the second is not
|
||||||
|
/// checked at all once the first is accepted.
|
||||||
|
#[test]
|
||||||
|
fn concurrent_pastes_are_checked_once() {
|
||||||
|
let page = LoginPage::bind(0).unwrap();
|
||||||
|
let url = page.url();
|
||||||
|
let checks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let counted = checks.clone();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
page.wait(Duration::from_secs(10), move |_| {
|
||||||
|
counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
let clients: Vec<_> = (0..2)
|
||||||
|
.map(|_| {
|
||||||
|
let url = url.clone();
|
||||||
|
std::thread::spawn(move || request(&url, "POST", "good"))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for client in clients {
|
||||||
|
let (status, body) = client.join().unwrap();
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert!(body.contains("\"ok\":true"), "{body}");
|
||||||
|
}
|
||||||
|
handle.join().unwrap().unwrap();
|
||||||
|
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serves_the_page_only_with_the_secret_and_accepts_a_good_paste() {
|
||||||
|
let page = LoginPage::bind(0).unwrap();
|
||||||
|
let url = page.url();
|
||||||
|
let bad = url.replace(&page.secret, "not-the-secret");
|
||||||
|
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
let verify = |paste: &str| match paste.trim() {
|
||||||
|
"good" => Ok(()),
|
||||||
|
_ => Err(IdxError::InvalidInput("not a Stockbit session".to_string())),
|
||||||
|
};
|
||||||
|
page.wait(Duration::from_secs(10), verify)
|
||||||
|
});
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
|
||||||
|
assert_eq!(request(&bad, "GET", "").0, 404);
|
||||||
|
let (status, html) = request(&url, "GET", "");
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert!(html.contains("Connect Stockbit to idx"));
|
||||||
|
assert!(html.contains("javascript:"), "bookmarklet rendered");
|
||||||
|
// The bookmarklet carries this run's page and a fragment, not the
|
||||||
|
// secret in a query string.
|
||||||
|
assert!(html.contains(&format!("{url}#t=")), "bookmarklet target");
|
||||||
|
|
||||||
|
let (status, body) = request(&url, "POST", "nonsense");
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert!(body.contains("\"ok\":false"), "{body}");
|
||||||
|
|
||||||
|
let (status, body) = request(&url, "POST", "good");
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert!(body.contains("\"ok\":true"), "{body}");
|
||||||
|
handle.join().unwrap().expect("accepted");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CSP allows no fetch of its own, so a stray asset would fail
|
||||||
|
/// silently; the page also has to keep the two entry points the docs name.
|
||||||
|
#[test]
|
||||||
|
fn the_page_is_self_contained_and_keeps_both_entry_points() {
|
||||||
|
let page = LoginPage::bind(0).unwrap().page();
|
||||||
|
for forbidden in ["<script src", "<link", "@import", "url(http"] {
|
||||||
|
assert!(!page.contains(forbidden), "page needs {forbidden}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
page.matches("https://").count(),
|
||||||
|
2,
|
||||||
|
"one link out, and the cookie path spelled out"
|
||||||
|
);
|
||||||
|
for url in page.split("https://").skip(1) {
|
||||||
|
assert!(
|
||||||
|
url.starts_with("stockbit.com"),
|
||||||
|
"page links somewhere else: {}",
|
||||||
|
&url[..url.len().min(40)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(page.contains("https://stockbit.com/symbol/BBCA"));
|
||||||
|
assert!(page.contains("Send my Stockbit session"));
|
||||||
|
assert!(page.contains("Paste from clipboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn times_out_without_a_paste() {
|
||||||
|
let page = LoginPage::bind(0).unwrap();
|
||||||
|
let started = Instant::now();
|
||||||
|
let err = page
|
||||||
|
.wait(Duration::from_millis(150), |_| {
|
||||||
|
Err(IdxError::InvalidInput("no".to_string()))
|
||||||
|
})
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("timed out"), "{err}");
|
||||||
|
assert!(started.elapsed() < Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browsers open idle preconnects; one must not delay the page.
|
||||||
|
#[test]
|
||||||
|
fn an_idle_connection_does_not_delay_the_page() {
|
||||||
|
let page = LoginPage::bind(0).unwrap();
|
||||||
|
let url = page.url();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
page.wait(Duration::from_secs(10), |_| {
|
||||||
|
Err(IdxError::InvalidInput("not a Stockbit session".to_string()))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
|
||||||
|
// Two sockets that say nothing, as Chrome's preconnects do.
|
||||||
|
let rest = url.trim_start_matches("http://");
|
||||||
|
let (authority, _) = rest.split_once('/').unwrap();
|
||||||
|
let idle: Vec<TcpStream> = (0..2)
|
||||||
|
.map(|_| TcpStream::connect(authority).unwrap())
|
||||||
|
.collect();
|
||||||
|
std::thread::sleep(Duration::from_millis(100));
|
||||||
|
|
||||||
|
let started = Instant::now();
|
||||||
|
let (status, _) = request(&url, "GET", "");
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert!(
|
||||||
|
started.elapsed() < Duration::from_secs(2),
|
||||||
|
"page waited {:?} behind idle connections",
|
||||||
|
started.elapsed()
|
||||||
|
);
|
||||||
|
drop(idle);
|
||||||
|
drop(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,14 @@
|
||||||
//! - Stockbit's terms describe its market data as delayed at least 10
|
//! - Stockbit's terms describe its market data as delayed at least 10
|
||||||
//! minutes and for personal use; frontends say so.
|
//! minutes and for personal use; frontends say so.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod browser;
|
||||||
|
pub mod corpaction;
|
||||||
|
pub mod exodus;
|
||||||
|
pub mod loopback;
|
||||||
|
pub mod movement;
|
||||||
mod parse;
|
mod parse;
|
||||||
|
pub mod signin;
|
||||||
|
|
||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::{Read, Seek, SeekFrom, Write};
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
|
|
@ -24,9 +31,16 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
pub use parse::{parse_symbol_page, status_error, stockbit_code};
|
pub use parse::{parse_symbol_page, status_error, stockbit_code};
|
||||||
|
|
||||||
use super::types::{Fundamentals, Intraday, Quote};
|
use super::types::{Bar, Fundamentals, Interval, Intraday, OrderBook, Period, Quote};
|
||||||
use super::{FundamentalsProvider, IntradayProvider, QuoteProvider};
|
use super::{
|
||||||
|
CorporateActionProvider, FundamentalsProvider, HistoryProvider, IntradayProvider,
|
||||||
|
OrderBookProvider, QuoteProvider, ShareholderMovementProvider,
|
||||||
|
};
|
||||||
|
use crate::corporate::types::{ActionKind, CorporateAction};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{MovementPage, MovementQuery};
|
||||||
|
use auth::SessionStore;
|
||||||
|
use exodus::Exodus;
|
||||||
|
|
||||||
pub const SOURCE: &str = "stockbit";
|
pub const SOURCE: &str = "stockbit";
|
||||||
/// Identifies idx-cli and where to reach its maintainers.
|
/// Identifies idx-cli and where to reach its maintainers.
|
||||||
|
|
@ -45,8 +59,13 @@ pub(crate) const CACHE_TTL_SECS: u64 = 5;
|
||||||
const MIN_INTERVAL: Duration = Duration::from_secs(1);
|
const MIN_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
const BASE_URL: &str = "https://stockbit.com/symbol/";
|
const BASE_URL: &str = "https://stockbit.com/symbol/";
|
||||||
|
|
||||||
|
/// Quotes come from the user's session when one is stored (live, from
|
||||||
|
/// `exodus`), else from the public page. The order book and history need the
|
||||||
|
/// session; intraday always reads the public page.
|
||||||
pub struct StockbitProvider {
|
pub struct StockbitProvider {
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
|
/// `None` when the config directory cannot be resolved.
|
||||||
|
exodus: Option<Exodus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StockbitProvider {
|
impl StockbitProvider {
|
||||||
|
|
@ -59,7 +78,20 @@ impl StockbitProvider {
|
||||||
.timeout_global(Some(Duration::from_secs(20)))
|
.timeout_global(Some(Duration::from_secs(20)))
|
||||||
.build()
|
.build()
|
||||||
.into();
|
.into();
|
||||||
Self { agent }
|
Self {
|
||||||
|
agent,
|
||||||
|
exodus: SessionStore::open().ok().map(Exodus::new),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The session API when the user has a session.
|
||||||
|
fn session(&self) -> Option<&Exodus> {
|
||||||
|
self.exodus.as_ref().filter(|e| e.store().has_session())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_session(&self) -> Result<&Exodus, IdxError> {
|
||||||
|
self.session()
|
||||||
|
.ok_or_else(|| IdxError::AuthError(auth::NO_SESSION.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The quote and today's series for a resolved symbol (`BBCA.JK`).
|
/// The quote and today's series for a resolved symbol (`BBCA.JK`).
|
||||||
|
|
@ -99,7 +131,7 @@ impl Default for StockbitProvider {
|
||||||
/// `idx` calls, a CLI beside idx-mcp) share the time of the last request in
|
/// `idx` calls, a CLI beside idx-mcp) share the time of the last request in
|
||||||
/// `<cache>/stockbit.pace` under an exclusive file lock. When that file is
|
/// `<cache>/stockbit.pace` under an exclusive file lock. When that file is
|
||||||
/// unusable, pacing is per process.
|
/// unusable, pacing is per process.
|
||||||
fn pace() {
|
pub(crate) fn pace() {
|
||||||
static LAST: Mutex<Option<Instant>> = Mutex::new(None);
|
static LAST: Mutex<Option<Instant>> = Mutex::new(None);
|
||||||
let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
let mut last = LAST.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
if let Some(prev) = *last {
|
if let Some(prev) = *last {
|
||||||
|
|
@ -151,7 +183,10 @@ fn now_ms() -> u128 {
|
||||||
|
|
||||||
impl QuoteProvider for StockbitProvider {
|
impl QuoteProvider for StockbitProvider {
|
||||||
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
fn quote(&self, symbol: &str) -> Result<Quote, IdxError> {
|
||||||
self.snapshot(symbol).map(|day| day.quote)
|
match self.session() {
|
||||||
|
Some(exodus) => exodus.quote(symbol),
|
||||||
|
None => self.snapshot(symbol).map(|day| day.quote),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,15 +202,177 @@ impl IntradayProvider for StockbitProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl OrderBookProvider for StockbitProvider {
|
||||||
|
fn orderbook(&self, symbol: &str) -> Result<OrderBook, IdxError> {
|
||||||
|
self.require_session()?.orderbook(symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CorporateActionProvider for StockbitProvider {
|
||||||
|
fn corporate_actions(
|
||||||
|
&self,
|
||||||
|
kind: ActionKind,
|
||||||
|
symbol: Option<&str>,
|
||||||
|
) -> Result<Vec<CorporateAction>, IdxError> {
|
||||||
|
self.require_session()?.corporate_actions(kind, symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShareholderMovementProvider for StockbitProvider {
|
||||||
|
fn shareholder_movements(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
page: u32,
|
||||||
|
) -> Result<MovementPage, IdxError> {
|
||||||
|
self.require_session()?.shareholder_movements(query, page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryProvider for StockbitProvider {
|
||||||
|
fn history(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> Result<Vec<Bar>, IdxError> {
|
||||||
|
self.require_session()?.daily(symbol, period, interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the user has a Stockbit session stored or seeded.
|
||||||
|
pub fn has_session() -> bool {
|
||||||
|
SessionStore::open().is_ok_and(|store| store.has_session())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the stored session still works, checked with one request.
|
||||||
|
///
|
||||||
|
/// The check refreshes the access token when it is close to expiry, which
|
||||||
|
/// spends the refresh token and rotates the pair; a session Stockbit refuses
|
||||||
|
/// is reported as `AUTHERROR` and left in place. Frontends use this to skip a
|
||||||
|
/// fresh sign-in when the session in hand is still good.
|
||||||
|
pub fn verify_session() -> Result<(), IdxError> {
|
||||||
|
let store = SessionStore::open()?;
|
||||||
|
if !store.has_session() {
|
||||||
|
return Err(IdxError::AuthError(
|
||||||
|
"no Stockbit session stored".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// As in `login`: the mock provider stands in for the network.
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
let check = !crate::api::mock::enabled();
|
||||||
|
#[cfg(not(feature = "testing"))]
|
||||||
|
let check = true;
|
||||||
|
if check {
|
||||||
|
Exodus::new(store).verify()?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
|
pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
|
||||||
IdxError::Unsupported(format!(
|
IdxError::Unsupported(format!(
|
||||||
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"
|
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores a pasted session (stockbit.com's `credentialStorage` cookie or a
|
||||||
|
/// refresh token) and checks it with one request, which refreshes it when the
|
||||||
|
/// access token is missing or stale. A session Stockbit refuses is removed.
|
||||||
|
///
|
||||||
|
/// A check that fails another way (network, rate limit) puts the previous
|
||||||
|
/// session back too, unless Stockbit already rotated the pasted one: the
|
||||||
|
/// refresh spent the pasted token, so the stored pair is then the only
|
||||||
|
/// working copy, and a refresh Stockbit answered is proof enough.
|
||||||
|
pub fn login(pasted: &str) -> Result<auth::SessionStatus, IdxError> {
|
||||||
|
let session = auth::session_from_paste(pasted).ok_or_else(|| {
|
||||||
|
IdxError::InvalidInput(
|
||||||
|
"not a Stockbit session: paste the value of stockbit.com's `credentialStorage` \
|
||||||
|
cookie, or a refresh token"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let store = SessionStore::open()?;
|
||||||
|
// A refused paste must not lose a session that already worked.
|
||||||
|
let previous = std::fs::read(store.path()).ok();
|
||||||
|
store.save(&session)?;
|
||||||
|
let saved = std::fs::read(store.path()).ok();
|
||||||
|
#[cfg(feature = "testing")]
|
||||||
|
let check = !crate::api::mock::enabled();
|
||||||
|
#[cfg(not(feature = "testing"))]
|
||||||
|
let check = true;
|
||||||
|
if check && let Err(err) = Exodus::new(store.clone()).verify() {
|
||||||
|
let rotated = std::fs::read(store.path()).ok() != saved;
|
||||||
|
if !keep_after_failed_check(&err, rotated) {
|
||||||
|
restore(&store, previous);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
crate::runtime::warn(format!(
|
||||||
|
"Stockbit accepted the session and rotated it, but the check after that failed \
|
||||||
|
({err}); the new session is stored"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(store.status(chrono::Utc::now().timestamp()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `login` keeps the stored session after its check failed with
|
||||||
|
/// `err`: only when the check rotated it and Stockbit did not then refuse it.
|
||||||
|
fn keep_after_failed_check(err: &IdxError, rotated: bool) -> bool {
|
||||||
|
rotated && !matches!(err, IdxError::AuthError(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a session check failed for a reason that says nothing about the
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore(store: &SessionStore, previous: Option<Vec<u8>>) {
|
||||||
|
match previous {
|
||||||
|
Some(bytes) => {
|
||||||
|
let _ = std::fs::write(store.path(), bytes);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = store.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored session's state, without a request and without any token.
|
||||||
|
pub fn session_status() -> Result<auth::SessionStatus, IdxError> {
|
||||||
|
Ok(SessionStore::open()?.status(chrono::Utc::now().timestamp()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the stored session: whether there was one, and the state after
|
||||||
|
/// (still logged in when `IDX_STOCKBIT_*` seeds one).
|
||||||
|
pub fn logout() -> Result<(bool, auth::SessionStatus), IdxError> {
|
||||||
|
let store = SessionStore::open()?;
|
||||||
|
let removed = store.clear()?;
|
||||||
|
Ok((removed, store.status(chrono::Utc::now().timestamp())))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::USER_AGENT;
|
use super::{USER_AGENT, check_is_transient, keep_after_failed_check};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_keeps_a_rotated_session_only_when_stockbit_did_not_refuse_it() {
|
||||||
|
let network = IdxError::Http("timeout".to_string());
|
||||||
|
let refused = IdxError::AuthError("expired".to_string());
|
||||||
|
assert!(keep_after_failed_check(&network, true));
|
||||||
|
assert!(!keep_after_failed_check(&network, false));
|
||||||
|
assert!(!keep_after_failed_check(&refused, true));
|
||||||
|
assert!(!keep_after_failed_check(&refused, false));
|
||||||
|
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()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
/// CloudFront answers HTTP 403 to agents containing these (probed
|
/// CloudFront answers HTTP 403 to agents containing these (probed
|
||||||
/// 2026-10-22); the honest agent must never match one.
|
/// 2026-10-22); the honest agent must never match one.
|
||||||
|
|
|
||||||
328
crates/idx-core/src/api/stockbit/movement.rs
Normal file
328
crates/idx-core/src/api/stockbit/movement.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
//! Shareholder movements from `GET /insider/company/majorholder`: reported
|
||||||
|
//! changes in a holder's stake, newest first, 50 rows a page.
|
||||||
|
//!
|
||||||
|
//! Two sources share the feed: IDX insider disclosures (directors,
|
||||||
|
//! commissioners and other insiders, any size; a trade price) and KSEI's
|
||||||
|
//! daily changes of holders at or above 5% (no price; Stockbit estimates
|
||||||
|
//! one). Each row (`data.movement[]`) carries `id` (the holder's id, not the
|
||||||
|
//! row's), `name`, `symbol`, `date` (`"25 Mar 26"`), `previous` / `current` /
|
||||||
|
//! `changes` (`value`: shares as `"+147,933"`, `percentage`: `"+0.0001"`),
|
||||||
|
//! `action_type` (`ACTION_TYPE_BUY`, ..._SELL, ..._TRANSFER, ..._CROSS,
|
||||||
|
//! ..._RIGHT_ISSUE, ..._PRIVATE_PLACEMENT, ..._TENDER_OFFER, ...),
|
||||||
|
//! `nationality`, `badges`, `price.raw.value`, `value.raw.value`,
|
||||||
|
//! `is_estimated_price`, `purpose` and `data_source.type`.
|
||||||
|
//!
|
||||||
|
//! Query parameters (all optional): `symbol`, `date_start` / `date_end`
|
||||||
|
//! (YYYY-MM-DD), `insider` (holder id), `action_type`, `source_type`, `page`
|
||||||
|
//! (from 1).
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use crate::movements::types::{
|
||||||
|
ActionFilter, MovementAction, MovementPage, MovementQuery, MovementSource, ShareholderMovement,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const PATH: &str = "/insider/company/majorholder";
|
||||||
|
|
||||||
|
/// Stockbit's `action_type` value for `filter`.
|
||||||
|
pub fn action_type(filter: ActionFilter) -> &'static str {
|
||||||
|
match filter {
|
||||||
|
ActionFilter::Buy => "ACTION_TYPE_BUY",
|
||||||
|
ActionFilter::Sell => "ACTION_TYPE_SELL",
|
||||||
|
ActionFilter::Transfer => "ACTION_TYPE_TRANSFER",
|
||||||
|
ActionFilter::Cross => "ACTION_TYPE_CROSS",
|
||||||
|
ActionFilter::CorporateAction => "ACTION_TYPE_CORPACTION",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stockbit's `source_type` value for `source`.
|
||||||
|
pub fn source_type(source: MovementSource) -> &'static str {
|
||||||
|
match source {
|
||||||
|
MovementSource::Idx => "SOURCE_TYPE_IDX",
|
||||||
|
MovementSource::Ksei => "SOURCE_TYPE_KSEI",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request path for `page` of `query`; `code` is the bare IDX code.
|
||||||
|
/// Every value is a date, a digit id, a code or a fixed enum name, so
|
||||||
|
/// nothing needs escaping.
|
||||||
|
pub fn request_path(query: &MovementQuery, code: Option<&str>, page: u32) -> String {
|
||||||
|
let mut path = format!("{PATH}?page={page}");
|
||||||
|
if let Some(code) = code {
|
||||||
|
path.push_str(&format!("&symbol={code}"));
|
||||||
|
}
|
||||||
|
if let Some(from) = query.from {
|
||||||
|
path.push_str(&format!("&date_start={from}"));
|
||||||
|
}
|
||||||
|
if let Some(to) = query.to {
|
||||||
|
path.push_str(&format!("&date_end={to}"));
|
||||||
|
}
|
||||||
|
if let Some(id) = &query.holder_id {
|
||||||
|
path.push_str(&format!("&insider={id}"));
|
||||||
|
}
|
||||||
|
if let Some(action) = query.action {
|
||||||
|
path.push_str(&format!("&action_type={}", action_type(action)));
|
||||||
|
}
|
||||||
|
if let Some(source) = query.source {
|
||||||
|
path.push_str(&format!("&source_type={}", source_type(source)));
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of the feed. A row without a symbol, holder or readable date is
|
||||||
|
/// skipped; a body without `data` is a parse error.
|
||||||
|
pub fn parse_movements(body: &str) -> Result<MovementPage, IdxError> {
|
||||||
|
let value: Value = serde_json::from_str(body)
|
||||||
|
.map_err(|e| IdxError::ParseError(format!("Stockbit majorholder: {e}")))?;
|
||||||
|
let data = value
|
||||||
|
.get("data")
|
||||||
|
.filter(|data| data.is_object())
|
||||||
|
.ok_or_else(|| IdxError::ParseError("Stockbit majorholder: no data".to_string()))?;
|
||||||
|
let movements = data
|
||||||
|
.get("movement")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|rows| rows.iter().filter_map(parse_row).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Ok(MovementPage {
|
||||||
|
movements,
|
||||||
|
more: data
|
||||||
|
.get("is_more")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_row(row: &Value) -> Option<ShareholderMovement> {
|
||||||
|
let symbol = text(row.get("symbol"))?.to_ascii_uppercase();
|
||||||
|
let holder = text(row.get("name"))?.to_string();
|
||||||
|
let date = NaiveDate::parse_from_str(text(row.get("date"))?, "%d %b %y").ok()?;
|
||||||
|
let shares = |key: &str| signed(row.get(key).and_then(|v| v.get("value"))).unwrap_or(0);
|
||||||
|
let bps = |key: &str| {
|
||||||
|
percent(row.get(key).and_then(|v| v.get("percentage")))
|
||||||
|
.map(|p| (p * 100.0).round() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
let raw = |key: &str| {
|
||||||
|
signed(
|
||||||
|
row.get(key)
|
||||||
|
.and_then(|v| v.get("raw"))
|
||||||
|
.and_then(|v| v.get("value")),
|
||||||
|
)
|
||||||
|
.filter(|n| *n != 0)
|
||||||
|
};
|
||||||
|
Some(ShareholderMovement {
|
||||||
|
symbol,
|
||||||
|
date,
|
||||||
|
holder,
|
||||||
|
holder_id: text(row.get("id")).unwrap_or_default().to_string(),
|
||||||
|
action: action(text(row.get("action_type")).unwrap_or_default()),
|
||||||
|
source: match text(row.get("data_source").and_then(|v| v.get("type"))) {
|
||||||
|
Some("SOURCE_TYPE_IDX") => Some(MovementSource::Idx),
|
||||||
|
Some("SOURCE_TYPE_KSEI") => Some(MovementSource::Ksei),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
locality: match text(row.get("nationality")) {
|
||||||
|
Some("NATIONALITY_TYPE_LOCAL") => Some("Local".to_string()),
|
||||||
|
Some("NATIONALITY_TYPE_FOREIGN") => Some("Foreign".to_string()),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
roles: row
|
||||||
|
.get("badges")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|badges| {
|
||||||
|
badges
|
||||||
|
.iter()
|
||||||
|
.filter_map(|b| text(Some(b)))
|
||||||
|
.map(role)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
shares_before: shares("previous"),
|
||||||
|
shares_after: shares("current"),
|
||||||
|
shares_change: shares("changes"),
|
||||||
|
percentage_before_bps: bps("previous"),
|
||||||
|
percentage_after_bps: bps("current"),
|
||||||
|
percentage_change_bps: bps("changes"),
|
||||||
|
price: raw("price"),
|
||||||
|
price_estimated: row
|
||||||
|
.get("is_estimated_price")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
value: raw("value"),
|
||||||
|
purpose: text(row.get("purpose"))
|
||||||
|
.filter(|p| *p != "-")
|
||||||
|
.map(str::to_string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action(raw: &str) -> MovementAction {
|
||||||
|
match raw.strip_prefix("ACTION_TYPE_").unwrap_or(raw) {
|
||||||
|
"BUY" => MovementAction::Buy,
|
||||||
|
"SELL" => MovementAction::Sell,
|
||||||
|
"TRANSFER" => MovementAction::Transfer,
|
||||||
|
"CROSS" => MovementAction::Cross,
|
||||||
|
"RIGHT_ISSUE" => MovementAction::RightsIssue,
|
||||||
|
"PRIVATE_PLACEMENT" => MovementAction::PrivatePlacement,
|
||||||
|
"TENDER_OFFER" => MovementAction::TenderOffer,
|
||||||
|
"BONUS_STOCK" => MovementAction::BonusStock,
|
||||||
|
"DIVIDEND_STOCK" => MovementAction::StockDividend,
|
||||||
|
"DIVIDEND_CASH" => MovementAction::CashDividend,
|
||||||
|
_ => MovementAction::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `SHAREHOLDER_BADGE_DIREKTUR` -> `director`; an unknown badge keeps its
|
||||||
|
/// own name, lowercased.
|
||||||
|
fn role(badge: &str) -> String {
|
||||||
|
match badge.strip_prefix("SHAREHOLDER_BADGE_").unwrap_or(badge) {
|
||||||
|
"DIREKTUR" => "director".to_string(),
|
||||||
|
"KOMISARIS" => "commissioner".to_string(),
|
||||||
|
other => other.to_ascii_lowercase(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(value: Option<&Value>) -> Option<&str> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `"+147,933"`, `"-10,000,000"`, `"6982"` or a JSON number.
|
||||||
|
fn signed(value: Option<&Value>) -> Option<i64> {
|
||||||
|
match value? {
|
||||||
|
Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f.round() as i64)),
|
||||||
|
Value::String(s) => s.trim().replace(',', "").parse().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `"+0.0001"`, `"-0.16"`, `"54.94"`.
|
||||||
|
fn percent(value: Option<&Value>) -> Option<f64> {
|
||||||
|
let n = match value? {
|
||||||
|
Value::Number(n) => n.as_f64()?,
|
||||||
|
Value::String(s) => s.trim().replace(',', "").parse().ok()?,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
n.is_finite().then_some(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
std::fs::read_to_string(format!(
|
||||||
|
"{}/tests/fixtures/stockbit_exodus_{name}.json",
|
||||||
|
env!("CARGO_MANIFEST_DIR")
|
||||||
|
))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insider_buy_reads_shares_price_and_roles() {
|
||||||
|
let page = parse_movements(&fixture("majorholder_bbca")).unwrap();
|
||||||
|
assert!(page.more);
|
||||||
|
let row = page
|
||||||
|
.movements
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.holder == "HENDRA TANUMIHARDJA")
|
||||||
|
.expect("BBCA director row");
|
||||||
|
assert_eq!(row.symbol, "BBCA");
|
||||||
|
assert_eq!(row.date, NaiveDate::from_ymd_opt(2026, 3, 25).unwrap());
|
||||||
|
assert_eq!(row.action, MovementAction::Buy);
|
||||||
|
assert_eq!(row.source, Some(MovementSource::Idx));
|
||||||
|
assert_eq!(row.roles, ["director"]);
|
||||||
|
assert_eq!(
|
||||||
|
(row.shares_before, row.shares_after, row.shares_change),
|
||||||
|
(193_206, 341_139, 147_933)
|
||||||
|
);
|
||||||
|
// 0.0003% of BBCA rounds to 0 basis points; the shares stay exact.
|
||||||
|
assert_eq!(row.percentage_after_bps, 0);
|
||||||
|
assert_eq!(row.price, Some(6982));
|
||||||
|
assert_eq!(row.value, Some(1_032_868_206));
|
||||||
|
assert!(!row.price_estimated);
|
||||||
|
assert_eq!(row.locality.as_deref(), Some("Local"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ksei_sale_is_negative_with_an_estimated_price() {
|
||||||
|
let page = parse_movements(&fixture("majorholder")).unwrap();
|
||||||
|
let row = page
|
||||||
|
.movements
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.holder == "RUDI R. SUTANTRA")
|
||||||
|
.expect("ATLA KSEI sale");
|
||||||
|
assert_eq!(row.action, MovementAction::Sell);
|
||||||
|
assert_eq!(row.source, Some(MovementSource::Ksei));
|
||||||
|
assert_eq!(row.holder_id, "1000004481");
|
||||||
|
assert_eq!(row.shares_change, -10_000_000);
|
||||||
|
assert_eq!(row.percentage_before_bps, 4081);
|
||||||
|
assert_eq!(row.percentage_change_bps, -16);
|
||||||
|
assert!(row.price_estimated);
|
||||||
|
assert!(row.roles.is_empty());
|
||||||
|
assert_eq!(row.purpose, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_labels_are_kept_not_dropped() {
|
||||||
|
let body = r#"{"data":{"is_more":false,"movement":[
|
||||||
|
{"id":"7","name":"X","symbol":"abcd","date":"02 Nov 26",
|
||||||
|
"action_type":"ACTION_TYPE_SOMETHING_NEW","badges":["SHAREHOLDER_BADGE_CEO"],
|
||||||
|
"data_source":{"type":"SOURCE_TYPE_NEW"},"purpose":"-",
|
||||||
|
"previous":{"value":"0","percentage":"0"},
|
||||||
|
"current":{"value":"1,000","percentage":"0.01"},
|
||||||
|
"changes":{"value":"+1,000","percentage":"+0.01"},
|
||||||
|
"price":{"raw":{"value":"0"}},"value":{"raw":{"value":"0"}}},
|
||||||
|
{"id":"8","name":"","symbol":"ABCD","date":"02 Nov 26"},
|
||||||
|
{"id":"9","name":"Y","symbol":"ABCD","date":"2026-11-02"}
|
||||||
|
]}}"#;
|
||||||
|
let page = parse_movements(body).unwrap();
|
||||||
|
assert!(!page.more);
|
||||||
|
assert_eq!(page.movements.len(), 1);
|
||||||
|
let row = &page.movements[0];
|
||||||
|
assert_eq!(row.symbol, "ABCD");
|
||||||
|
assert_eq!(row.action, MovementAction::Other);
|
||||||
|
assert_eq!(row.source, None);
|
||||||
|
assert_eq!(row.roles, ["ceo"]);
|
||||||
|
assert_eq!(
|
||||||
|
(row.price, row.value, row.purpose.as_deref()),
|
||||||
|
(None, None, None)
|
||||||
|
);
|
||||||
|
assert_eq!(row.percentage_after_bps, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_broken_bodies() {
|
||||||
|
let empty = parse_movements(r#"{"data":{"is_more":false,"movement":null}}"#).unwrap();
|
||||||
|
assert_eq!(empty, MovementPage::default());
|
||||||
|
assert!(parse_movements(r#"{"message":"unauthorized"}"#).is_err());
|
||||||
|
assert!(parse_movements("<html>").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_path_carries_every_filter() {
|
||||||
|
let mut query = MovementQuery::from_args(
|
||||||
|
None,
|
||||||
|
Some("2026-10-01"),
|
||||||
|
Some("2026-10-15"),
|
||||||
|
Some("14074"),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
query.action = Some(ActionFilter::CorporateAction);
|
||||||
|
query.source = Some(MovementSource::Ksei);
|
||||||
|
assert_eq!(
|
||||||
|
request_path(&query, Some("BBCA"), 2),
|
||||||
|
"/insider/company/majorholder?page=2&symbol=BBCA&date_start=2026-10-01\
|
||||||
|
&date_end=2026-10-15&insider=14074&action_type=ACTION_TYPE_CORPACTION\
|
||||||
|
&source_type=SOURCE_TYPE_KSEI"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
request_path(&MovementQuery::default(), None, 1),
|
||||||
|
"/insider/company/majorholder?page=1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -124,8 +124,8 @@ pub fn status_error(status: u16, resolved: &str) -> IdxError {
|
||||||
match status {
|
match status {
|
||||||
403 => IdxError::UpstreamBlocked(
|
403 => IdxError::UpstreamBlocked(
|
||||||
"stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \
|
"stockbit.com refused this client (HTTP 403 from its CDN); idx does not retry a \
|
||||||
block. Try again later or from another network; `--provider msn` serves quotes \
|
block. Try again later or from another network; the msn provider (`--provider msn`, \
|
||||||
without Stockbit"
|
or IDX_PROVIDER=msn for idx-mcp) serves quotes without Stockbit"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
),
|
),
|
||||||
404 => IdxError::SymbolNotFound(resolved.to_string()),
|
404 => IdxError::SymbolNotFound(resolved.to_string()),
|
||||||
|
|
@ -271,7 +271,7 @@ fn wib(local: NaiveDateTime) -> Option<DateTime<FixedOffset>> {
|
||||||
|
|
||||||
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
|
/// A JSON number or numeric string (`"+59.54"`, `"5175.000000"`); `"N/A"`,
|
||||||
/// `"NA"` and empty strings are absent.
|
/// `"NA"` and empty strings are absent.
|
||||||
fn number(value: &Value) -> Option<f64> {
|
pub(super) fn number(value: &Value) -> Option<f64> {
|
||||||
let n = match value {
|
let n = match value {
|
||||||
Value::Number(n) => n.as_f64()?,
|
Value::Number(n) => n.as_f64()?,
|
||||||
Value::String(s) => s.trim().parse::<f64>().ok()?,
|
Value::String(s) => s.trim().parse::<f64>().ok()?,
|
||||||
|
|
|
||||||
452
crates/idx-core/src/api/stockbit/signin.rs
Normal file
452
crates/idx-core/src/api/stockbit/signin.rs
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
//! One Stockbit sign-in for every frontend. `idx stockbit login` and the
|
||||||
|
//! `stockbit_login` MCP tool both start one here, and differ only in how
|
||||||
|
//! they wait: the CLI in the foreground, idx-mcp on a thread it polls.
|
||||||
|
//!
|
||||||
|
//! Two ways a session reaches idx, both ending in `accept`:
|
||||||
|
//! - **Browser** (`browser.rs`): idx opens a Chromium-family browser on
|
||||||
|
//! Stockbit's own sign-in page, in a profile of its own, and reads the
|
||||||
|
//! session cookie over DevTools. Nothing to copy; the default on a desktop.
|
||||||
|
//! - **Page** (`loopback.rs`): a page on 127.0.0.1 that takes the cookie from
|
||||||
|
//! the user's own browser (bookmarklet, clipboard or paste). For a shell
|
||||||
|
//! over SSH, a machine with no display, or no supported browser.
|
||||||
|
//!
|
||||||
|
//! `Method::Auto` picks the browser unless it would not reach the user, and
|
||||||
|
//! falls back to the page only when the browser cannot start. A sign-in that
|
||||||
|
//! started and then timed out or was closed is an error, not a fallback.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex, PoisonError};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::auth::SessionStatus;
|
||||||
|
use super::{browser, loopback};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// How long the browser window waits: the user signs in there, one-time code
|
||||||
|
/// and all.
|
||||||
|
pub const BROWSER_TIMEOUT: Duration = Duration::from_secs(15 * 60);
|
||||||
|
/// How long the page waits for a session from the user's browser.
|
||||||
|
pub const PAGE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||||
|
/// Port the page prefers, so an `ssh -L` forward stays the same across runs.
|
||||||
|
pub const PAGE_PORT: u16 = 7878;
|
||||||
|
/// How long `shutdown` waits for a browser to close.
|
||||||
|
const SHUTDOWN_GRACE: Duration = Duration::from_secs(6);
|
||||||
|
|
||||||
|
/// Which way to sign in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum Method {
|
||||||
|
/// The browser when it would reach the user, else the page.
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Browser,
|
||||||
|
Page,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Options {
|
||||||
|
pub method: Method,
|
||||||
|
/// The page's port; `None` prefers `PAGE_PORT` and takes any free one
|
||||||
|
/// when that is busy.
|
||||||
|
pub port: Option<u16>,
|
||||||
|
/// Open the page in the desktop's default browser, when this machine has
|
||||||
|
/// a desktop the user is at.
|
||||||
|
pub open_page: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a sign-in reaches the user.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "method", rename_all = "snake_case")]
|
||||||
|
pub enum Started {
|
||||||
|
/// A stored session already works; nothing was opened.
|
||||||
|
Existing,
|
||||||
|
/// A browser window on Stockbit's sign-in page: sign in there.
|
||||||
|
Browser { browser: String },
|
||||||
|
/// The page on 127.0.0.1.
|
||||||
|
Page {
|
||||||
|
url: String,
|
||||||
|
port: u16,
|
||||||
|
/// The `ssh -L` command that brings the page to another machine.
|
||||||
|
forward: String,
|
||||||
|
/// The shell is over SSH, so the page is on another machine than the
|
||||||
|
/// user's browser.
|
||||||
|
remote: bool,
|
||||||
|
/// Why the browser was not used, when `Auto` picked the page.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
reason: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sign-in that has started and not yet been waited on.
|
||||||
|
pub struct SignIn {
|
||||||
|
started: Started,
|
||||||
|
timeout: Duration,
|
||||||
|
waiting: Waiting,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Waiting {
|
||||||
|
Browser(browser::Window),
|
||||||
|
Page(loopback::LoginPage),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignIn {
|
||||||
|
/// Opens the browser window or the page, and returns once it is up.
|
||||||
|
pub fn start(options: &Options) -> Result<Self, IdxError> {
|
||||||
|
match options.method {
|
||||||
|
Method::Browser => Self::browser(),
|
||||||
|
Method::Page => Self::page(options, None),
|
||||||
|
Method::Auto => match no_local_browser() {
|
||||||
|
Some(reason) => Self::page(options, Some(reason.to_string())),
|
||||||
|
None => Self::browser().or_else(|err| {
|
||||||
|
Self::page(
|
||||||
|
options,
|
||||||
|
Some(format!("the browser sign-in could not start: {err}")),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn browser() -> Result<Self, IdxError> {
|
||||||
|
let profile = browser::profile_dir()?;
|
||||||
|
let browser = browser::Browser::find(&profile)?;
|
||||||
|
let window = browser.open(&profile)?;
|
||||||
|
Ok(Self {
|
||||||
|
started: Started::Browser {
|
||||||
|
browser: browser.name,
|
||||||
|
},
|
||||||
|
timeout: BROWSER_TIMEOUT,
|
||||||
|
waiting: Waiting::Browser(window),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn page(options: &Options, reason: Option<String>) -> Result<Self, IdxError> {
|
||||||
|
let page = match options.port {
|
||||||
|
Some(port) => loopback::LoginPage::bind(port)?,
|
||||||
|
None => {
|
||||||
|
loopback::LoginPage::bind(PAGE_PORT).or_else(|_| loopback::LoginPage::bind(0))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if options.open_page && no_local_browser().is_none() {
|
||||||
|
open_in_browser(&page.url());
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
started: Started::Page {
|
||||||
|
url: page.url(),
|
||||||
|
port: page.port(),
|
||||||
|
forward: loopback::ssh_forward(page.port()),
|
||||||
|
remote: over_ssh(),
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
timeout: PAGE_TIMEOUT,
|
||||||
|
waiting: Waiting::Page(page),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn started(&self) -> &Started {
|
||||||
|
&self.started
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long `wait` waits.
|
||||||
|
pub fn timeout(&self) -> Duration {
|
||||||
|
self.timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for the user to finish, and reports the session stored.
|
||||||
|
pub fn wait(self) -> Result<SessionStatus, IdxError> {
|
||||||
|
self.wait_until(&AtomicBool::new(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `wait`, ending early with the browser closed when `cancel` is set.
|
||||||
|
/// The page has nothing to close and stops with the process.
|
||||||
|
fn wait_until(self, cancel: &AtomicBool) -> Result<SessionStatus, IdxError> {
|
||||||
|
match self.waiting {
|
||||||
|
Waiting::Browser(window) => window.wait(self.timeout, cancel, &accept)?,
|
||||||
|
Waiting::Page(page) => page.wait(self.timeout, accept)?,
|
||||||
|
}
|
||||||
|
super::session_status()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks a session value (the `credentialStorage` cookie or a refresh
|
||||||
|
/// token) and stores it: the step every way in ends with.
|
||||||
|
pub fn accept(value: &str) -> Result<(), IdxError> {
|
||||||
|
super::login(value).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a browser opened here would not reach the user, if it would not.
|
||||||
|
fn no_local_browser() -> Option<&'static str> {
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
let display =
|
||||||
|
std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some();
|
||||||
|
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||||
|
let display = true;
|
||||||
|
blocker(over_ssh(), display)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocker(ssh: bool, display: bool) -> Option<&'static str> {
|
||||||
|
if ssh {
|
||||||
|
Some("this shell is over SSH, so a browser started here opens on the remote machine")
|
||||||
|
} else if !display {
|
||||||
|
Some("this machine has no display (DISPLAY and WAYLAND_DISPLAY are unset)")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn over_ssh() -> bool {
|
||||||
|
std::env::var_os("SSH_CONNECTION").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens `url` with the desktop's default browser, best effort.
|
||||||
|
fn open_in_browser(url: &str) {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let (program, args) = ("open", vec![url]);
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let (program, args) = ("cmd", vec!["/C", "start", "", url]);
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
let (program, args) = ("xdg-open", vec![url]);
|
||||||
|
let opened = std::process::Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.is_ok();
|
||||||
|
if !opened {
|
||||||
|
crate::runtime::warn(format!("could not open a browser; open {url} yourself"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a background sign-in is doing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum State {
|
||||||
|
Waiting,
|
||||||
|
SignedIn,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A background sign-in as `start_background` and `progress` report it.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Progress {
|
||||||
|
pub state: State,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub started: Started,
|
||||||
|
/// Seconds before a waiting sign-in gives up; 0 once it has ended.
|
||||||
|
pub expires_in_secs: u64,
|
||||||
|
/// True when `start_background` found this sign-in already waiting.
|
||||||
|
pub already_open: bool,
|
||||||
|
/// Why it failed: the error's code and message.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<ProgressError>,
|
||||||
|
/// The latest warning while it waits, such as Stockbit refusing the
|
||||||
|
/// session signed in so far; gone once it signs in.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub warning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ProgressError {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Slot {
|
||||||
|
id: u64,
|
||||||
|
started: Started,
|
||||||
|
opened: Instant,
|
||||||
|
timeout: Duration,
|
||||||
|
state: State,
|
||||||
|
error: Option<ProgressError>,
|
||||||
|
warning: Option<String>,
|
||||||
|
cancel: Arc<AtomicBool>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Slot {
|
||||||
|
fn progress(&self, already_open: bool) -> Progress {
|
||||||
|
let expires_in_secs = match self.state {
|
||||||
|
State::Waiting => self.timeout.saturating_sub(self.opened.elapsed()).as_secs(),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
Progress {
|
||||||
|
state: self.state,
|
||||||
|
started: self.started.clone(),
|
||||||
|
expires_in_secs,
|
||||||
|
already_open,
|
||||||
|
error: self.error.clone(),
|
||||||
|
warning: self.warning.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_waiting(&self) -> bool {
|
||||||
|
self.state == State::Waiting && self.opened.elapsed() < self.timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This process's background sign-in, if one has run: one at a time.
|
||||||
|
static SLOT: Mutex<Option<Slot>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// Starts a sign-in on a thread and returns at once, for a frontend that
|
||||||
|
/// polls (`progress`) instead of blocking.
|
||||||
|
///
|
||||||
|
/// A stored session that still works means nothing to sign in to; a sign-in
|
||||||
|
/// already waiting is returned again rather than opening a second one.
|
||||||
|
pub fn start_background(options: &Options) -> Result<Progress, IdxError> {
|
||||||
|
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||||
|
// Held while the sign-in starts, so two calls never open two windows.
|
||||||
|
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
if let Some(open) = slot.as_ref().filter(|open| open.is_waiting()) {
|
||||||
|
return Ok(open.progress(true));
|
||||||
|
}
|
||||||
|
if super::verify_session().is_ok() {
|
||||||
|
return Ok(Progress {
|
||||||
|
state: State::SignedIn,
|
||||||
|
started: Started::Existing,
|
||||||
|
expires_in_secs: 0,
|
||||||
|
already_open: false,
|
||||||
|
error: None,
|
||||||
|
warning: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let sign_in = SignIn::start(options)?;
|
||||||
|
let id = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
|
let mut open = Slot {
|
||||||
|
id,
|
||||||
|
started: sign_in.started().clone(),
|
||||||
|
opened: Instant::now(),
|
||||||
|
timeout: sign_in.timeout(),
|
||||||
|
state: State::Waiting,
|
||||||
|
error: None,
|
||||||
|
warning: None,
|
||||||
|
cancel: cancel.clone(),
|
||||||
|
thread: None,
|
||||||
|
};
|
||||||
|
let progress = open.progress(false);
|
||||||
|
open.thread = Some(std::thread::spawn(move || {
|
||||||
|
// Nobody reads a polling frontend's stderr, so what the wait warns
|
||||||
|
// about (a refused session) goes into the progress it polls.
|
||||||
|
let sink = crate::runtime::on_warning(move |message| {
|
||||||
|
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
if let Some(open) = slot.as_mut().filter(|open| open.id == id) {
|
||||||
|
open.warning = Some(message.to_string());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let result = sign_in.wait_until(&cancel);
|
||||||
|
drop(sink);
|
||||||
|
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
if let Some(open) = slot.as_mut().filter(|open| open.id == id) {
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
open.state = State::SignedIn;
|
||||||
|
open.warning = None;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
open.state = State::Failed;
|
||||||
|
open.error = Some(ProgressError {
|
||||||
|
code: err.code().to_string(),
|
||||||
|
message: err.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
*slot = Some(open);
|
||||||
|
Ok(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends a background sign-in, for a process about to exit: a thread does not
|
||||||
|
/// outlive `main`, so without this its browser window would stay open with
|
||||||
|
/// nothing reading it.
|
||||||
|
pub fn shutdown() {
|
||||||
|
let (thread, browser) = {
|
||||||
|
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
|
let Some(open) = slot.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
open.cancel.store(true, Ordering::Relaxed);
|
||||||
|
(
|
||||||
|
open.thread.take(),
|
||||||
|
matches!(open.started, Started::Browser { .. }),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let Some(thread) = thread.filter(|_| browser) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let deadline = Instant::now() + SHUTDOWN_GRACE;
|
||||||
|
while !thread.is_finished() && Instant::now() < deadline {
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The background sign-in this process ran last, if any.
|
||||||
|
pub fn progress() -> Option<Progress> {
|
||||||
|
SLOT.lock()
|
||||||
|
.unwrap_or_else(PoisonError::into_inner)
|
||||||
|
.as_ref()
|
||||||
|
.map(|open| open.progress(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The browser is skipped exactly when it would not reach the user.
|
||||||
|
#[test]
|
||||||
|
fn the_browser_is_skipped_over_ssh_and_without_a_display() {
|
||||||
|
assert_eq!(blocker(false, true), None);
|
||||||
|
assert!(blocker(true, true).unwrap().contains("SSH"));
|
||||||
|
assert!(blocker(false, false).unwrap().contains("no display"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frontends read the method from one field, whichever way was taken.
|
||||||
|
#[test]
|
||||||
|
fn progress_names_its_method_and_hides_what_it_does_not_have() {
|
||||||
|
let page = Progress {
|
||||||
|
state: State::Waiting,
|
||||||
|
started: Started::Page {
|
||||||
|
url: "http://127.0.0.1:7878/login/x".to_string(),
|
||||||
|
port: 7878,
|
||||||
|
forward: "ssh -L 7878:127.0.0.1:7878 me@host".to_string(),
|
||||||
|
remote: false,
|
||||||
|
reason: None,
|
||||||
|
},
|
||||||
|
expires_in_secs: 300,
|
||||||
|
already_open: false,
|
||||||
|
error: None,
|
||||||
|
warning: None,
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(&page).unwrap();
|
||||||
|
assert_eq!(value["state"], "waiting");
|
||||||
|
assert_eq!(value["method"], "page");
|
||||||
|
assert_eq!(value["port"], 7878);
|
||||||
|
assert!(
|
||||||
|
["reason", "error", "warning"]
|
||||||
|
.iter()
|
||||||
|
.all(|key| value.get(key).is_none())
|
||||||
|
);
|
||||||
|
|
||||||
|
let failed = Progress {
|
||||||
|
state: State::Failed,
|
||||||
|
started: Started::Browser {
|
||||||
|
browser: "Helium".to_string(),
|
||||||
|
},
|
||||||
|
expires_in_secs: 0,
|
||||||
|
already_open: false,
|
||||||
|
error: Some(ProgressError {
|
||||||
|
code: "IO".to_string(),
|
||||||
|
message: "the window was closed".to_string(),
|
||||||
|
}),
|
||||||
|
warning: Some("Stockbit refused the session".to_string()),
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(&failed).unwrap();
|
||||||
|
assert_eq!(value["method"], "browser");
|
||||||
|
assert_eq!(value["browser"], "Helium");
|
||||||
|
assert_eq!(value["error"]["code"], "IO");
|
||||||
|
assert_eq!(value["warning"], "Stockbit refused the session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,8 +65,10 @@ pub struct Quote {
|
||||||
/// Best offer in IDR (whole Rupiah), gated like `bid`.
|
/// Best offer in IDR (whole Rupiah), gated like `bid`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub offer: Option<i64>,
|
pub offer: Option<i64>,
|
||||||
/// The provider's own session state, verbatim (Stockbit
|
/// Stockbit's market state as its public page words it: `open` while
|
||||||
/// `market_hour.status`, e.g. `close`). Absent for providers without one.
|
/// the regular market trades, `close` otherwise (also the lunch break
|
||||||
|
/// and auctions). A logged-in quote maps its session state to the same
|
||||||
|
/// two words. Absent for other providers, or when the state is unknown.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub market_status: Option<String>,
|
pub market_status: Option<String>,
|
||||||
/// What the provider says about this symbol's session: suspended,
|
/// What the provider says about this symbol's session: suspended,
|
||||||
|
|
@ -94,6 +96,54 @@ pub struct Intraday {
|
||||||
pub points: Vec<IntradayPoint>,
|
pub points: Vec<IntradayPoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One price level of an order book.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BookLevel {
|
||||||
|
/// Limit price in IDR (whole Rupiah).
|
||||||
|
pub price: i64,
|
||||||
|
/// Size resting at this price, in shares (1 lot = 100 shares).
|
||||||
|
pub shares: u64,
|
||||||
|
/// Orders queued at this price.
|
||||||
|
pub orders: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The order book of one IDX stock from the user's Stockbit session, with
|
||||||
|
/// the session's figures and foreign flow. Outside trading it is the book as
|
||||||
|
/// the market left it; `market_status` says which.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OrderBook {
|
||||||
|
pub symbol: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Last price in IDR (whole Rupiah).
|
||||||
|
pub price: i64,
|
||||||
|
pub change: Option<i64>,
|
||||||
|
pub change_pct: Option<f64>,
|
||||||
|
pub prev_close: Option<i64>,
|
||||||
|
pub open: Option<i64>,
|
||||||
|
pub high: Option<i64>,
|
||||||
|
pub low: Option<i64>,
|
||||||
|
/// Session volume in shares.
|
||||||
|
pub volume: Option<u64>,
|
||||||
|
/// Session value traded in IDR.
|
||||||
|
pub value: Option<u64>,
|
||||||
|
/// Trades in the session.
|
||||||
|
pub frequency: Option<u64>,
|
||||||
|
/// Foreign buy, sell and net value over the session, IDR; `None` while
|
||||||
|
/// Stockbit marks them not current (in session they lag a day).
|
||||||
|
pub foreign_buy: Option<i64>,
|
||||||
|
pub foreign_sell: Option<i64>,
|
||||||
|
pub foreign_net: Option<i64>,
|
||||||
|
/// Best price first.
|
||||||
|
pub bids: Vec<BookLevel>,
|
||||||
|
/// Best price first.
|
||||||
|
pub offers: Vec<BookLevel>,
|
||||||
|
/// Stockbit's session state, verbatim (e.g. `STATE_NAME_SESSION_2`,
|
||||||
|
/// `STATE_NAME_BREAK`, `STATE_NAME_MARKET_CLOSED`).
|
||||||
|
pub market_status: Option<String>,
|
||||||
|
/// Stockbit's listing status when it is not active (e.g. suspended).
|
||||||
|
pub session_note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
/// OHLC candle data normalized from Yahoo Finance chart indicators.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Ohlc {
|
pub struct Ohlc {
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,14 @@ impl Idx {
|
||||||
crate::flow::Flow { idx: self }
|
crate::flow::Flow { idx: self }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn corporate(&self) -> crate::corporate::Corporate<'_> {
|
||||||
|
crate::corporate::Corporate { idx: self }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn movements(&self) -> crate::movements::Movements<'_> {
|
||||||
|
crate::movements::Movements { idx: self }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
pub fn financial(&self) -> crate::financial::Financial<'_> {
|
||||||
crate::financial::Financial { idx: self }
|
crate::financial::Financial { idx: self }
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +199,7 @@ where
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
if use_cache && let Some(stale) = cache.any::<T>(spec.bucket, spec.key)? {
|
||||||
runtime::warn(format!(
|
runtime::warn(format!(
|
||||||
"network failed, serving stale cache for {}",
|
"network failed ({err}), serving stale cache for {}",
|
||||||
spec.subject
|
spec.subject
|
||||||
));
|
));
|
||||||
return Ok(hit(stale, true));
|
return Ok(hit(stale, true));
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ pub enum HistoryProviderKind {
|
||||||
Auto,
|
Auto,
|
||||||
Yahoo,
|
Yahoo,
|
||||||
Msn,
|
Msn,
|
||||||
|
// Needs the user's Stockbit session (`idx stockbit login`).
|
||||||
|
Stockbit,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderKind {
|
impl ProviderKind {
|
||||||
|
|
@ -63,6 +65,7 @@ impl HistoryProviderKind {
|
||||||
Self::Auto => "auto",
|
Self::Auto => "auto",
|
||||||
Self::Yahoo => "yahoo",
|
Self::Yahoo => "yahoo",
|
||||||
Self::Msn => "msn",
|
Self::Msn => "msn",
|
||||||
|
Self::Stockbit => "stockbit",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,9 +76,11 @@ impl HistoryProviderKind {
|
||||||
Ok(Self::Yahoo)
|
Ok(Self::Yahoo)
|
||||||
} else if value.eq_ignore_ascii_case("msn") {
|
} else if value.eq_ignore_ascii_case("msn") {
|
||||||
Ok(Self::Msn)
|
Ok(Self::Msn)
|
||||||
|
} else if value.eq_ignore_ascii_case("stockbit") {
|
||||||
|
Ok(Self::Stockbit)
|
||||||
} else {
|
} else {
|
||||||
Err(IdxError::ConfigError(format!(
|
Err(IdxError::ConfigError(format!(
|
||||||
"invalid history provider '{value}' (expected auto, yahoo, or msn)"
|
"invalid history provider '{value}' (expected auto, yahoo, msn, or stockbit)"
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
crates/idx-core/src/corporate/mod.rs
Normal file
109
crates/idx-core/src/corporate/mod.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
//! Corporate actions (cash and stock dividends, bonus shares, rights issues,
|
||||||
|
//! warrants, splits, shareholders' meetings, public exposes, IPOs, tender
|
||||||
|
//! offers) from the user's Stockbit session: one stock's history, or the
|
||||||
|
//! market's calendar. Stockbit serves one kind per request, so a query costs
|
||||||
|
//! one request per kind (paced 1 s apart), each cached for 6 hours.
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use chrono::{NaiveDate, Utc};
|
||||||
|
|
||||||
|
use crate::api::{corporate_action_provider, stockbit, today_wib};
|
||||||
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use types::{ActionKind, CalendarRange, CorporateAction};
|
||||||
|
|
||||||
|
/// Announcements change a few times a day at most.
|
||||||
|
const TTL_SECS: u64 = 6 * 3600;
|
||||||
|
const BUCKET: &str = "stockbit-corpaction";
|
||||||
|
|
||||||
|
pub struct Corporate<'a> {
|
||||||
|
pub(crate) idx: &'a Idx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Corporate<'_> {
|
||||||
|
/// Today in WIB.
|
||||||
|
pub fn today(&self) -> NaiveDate {
|
||||||
|
today_wib()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One IDX stock's corporate actions of `kinds`, newest first.
|
||||||
|
pub fn actions(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
kinds: &[ActionKind],
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||||
|
let (resolved, _) = self.idx.idx_code(symbol, "corporate actions")?;
|
||||||
|
let mut fetched = self.fetch(kinds, Some(&resolved), policy)?;
|
||||||
|
fetched
|
||||||
|
.data
|
||||||
|
.sort_by(|a, b| b.date.cmp(&a.date).then(a.kind.cmp(&b.kind)));
|
||||||
|
Ok(fetched)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The market's corporate actions of `kinds` dated within `range` (see
|
||||||
|
/// `CorporateAction::date`), plus offers still open in it (see
|
||||||
|
/// `CalendarRange::includes`), oldest first.
|
||||||
|
pub fn calendar(
|
||||||
|
&self,
|
||||||
|
range: CalendarRange,
|
||||||
|
kinds: &[ActionKind],
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||||
|
let fetched = self.fetch(kinds, None, policy)?;
|
||||||
|
Ok(fetched.map(|mut actions| {
|
||||||
|
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
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every kind in turn, through the cache; the result is as old as its
|
||||||
|
/// oldest part and stale if any part is.
|
||||||
|
fn fetch(
|
||||||
|
&self,
|
||||||
|
kinds: &[ActionKind],
|
||||||
|
resolved: Option<&str>,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<Vec<CorporateAction>>, IdxError> {
|
||||||
|
if kinds.is_empty() {
|
||||||
|
return Err(IdxError::InvalidInput(
|
||||||
|
"name at least one kind of corporate action".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let provider = corporate_action_provider();
|
||||||
|
let subject = resolved.unwrap_or("corporate actions");
|
||||||
|
let mut merged = Fetched {
|
||||||
|
data: Vec::new(),
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
fetched_at: Utc::now(),
|
||||||
|
stale: false,
|
||||||
|
};
|
||||||
|
let mut seen = Vec::with_capacity(kinds.len());
|
||||||
|
for &kind in kinds {
|
||||||
|
if seen.contains(&kind) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.push(kind);
|
||||||
|
let key = format!("{}:{}", kind.as_str(), resolved.unwrap_or("market"));
|
||||||
|
let part = cached_fetch(
|
||||||
|
&self.idx.cache,
|
||||||
|
CacheSpec {
|
||||||
|
bucket: BUCKET,
|
||||||
|
key: &key,
|
||||||
|
ttl_secs: TTL_SECS,
|
||||||
|
subject,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
|| provider.corporate_actions(kind, resolved),
|
||||||
|
Ok,
|
||||||
|
)?;
|
||||||
|
merged.fetched_at = merged.fetched_at.min(part.fetched_at);
|
||||||
|
merged.stale |= part.stale;
|
||||||
|
merged.data.extend(part.data);
|
||||||
|
}
|
||||||
|
Ok(merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
419
crates/idx-core/src/corporate/types.rs
Normal file
419
crates/idx-core/src/corporate/types.rs
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
use chrono::{Duration, NaiveDate};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// The kinds of corporate action Stockbit lists, one request each. JSON
|
||||||
|
/// names them as `--kind` does (`as_str`), so an output kind can be passed
|
||||||
|
/// back in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum ActionKind {
|
||||||
|
/// Cash dividend.
|
||||||
|
Dividend,
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "clap",
|
||||||
|
value(name = "stock-dividend", alias = "stock_dividend")
|
||||||
|
)]
|
||||||
|
#[serde(alias = "stock_dividend")]
|
||||||
|
StockDividend,
|
||||||
|
/// Bonus shares.
|
||||||
|
Bonus,
|
||||||
|
/// Rights issue (HMETD).
|
||||||
|
Rights,
|
||||||
|
Warrant,
|
||||||
|
/// Stock split.
|
||||||
|
Split,
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "clap",
|
||||||
|
value(name = "reverse-split", alias = "reverse_split")
|
||||||
|
)]
|
||||||
|
#[serde(alias = "reverse_split")]
|
||||||
|
ReverseSplit,
|
||||||
|
/// Shareholders' meeting (RUPS).
|
||||||
|
Meeting,
|
||||||
|
/// Public expose.
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "clap",
|
||||||
|
value(name = "expose", alias = "public-expose", alias = "public_expose")
|
||||||
|
)]
|
||||||
|
#[serde(rename = "expose", alias = "public_expose")]
|
||||||
|
PublicExpose,
|
||||||
|
Ipo,
|
||||||
|
#[cfg_attr(
|
||||||
|
feature = "clap",
|
||||||
|
value(name = "tender", alias = "tender-offer", alias = "tender_offer")
|
||||||
|
)]
|
||||||
|
#[serde(rename = "tender", alias = "tender_offer")]
|
||||||
|
TenderOffer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionKind {
|
||||||
|
pub const ALL: [Self; 11] = [
|
||||||
|
Self::Dividend,
|
||||||
|
Self::StockDividend,
|
||||||
|
Self::Bonus,
|
||||||
|
Self::Rights,
|
||||||
|
Self::Warrant,
|
||||||
|
Self::Split,
|
||||||
|
Self::ReverseSplit,
|
||||||
|
Self::Meeting,
|
||||||
|
Self::PublicExpose,
|
||||||
|
Self::Ipo,
|
||||||
|
Self::TenderOffer,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// What one stock's history covers by default: everything but its IPO.
|
||||||
|
pub const STOCK_DEFAULT: [Self; 10] = [
|
||||||
|
Self::Dividend,
|
||||||
|
Self::StockDividend,
|
||||||
|
Self::Bonus,
|
||||||
|
Self::Rights,
|
||||||
|
Self::Warrant,
|
||||||
|
Self::Split,
|
||||||
|
Self::ReverseSplit,
|
||||||
|
Self::Meeting,
|
||||||
|
Self::PublicExpose,
|
||||||
|
Self::TenderOffer,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The CLI's name (`--kind`), also used in MCP inputs.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Dividend => "dividend",
|
||||||
|
Self::StockDividend => "stock-dividend",
|
||||||
|
Self::Bonus => "bonus",
|
||||||
|
Self::Rights => "rights",
|
||||||
|
Self::Warrant => "warrant",
|
||||||
|
Self::Split => "split",
|
||||||
|
Self::ReverseSplit => "reverse-split",
|
||||||
|
Self::Meeting => "meeting",
|
||||||
|
Self::PublicExpose => "expose",
|
||||||
|
Self::Ipo => "ipo",
|
||||||
|
Self::TenderOffer => "tender",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the action entitles holders on a cum date (and so moves the
|
||||||
|
/// price on the ex date).
|
||||||
|
pub fn is_entitlement(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Self::Dividend
|
||||||
|
| Self::StockDividend
|
||||||
|
| Self::Bonus
|
||||||
|
| Self::Rights
|
||||||
|
| Self::Split
|
||||||
|
| Self::ReverseSplit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for ActionKind {
|
||||||
|
type Err = IdxError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
let wanted = value.trim().to_ascii_lowercase().replace('_', "-");
|
||||||
|
// The names JSON carried before they matched `--kind`, which an
|
||||||
|
// agent may pass back from an earlier answer.
|
||||||
|
let wanted = match wanted.as_str() {
|
||||||
|
"public-expose" => "expose",
|
||||||
|
"tender-offer" => "tender",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
Self::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|kind| kind.as_str() == wanted)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect();
|
||||||
|
IdxError::InvalidInput(format!(
|
||||||
|
"invalid kind '{value}' (expected one of: {})",
|
||||||
|
names.join(", ")
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `old : new` as Stockbit states it: a 1:5 split turns 1 share into 5; a
|
||||||
|
/// 655:104 rights issue gives 104 rights per 655 shares held; a 6:4 bonus
|
||||||
|
/// gives 4 bonus shares per 6 held.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Ratio {
|
||||||
|
pub old: u64,
|
||||||
|
pub new: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inclusive dates.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct DateRange {
|
||||||
|
pub start: NaiveDate,
|
||||||
|
pub end: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One corporate action of one stock. Which fields are present depends on
|
||||||
|
/// `kind`; absent ones are left out of JSON.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CorporateAction {
|
||||||
|
/// `BBCA.JK`.
|
||||||
|
pub symbol: String,
|
||||||
|
pub kind: ActionKind,
|
||||||
|
/// Where the action sits on a calendar: the cum date for entitlements
|
||||||
|
/// (the last day to buy and be entitled), else the meeting or expose
|
||||||
|
/// day, the listing day, a warrant's first trading day, or a tender
|
||||||
|
/// offer's first day. An entitlement Stockbit gives no cum date for is
|
||||||
|
/// placed on its ex date, and `cum_date` is then absent.
|
||||||
|
pub date: NaiveDate,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cum_date: Option<NaiveDate>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ex_date: Option<NaiveDate>,
|
||||||
|
/// Recording date; for a meeting, the date that decides who may attend.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub record_date: Option<NaiveDate>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pay_date: Option<NaiveDate>,
|
||||||
|
/// 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;
|
||||||
|
/// absent for IDR, which every recorded dividend was.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub currency: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ratio: Option<Ratio>,
|
||||||
|
/// Stockbit's price adjustment factor: 5 for a 1:5 split, 1.6667 for a
|
||||||
|
/// 6:4 bonus.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub factor: Option<f64>,
|
||||||
|
/// Whole IDR: the rights exercise price, a warrant's exercise price, the
|
||||||
|
/// tender offer price or the final IPO price.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub price: Option<i64>,
|
||||||
|
/// When rights or warrants trade, a tender offer runs, or an IPO is
|
||||||
|
/// offered.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub period: Option<DateRange>,
|
||||||
|
/// When warrants can be exercised.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub exercise: Option<DateRange>,
|
||||||
|
/// Shares offered (IPO) or sought (tender offer).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub shares: Option<u64>,
|
||||||
|
/// Share of the company a tender offer seeks, basis points.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub percentage_bps: Option<i64>,
|
||||||
|
/// Meeting or expose time, as Stockbit writes it (WIB).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub time: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub venue: Option<String>,
|
||||||
|
/// The issuer's name, when Stockbit gives it (IPO, tender offer).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CorporateAction {
|
||||||
|
pub fn new(symbol: String, kind: ActionKind, date: NaiveDate) -> Self {
|
||||||
|
Self {
|
||||||
|
symbol,
|
||||||
|
kind,
|
||||||
|
date,
|
||||||
|
cum_date: None,
|
||||||
|
ex_date: None,
|
||||||
|
record_date: None,
|
||||||
|
pay_date: None,
|
||||||
|
cash_per_share: None,
|
||||||
|
currency: None,
|
||||||
|
ratio: None,
|
||||||
|
factor: None,
|
||||||
|
price: None,
|
||||||
|
period: None,
|
||||||
|
exercise: None,
|
||||||
|
shares: None,
|
||||||
|
percentage_bps: None,
|
||||||
|
time: None,
|
||||||
|
venue: None,
|
||||||
|
name: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default calendar span, days from today.
|
||||||
|
pub const CALENDAR_DEFAULT_DAYS: i64 = 30;
|
||||||
|
/// Longest calendar range, days.
|
||||||
|
pub const CALENDAR_MAX_DAYS: i64 = 366;
|
||||||
|
|
||||||
|
/// The dates a market-wide calendar covers, inclusive.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CalendarRange {
|
||||||
|
pub from: NaiveDate,
|
||||||
|
pub to: NaiveDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CalendarRange {
|
||||||
|
/// `from` defaults to today, `to` to 30 days after `from`; at most
|
||||||
|
/// `CALENDAR_MAX_DAYS` days, counting both ends.
|
||||||
|
pub fn from_args(
|
||||||
|
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"))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let from = from.map(|v| parse("from", v)).transpose()?.unwrap_or(today);
|
||||||
|
let to = match to {
|
||||||
|
Some(v) => parse("to", v)?,
|
||||||
|
None => from
|
||||||
|
.checked_add_signed(Duration::days(CALENDAR_DEFAULT_DAYS))
|
||||||
|
.ok_or_else(|| IdxError::InvalidInput(format!("from ({from}) is out of range")))?,
|
||||||
|
};
|
||||||
|
if to < from {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"to ({to}) is before from ({from})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if (to - from).num_days() >= CALENDAR_MAX_DAYS {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"a calendar covers at most {CALENDAR_MAX_DAYS} days"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Self { from, to })
|
||||||
|
}
|
||||||
|
|
||||||
|
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)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn day(s: &str) -> NaiveDate {
|
||||||
|
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");
|
||||||
|
let range = CalendarRange::from_args(None, None, today).unwrap();
|
||||||
|
assert_eq!((range.from, range.to), (today, day("2026-12-03")));
|
||||||
|
let range = CalendarRange::from_args(Some("2026-11-10"), None, today).unwrap();
|
||||||
|
assert_eq!(range.to, day("2026-12-10"));
|
||||||
|
// 366 days counting both ends is the most.
|
||||||
|
let range = CalendarRange::from_args(Some("2028-01-01"), Some("2028-12-31"), today);
|
||||||
|
assert!(range.is_ok());
|
||||||
|
for (from, to) in [
|
||||||
|
(Some("2026-11-10"), Some("2026-11-09")),
|
||||||
|
(Some("2026-01-01"), Some("2027-06-01")),
|
||||||
|
(Some("2028-01-01"), Some("2029-01-01")),
|
||||||
|
(Some("11/10/2026"), None),
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
CalendarRange::from_args(from, to, today),
|
||||||
|
Err(IdxError::InvalidInput(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last date there is: 30 days on is an error, not a panic.
|
||||||
|
#[test]
|
||||||
|
fn test_calendar_range_at_the_end_of_time() {
|
||||||
|
let last = NaiveDate::MAX.format("%Y-%m-%d").to_string();
|
||||||
|
let err = CalendarRange::from_args(Some(&last), None, day("2026-11-03")).unwrap_err();
|
||||||
|
assert!(err.to_string().contains("out of range"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_action_kind_names_round_trip() {
|
||||||
|
for kind in ActionKind::ALL {
|
||||||
|
assert_eq!(kind.as_str().parse::<ActionKind>().unwrap(), kind);
|
||||||
|
}
|
||||||
|
assert!("rups".parse::<ActionKind>().is_err());
|
||||||
|
assert_eq!(
|
||||||
|
"stock_dividend".parse::<ActionKind>().unwrap(),
|
||||||
|
ActionKind::StockDividend
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON's `kind` is the name `--kind` and MCP take.
|
||||||
|
#[test]
|
||||||
|
fn test_action_kind_json_is_the_input_name() {
|
||||||
|
for kind in ActionKind::ALL {
|
||||||
|
let json = serde_json::to_value(kind).unwrap();
|
||||||
|
assert_eq!(json, kind.as_str(), "{kind:?}");
|
||||||
|
assert_eq!(serde_json::from_value::<ActionKind>(json).unwrap(), kind);
|
||||||
|
}
|
||||||
|
// Entries cached before the names changed still read.
|
||||||
|
let old: ActionKind = serde_json::from_str("\"public_expose\"").unwrap();
|
||||||
|
assert_eq!(old, ActionKind::PublicExpose);
|
||||||
|
// And as input, so a kind copied from an older answer works.
|
||||||
|
assert_eq!("public_expose".parse::<ActionKind>().unwrap(), old);
|
||||||
|
assert_eq!(
|
||||||
|
"tender_offer".parse::<ActionKind>().unwrap(),
|
||||||
|
ActionKind::TenderOffer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ pub mod broker;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
mod client;
|
mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod corporate;
|
||||||
pub mod curl_impersonate;
|
pub mod curl_impersonate;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod financial;
|
pub mod financial;
|
||||||
|
|
@ -15,6 +16,7 @@ pub mod flow;
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
pub mod investor;
|
pub mod investor;
|
||||||
pub mod market;
|
pub mod market;
|
||||||
|
pub mod movements;
|
||||||
#[cfg(feature = "ownership")]
|
#[cfg(feature = "ownership")]
|
||||||
pub mod ownership;
|
pub mod ownership;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
|
|
@ -22,12 +24,40 @@ pub mod text;
|
||||||
pub use client::{CachePolicy, Fetched, Idx};
|
pub use client::{CachePolicy, Fetched, Idx};
|
||||||
|
|
||||||
pub mod runtime {
|
pub mod runtime {
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
static QUIET: AtomicBool = AtomicBool::new(false);
|
static QUIET: AtomicBool = AtomicBool::new(false);
|
||||||
static JSON: AtomicBool = AtomicBool::new(false);
|
static JSON: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
type Sink = Box<dyn Fn(&str)>;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends this thread's warnings to `sink` too, until the guard drops: a
|
||||||
|
/// frontend whose stderr nobody reads (idx-mcp) hands them on this way.
|
||||||
|
/// Warnings still go to stderr, and `set_quiet` does not silence `sink`.
|
||||||
|
pub fn on_warning(sink: impl Fn(&str) + 'static) -> WarningSink {
|
||||||
|
let previous = SINK.with(|slot| slot.replace(Some(Box::new(sink))));
|
||||||
|
WarningSink { previous }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restores the thread's previous sink when dropped.
|
||||||
|
#[must_use = "warnings reach the sink only while this guard lives"]
|
||||||
|
pub struct WarningSink {
|
||||||
|
previous: Option<Sink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for WarningSink {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let previous = self.previous.take();
|
||||||
|
SINK.with(|slot| *slot.borrow_mut() = previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_quiet(quiet: bool) {
|
pub fn set_quiet(quiet: bool) {
|
||||||
QUIET.store(quiet, Ordering::Relaxed);
|
QUIET.store(quiet, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +92,40 @@ pub mod runtime {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn warn(message: impl Display) {
|
pub fn warn(message: impl Display) {
|
||||||
|
let message = message.to_string();
|
||||||
|
SINK.with(|slot| {
|
||||||
|
if let Some(sink) = slot.borrow().as_ref() {
|
||||||
|
sink(&message);
|
||||||
|
}
|
||||||
|
});
|
||||||
emit("warning", message);
|
emit("warning", message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
/// A sink sees this thread's warnings while its guard lives, and an
|
||||||
|
/// inner one hands back to the outer when it drops.
|
||||||
|
#[test]
|
||||||
|
fn warnings_reach_the_sink_while_its_guard_lives() {
|
||||||
|
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let outer = {
|
||||||
|
let seen = seen.clone();
|
||||||
|
on_warning(move |m| seen.borrow_mut().push(format!("outer {m}")))
|
||||||
|
};
|
||||||
|
warn("one");
|
||||||
|
{
|
||||||
|
let seen = seen.clone();
|
||||||
|
let _inner = on_warning(move |m| seen.borrow_mut().push(format!("inner {m}")));
|
||||||
|
warn("two");
|
||||||
|
}
|
||||||
|
warn("three");
|
||||||
|
drop(outer);
|
||||||
|
warn("four");
|
||||||
|
info("not a warning");
|
||||||
|
assert_eq!(*seen.borrow(), ["outer one", "inner two", "outer three"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ use std::str::FromStr;
|
||||||
|
|
||||||
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
|
use chrono::{DateTime, Datelike, Duration, NaiveDate, NaiveTime, Utc, Weekday};
|
||||||
|
|
||||||
use crate::api::types::{Interval, Intraday, Ohlc, Period, Quote};
|
use crate::api::types::{Interval, Intraday, Ohlc, OrderBook, Period, Quote};
|
||||||
use crate::api::{history_provider, history_source, idx_offset, intraday_provider, stockbit};
|
use crate::api::{
|
||||||
|
history_provider, history_source, idx_offset, intraday_provider, orderbook_provider, stockbit,
|
||||||
|
};
|
||||||
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
use crate::config::{HistoryProviderKind, ProviderKind};
|
use crate::config::{HistoryProviderKind, ProviderKind};
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
|
@ -95,11 +97,20 @@ impl Market<'_> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the history source for an optional `--history-provider`
|
/// Resolves the history source for an optional `--history-provider`
|
||||||
/// override, without fetching.
|
/// override and the window asked for, without fetching.
|
||||||
pub fn history_source(&self, mode: Option<HistoryProviderKind>) -> HistorySource {
|
pub fn history_source(
|
||||||
|
&self,
|
||||||
|
mode: Option<HistoryProviderKind>,
|
||||||
|
period: &Period,
|
||||||
|
interval: &Interval,
|
||||||
|
) -> HistorySource {
|
||||||
let mode = mode.unwrap_or(self.idx.config.history_provider);
|
let mode = mode.unwrap_or(self.idx.config.history_provider);
|
||||||
let selected = self.idx.provider.kind();
|
let selected = self.idx.provider.kind();
|
||||||
let provider = history_source(selected, mode);
|
// Only `auto` under the stockbit provider looks for a session.
|
||||||
|
let session = mode == HistoryProviderKind::Auto
|
||||||
|
&& selected == ProviderKind::Stockbit
|
||||||
|
&& stockbit::has_session();
|
||||||
|
let provider = history_source(selected, mode, period, interval, session);
|
||||||
HistorySource {
|
HistorySource {
|
||||||
provider,
|
provider,
|
||||||
mode,
|
mode,
|
||||||
|
|
@ -116,11 +127,11 @@ impl Market<'_> {
|
||||||
policy: CachePolicy,
|
policy: CachePolicy,
|
||||||
) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
|
) -> Result<Fetched<Vec<Ohlc>>, IdxError> {
|
||||||
let idx = self.idx;
|
let idx = self.idx;
|
||||||
let (provider_kind, provider) =
|
|
||||||
history_provider(idx.provider.kind(), source.mode, idx.config.verbose)?;
|
|
||||||
let bucket = format!("{}-history", provider_kind.as_str());
|
|
||||||
let resolved = idx.resolve(symbol)?;
|
let resolved = idx.resolve(symbol)?;
|
||||||
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
|
let key = format!("{resolved}-{}-{}", period.as_str(), interval.as_str());
|
||||||
|
self.with_history_fallback(source, |source| {
|
||||||
|
let provider = history_provider(source.provider, idx.config.verbose);
|
||||||
|
let bucket = format!("{}-history", source.provider.as_str());
|
||||||
cached_fetch(
|
cached_fetch(
|
||||||
&idx.cache,
|
&idx.cache,
|
||||||
CacheSpec {
|
CacheSpec {
|
||||||
|
|
@ -128,12 +139,62 @@ impl Market<'_> {
|
||||||
key: &key,
|
key: &key,
|
||||||
ttl_secs: idx.config.quote_ttl,
|
ttl_secs: idx.config.quote_ttl,
|
||||||
subject: &resolved,
|
subject: &resolved,
|
||||||
source: provider_kind.as_str(),
|
source: source.provider.as_str(),
|
||||||
},
|
},
|
||||||
policy,
|
policy,
|
||||||
|| provider.history(&resolved, period, interval),
|
|| provider.history(&resolved, period, interval),
|
||||||
Ok,
|
Ok,
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `fetch` on `source`; when `auto` picked Stockbit because a
|
||||||
|
/// session file exists and Stockbit then refuses that session, runs it
|
||||||
|
/// again on Yahoo instead of failing (`has_session` sees only the file).
|
||||||
|
pub(crate) fn with_history_fallback<T>(
|
||||||
|
&self,
|
||||||
|
source: HistorySource,
|
||||||
|
fetch: impl Fn(HistorySource) -> Result<Fetched<T>, IdxError>,
|
||||||
|
) -> Result<Fetched<T>, IdxError> {
|
||||||
|
match fetch(source) {
|
||||||
|
Err(IdxError::AuthError(reason))
|
||||||
|
if source.mode == HistoryProviderKind::Auto
|
||||||
|
&& source.provider == ProviderKind::Stockbit =>
|
||||||
|
{
|
||||||
|
crate::runtime::warn(format!("{reason}; history falls back to yahoo meanwhile"));
|
||||||
|
fetch(HistorySource {
|
||||||
|
provider: ProviderKind::Yahoo,
|
||||||
|
is_fallback: self.idx.provider.kind() != ProviderKind::Yahoo,
|
||||||
|
..source
|
||||||
|
})
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The order book of one IDX stock from the user's Stockbit session,
|
||||||
|
/// whichever market provider is selected.
|
||||||
|
pub fn orderbook(
|
||||||
|
&self,
|
||||||
|
symbol: &str,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<OrderBook>, IdxError> {
|
||||||
|
let idx = self.idx;
|
||||||
|
let resolved = idx.resolve(symbol)?;
|
||||||
|
let provider = orderbook_provider();
|
||||||
|
cached_fetch(
|
||||||
|
&idx.cache,
|
||||||
|
CacheSpec {
|
||||||
|
bucket: "stockbit-orderbook",
|
||||||
|
key: &resolved,
|
||||||
|
ttl_secs: idx.config.quote_ttl.min(stockbit::CACHE_TTL_SECS),
|
||||||
|
subject: &resolved,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
|| provider.orderbook(&resolved),
|
||||||
|
Ok,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Screener results, ranked locally and truncated to `query.limit`.
|
/// Screener results, ranked locally and truncated to `query.limit`.
|
||||||
|
|
|
||||||
165
crates/idx-core/src/movements/mod.rs
Normal file
165
crates/idx-core/src/movements/mod.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
//! Shareholder movements from the user's Stockbit session: reported changes
|
||||||
|
//! in insiders' and major holders' stakes (IDX insider disclosures and KSEI's
|
||||||
|
//! 5% holder changes), one stock's or the market's, newest first. Each page
|
||||||
|
//! is 50 rows and one request (paced 1 s apart). A query's pages are read
|
||||||
|
//! together and cached together for an hour, so they come from one moment
|
||||||
|
//! of a feed that gains rows at the top.
|
||||||
|
//!
|
||||||
|
//! These are the reports as filed. Whether they predict anything is not
|
||||||
|
//! claimed here: share plans read as a cluster of buys at one price, and a
|
||||||
|
//! row appears days after the trade (see `docs/STOCKBIT.md`).
|
||||||
|
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::api::{ShareholderMovementProvider, shareholder_movement_provider, stockbit};
|
||||||
|
use crate::client::{CachePolicy, CacheSpec, Fetched, Idx, cached_fetch};
|
||||||
|
use crate::error::IdxError;
|
||||||
|
use crate::runtime;
|
||||||
|
use types::{MAX_PAGES, MovementList, MovementQuery};
|
||||||
|
|
||||||
|
/// The feed gains rows through the day; an hour keeps a session's repeated
|
||||||
|
/// queries cheap without hiding a day's filings for long.
|
||||||
|
const TTL_SECS: u64 = 3600;
|
||||||
|
const BUCKET: &str = "stockbit-movements";
|
||||||
|
|
||||||
|
pub struct Movements<'a> {
|
||||||
|
pub(crate) idx: &'a Idx,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Movements<'_> {
|
||||||
|
/// Up to `query.pages` pages of the feed (at most `MAX_PAGES`), newest
|
||||||
|
/// first; `more` says whether rows remain past them.
|
||||||
|
///
|
||||||
|
/// When a page after the first fails and no cached answer is at hand,
|
||||||
|
/// the rows read before it are returned with `incomplete` saying why,
|
||||||
|
/// and are not cached.
|
||||||
|
pub fn list(
|
||||||
|
&self,
|
||||||
|
query: &MovementQuery,
|
||||||
|
policy: CachePolicy,
|
||||||
|
) -> Result<Fetched<MovementList>, IdxError> {
|
||||||
|
let mut query = query.clone();
|
||||||
|
query.pages = query.pages.clamp(1, MAX_PAGES);
|
||||||
|
if let Some(symbol) = &query.symbol {
|
||||||
|
let (resolved, _) = self.idx.idx_code(symbol, "shareholder movements")?;
|
||||||
|
query.symbol = Some(resolved);
|
||||||
|
}
|
||||||
|
let provider = shareholder_movement_provider();
|
||||||
|
let subject = query
|
||||||
|
.symbol
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "shareholder movements".to_string());
|
||||||
|
let key = cache_key(&query);
|
||||||
|
let partial = RefCell::new(None);
|
||||||
|
let result = cached_fetch(
|
||||||
|
&self.idx.cache,
|
||||||
|
CacheSpec {
|
||||||
|
bucket: BUCKET,
|
||||||
|
key: &key,
|
||||||
|
ttl_secs: TTL_SECS,
|
||||||
|
subject: &subject,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
},
|
||||||
|
policy,
|
||||||
|
|| read_pages(provider.as_ref(), &query, &partial),
|
||||||
|
Ok,
|
||||||
|
);
|
||||||
|
match (result, partial.into_inner()) {
|
||||||
|
(Err(err), Some(mut list)) => {
|
||||||
|
runtime::warn(format!(
|
||||||
|
"{err}; showing the {} rows read before it",
|
||||||
|
list.movements.len()
|
||||||
|
));
|
||||||
|
list.incomplete = Some(err.to_string());
|
||||||
|
Ok(Fetched {
|
||||||
|
data: list,
|
||||||
|
source: stockbit::SOURCE,
|
||||||
|
fetched_at: Utc::now(),
|
||||||
|
stale: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
(result, _) => result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the query's pages in turn. A failure after the first page leaves
|
||||||
|
/// the rows read so far in `partial` and fails, so they are not cached.
|
||||||
|
fn read_pages(
|
||||||
|
provider: &dyn ShareholderMovementProvider,
|
||||||
|
query: &MovementQuery,
|
||||||
|
partial: &RefCell<Option<MovementList>>,
|
||||||
|
) -> Result<MovementList, IdxError> {
|
||||||
|
let mut list = MovementList::default();
|
||||||
|
for page in 1..=query.pages {
|
||||||
|
match provider.shareholder_movements(query, page) {
|
||||||
|
Ok(part) => {
|
||||||
|
list.movements.extend(part.movements);
|
||||||
|
list.more = part.more;
|
||||||
|
if !part.more {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) if page > 1 => {
|
||||||
|
list.more = true;
|
||||||
|
*partial.borrow_mut() = Some(list);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every filter and the page count, so no two queries share an entry.
|
||||||
|
fn cache_key(query: &MovementQuery) -> String {
|
||||||
|
let opt = |value: Option<String>| value.unwrap_or_else(|| "-".to_string());
|
||||||
|
format!(
|
||||||
|
"{}:{}:{}:{}:{}:{}:{}p",
|
||||||
|
query.symbol.as_deref().unwrap_or("market"),
|
||||||
|
opt(query.from.map(|d| d.to_string())),
|
||||||
|
opt(query.to.map(|d| d.to_string())),
|
||||||
|
query.holder_id.as_deref().unwrap_or("-"),
|
||||||
|
query.action.map(|a| a.as_str()).unwrap_or("-"),
|
||||||
|
query.source.map(|s| s.as_str()).unwrap_or("-"),
|
||||||
|
query.pages,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use types::{ActionFilter, MovementSource};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_keys_differ_by_every_filter() {
|
||||||
|
let base = MovementQuery::from_args(None, None, None, None, 1).unwrap();
|
||||||
|
let mut two = base.clone();
|
||||||
|
two.pages = 2;
|
||||||
|
let mut keys = vec![cache_key(&base), cache_key(&two)];
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.symbol = Some("BBCA.JK".to_string());
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.from = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.to = chrono::NaiveDate::from_ymd_opt(2026, 10, 1);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.holder_id = Some("14074".to_string());
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base.clone();
|
||||||
|
q.action = Some(ActionFilter::Sell);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let mut q = base;
|
||||||
|
q.source = Some(MovementSource::Ksei);
|
||||||
|
keys.push(cache_key(&q));
|
||||||
|
let unique: std::collections::HashSet<_> = keys.iter().collect();
|
||||||
|
assert_eq!(unique.len(), keys.len(), "{keys:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
339
crates/idx-core/src/movements/types.rs
Normal file
339
crates/idx-core/src/movements/types.rs
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
|
/// What a holder's row records, as Stockbit labels it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum MovementAction {
|
||||||
|
Buy,
|
||||||
|
Sell,
|
||||||
|
/// Shares moved between accounts or parties, not a market trade.
|
||||||
|
Transfer,
|
||||||
|
/// A crossing: a negotiated trade between two parties.
|
||||||
|
Cross,
|
||||||
|
RightsIssue,
|
||||||
|
PrivatePlacement,
|
||||||
|
TenderOffer,
|
||||||
|
BonusStock,
|
||||||
|
StockDividend,
|
||||||
|
CashDividend,
|
||||||
|
/// A label Stockbit added after this release.
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovementAction {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Buy => "buy",
|
||||||
|
Self::Sell => "sell",
|
||||||
|
Self::Transfer => "transfer",
|
||||||
|
Self::Cross => "cross",
|
||||||
|
Self::RightsIssue => "rights_issue",
|
||||||
|
Self::PrivatePlacement => "private_placement",
|
||||||
|
Self::TenderOffer => "tender_offer",
|
||||||
|
Self::BonusStock => "bonus_stock",
|
||||||
|
Self::StockDividend => "stock_dividend",
|
||||||
|
Self::CashDividend => "cash_dividend",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `filter` (Stockbit's `action_type`) selects this action.
|
||||||
|
pub fn matches(self, filter: ActionFilter) -> bool {
|
||||||
|
match filter {
|
||||||
|
ActionFilter::Buy => self == Self::Buy,
|
||||||
|
ActionFilter::Sell => self == Self::Sell,
|
||||||
|
ActionFilter::Transfer => self == Self::Transfer,
|
||||||
|
ActionFilter::Cross => self == Self::Cross,
|
||||||
|
ActionFilter::CorporateAction => matches!(
|
||||||
|
self,
|
||||||
|
Self::RightsIssue
|
||||||
|
| Self::PrivatePlacement
|
||||||
|
| Self::TenderOffer
|
||||||
|
| Self::BonusStock
|
||||||
|
| Self::StockDividend
|
||||||
|
| Self::CashDividend
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The action filters Stockbit's feed takes (`action_type`). Corporate
|
||||||
|
/// action covers rights issues, private placements and the like.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum ActionFilter {
|
||||||
|
Buy,
|
||||||
|
Sell,
|
||||||
|
Transfer,
|
||||||
|
Cross,
|
||||||
|
#[cfg_attr(feature = "clap", value(name = "corporate-action"))]
|
||||||
|
CorporateAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionFilter {
|
||||||
|
pub const ALL: [Self; 5] = [
|
||||||
|
Self::Buy,
|
||||||
|
Self::Sell,
|
||||||
|
Self::Transfer,
|
||||||
|
Self::Cross,
|
||||||
|
Self::CorporateAction,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The CLI's name (`--action`), also used in MCP inputs.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Buy => "buy",
|
||||||
|
Self::Sell => "sell",
|
||||||
|
Self::Transfer => "transfer",
|
||||||
|
Self::Cross => "cross",
|
||||||
|
Self::CorporateAction => "corporate-action",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for ActionFilter {
|
||||||
|
type Err = IdxError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
let wanted = value.trim().to_ascii_lowercase().replace('_', "-");
|
||||||
|
Self::ALL
|
||||||
|
.into_iter()
|
||||||
|
.find(|filter| filter.as_str() == wanted)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
IdxError::InvalidInput(format!(
|
||||||
|
"unknown action '{value}'; use one of: {}",
|
||||||
|
Self::ALL.map(Self::as_str).join(", ")
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who reported the change: IDX insider disclosures (directors,
|
||||||
|
/// commissioners and other insiders, any size), or KSEI's daily changes of
|
||||||
|
/// holders at or above 5%.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum MovementSource {
|
||||||
|
Idx,
|
||||||
|
Ksei,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovementSource {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Idx => "idx",
|
||||||
|
Self::Ksei => "ksei",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for MovementSource {
|
||||||
|
type Err = IdxError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"idx" => Ok(Self::Idx),
|
||||||
|
"ksei" => Ok(Self::Ksei),
|
||||||
|
_ => Err(IdxError::InvalidInput(format!(
|
||||||
|
"unknown source '{value}'; use idx or ksei"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One reported change in a holder's stake. Share counts are exact;
|
||||||
|
/// percentages are of the company's shares, in basis points as Stockbit
|
||||||
|
/// rounds them, so a small insider's stake can read 0.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ShareholderMovement {
|
||||||
|
pub symbol: String,
|
||||||
|
/// The day of the change (the trade or the register date).
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub holder: String,
|
||||||
|
/// Stockbit's id for the holder; `--holder` takes it.
|
||||||
|
pub holder_id: String,
|
||||||
|
pub action: MovementAction,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source: Option<MovementSource>,
|
||||||
|
/// `Local` or `Foreign`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub locality: Option<String>,
|
||||||
|
/// Insider roles at the company: `director`, `commissioner`.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
pub shares_before: i64,
|
||||||
|
pub shares_after: i64,
|
||||||
|
/// Signed: negative for a sale.
|
||||||
|
pub shares_change: i64,
|
||||||
|
pub percentage_before_bps: i64,
|
||||||
|
pub percentage_after_bps: i64,
|
||||||
|
pub percentage_change_bps: i64,
|
||||||
|
/// Whole IDR per share.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub price: Option<i64>,
|
||||||
|
/// The price is Stockbit's estimate (KSEI rows report no price).
|
||||||
|
pub price_estimated: bool,
|
||||||
|
/// Whole IDR: shares changed times price.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub value: Option<i64>,
|
||||||
|
/// The stated purpose, free text (`Investasi`, `Divestasi`, ...).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub purpose: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page of Stockbit's feed (50 rows), newest first.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MovementPage {
|
||||||
|
pub movements: Vec<ShareholderMovement>,
|
||||||
|
/// Whether a further page exists.
|
||||||
|
pub more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most pages one query reads (500 rows).
|
||||||
|
pub const MAX_PAGES: u32 = 10;
|
||||||
|
|
||||||
|
/// What to list. `symbol` is resolved (`BBCA.JK`) once it reaches a
|
||||||
|
/// provider; `None` is the whole market.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct MovementQuery {
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
pub from: Option<NaiveDate>,
|
||||||
|
pub to: Option<NaiveDate>,
|
||||||
|
pub holder_id: Option<String>,
|
||||||
|
pub action: Option<ActionFilter>,
|
||||||
|
pub source: Option<MovementSource>,
|
||||||
|
/// Pages to read, 1 to `MAX_PAGES`.
|
||||||
|
pub pages: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MovementQuery {
|
||||||
|
/// Validates user input: dates as YYYY-MM-DD with `from` not after
|
||||||
|
/// `to`, a numeric holder id, and 1 to `MAX_PAGES` pages.
|
||||||
|
pub fn from_args(
|
||||||
|
symbol: Option<&str>,
|
||||||
|
from: Option<&str>,
|
||||||
|
to: Option<&str>,
|
||||||
|
holder_id: Option<&str>,
|
||||||
|
pages: u32,
|
||||||
|
) -> 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"))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let from = from.map(|v| parse("from", v)).transpose()?;
|
||||||
|
let to = to.map(|v| parse("to", v)).transpose()?;
|
||||||
|
if let (Some(from), Some(to)) = (from, to)
|
||||||
|
&& to < from
|
||||||
|
{
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"to ({to}) is before from ({from})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let holder_id = holder_id.map(str::trim).filter(|id| !id.is_empty());
|
||||||
|
if let Some(id) = holder_id
|
||||||
|
&& !id.bytes().all(|b| b.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"holder '{id}' is not a holder id; ids are digits, shown in the HOLDER ID \
|
||||||
|
column and as holder_id in JSON"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !(1..=MAX_PAGES).contains(&pages) {
|
||||||
|
return Err(IdxError::InvalidInput(format!(
|
||||||
|
"pages must be 1 to {MAX_PAGES}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
symbol: symbol
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
holder_id: holder_id.map(str::to_string),
|
||||||
|
action: None,
|
||||||
|
source: None,
|
||||||
|
pages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `movement` passes the query's filters (what Stockbit applies
|
||||||
|
/// on its side; the mock provider uses it).
|
||||||
|
pub fn admits(&self, movement: &ShareholderMovement) -> bool {
|
||||||
|
self.from.is_none_or(|from| movement.date >= from)
|
||||||
|
&& self.to.is_none_or(|to| movement.date <= to)
|
||||||
|
&& self
|
||||||
|
.holder_id
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|id| &movement.holder_id == id)
|
||||||
|
&& self
|
||||||
|
.action
|
||||||
|
.is_none_or(|filter| movement.action.matches(filter))
|
||||||
|
&& self
|
||||||
|
.source
|
||||||
|
.is_none_or(|source| movement.source == Some(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rows a query read, newest first.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct MovementList {
|
||||||
|
pub movements: Vec<ShareholderMovement>,
|
||||||
|
/// More rows exist past the pages read (raise `--pages`, or narrow the
|
||||||
|
/// dates).
|
||||||
|
pub more: bool,
|
||||||
|
/// Why reading stopped before the pages asked for: a later page failed,
|
||||||
|
/// and `movements` holds the rows read before it. Absent when every page
|
||||||
|
/// asked for was read.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub incomplete: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_validates_dates_holder_and_pages() {
|
||||||
|
let query =
|
||||||
|
MovementQuery::from_args(Some(" bbca "), Some("2026-10-01"), None, Some("14074"), 2)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(query.symbol.as_deref(), Some("bbca"));
|
||||||
|
assert_eq!(query.from, NaiveDate::from_ymd_opt(2026, 10, 1));
|
||||||
|
assert_eq!(query.holder_id.as_deref(), Some("14074"));
|
||||||
|
|
||||||
|
for (from, to, holder, pages) in [
|
||||||
|
(Some("01-10-2026"), None, None, 1),
|
||||||
|
(Some("2026-10-02"), Some("2026-10-01"), None, 1),
|
||||||
|
(None, None, Some("RUDI"), 1),
|
||||||
|
(None, None, Some("1&page=9"), 1),
|
||||||
|
(None, None, None, 0),
|
||||||
|
(None, None, None, MAX_PAGES + 1),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
MovementQuery::from_args(None, from, to, holder, pages).is_err(),
|
||||||
|
"{from:?} {to:?} {holder:?} {pages}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn action_filter_names_round_trip() {
|
||||||
|
for filter in ActionFilter::ALL {
|
||||||
|
assert_eq!(filter.as_str().parse::<ActionFilter>().unwrap(), filter);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
"corporate_action".parse::<ActionFilter>().unwrap(),
|
||||||
|
ActionFilter::CorporateAction
|
||||||
|
);
|
||||||
|
assert!("dividend".parse::<ActionFilter>().is_err());
|
||||||
|
assert!(MovementAction::RightsIssue.matches(ActionFilter::CorporateAction));
|
||||||
|
assert!(!MovementAction::Transfer.matches(ActionFilter::CorporateAction));
|
||||||
|
}
|
||||||
|
}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1m.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"cagr":"", "change":-1225, "drawdown":"", "markingpoint":"", "percentage":"-19.14", "prices":[{"date":"1789491600000", "formatted_date":"2026-09-16", "xlabel":"", "value":"6325", "percentage":"-1.17", "change":-75, "open":"6350", "high":"6425", "low":"6325", "volume":"721574"}, {"date":"1789578000000", "formatted_date":"2026-09-17", "xlabel":"", "value":"6350", "percentage":"-0.78", "change":-50, "open":"6400", "high":"6400", "low":"6325", "volume":"618120"}, {"date":"1789664400000", "formatted_date":"2026-09-18", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6325", "high":"6350", "low":"6225", "volume":"1767537"}, {"date":"1789923600000", "formatted_date":"2026-09-21", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6300", "high":"6300", "low":"6225", "volume":"699141"}, {"date":"1790010000000", "formatted_date":"2026-09-22", "xlabel":"", "value":"6200", "percentage":"-3.12", "change":-200, "open":"6225", "high":"6250", "low":"6175", "volume":"932112"}, {"date":"1790096400000", "formatted_date":"2026-09-23", "xlabel":"", "value":"6300", "percentage":"-1.56", "change":-100, "open":"6200", "high":"6300", "low":"6200", "volume":"968261"}, {"date":"1790182800000", "formatted_date":"2026-09-24", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6250", "high":"6275", "low":"6225", "volume":"756486"}, {"date":"1790269200000", "formatted_date":"2026-09-25", "xlabel":"", "value":"6250", "percentage":"-2.34", "change":-150, "open":"6225", "high":"6275", "low":"6200", "volume":"894474"}, {"date":"1790528400000", "formatted_date":"2026-09-28", "xlabel":"", "value":"6225", "percentage":"-2.73", "change":-175, "open":"6225", "high":"6250", "low":"6175", "volume":"1121076"}, {"date":"1790614800000", "formatted_date":"2026-09-29", "xlabel":"", "value":"6150", "percentage":"-3.91", "change":-250, "open":"6150", "high":"6225", "low":"6125", "volume":"1497834"}, {"date":"1790701200000", "formatted_date":"2026-09-30", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6100", "high":"6150", "low":"6050", "volume":"2213357"}, {"date":"1790787600000", "formatted_date":"2026-10-01", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6050", "high":"6150", "low":"6000", "volume":"1180449"}, {"date":"1790874000000", "formatted_date":"2026-10-02", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6000", "high":"6150", "low":"5975", "volume":"1319203"}, {"date":"1791133200000", "formatted_date":"2026-10-05", "xlabel":"", "value":"6175", "percentage":"-3.52", "change":-225, "open":"6100", "high":"6175", "low":"6075", "volume":"899057"}, {"date":"1791219600000", "formatted_date":"2026-10-06", "xlabel":"", "value":"6100", "percentage":"-4.69", "change":-300, "open":"6175", "high":"6200", "low":"6100", "volume":"1521734"}, {"date":"1791306000000", "formatted_date":"2026-10-07", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6075", "high":"6100", "low":"5975", "volume":"1626908"}, {"date":"1791392400000", "formatted_date":"2026-10-08", "xlabel":"", "value":"6000", "percentage":"-6.25", "change":-400, "open":"6100", "high":"6150", "low":"6000", "volume":"1131403"}, {"date":"1791478800000", "formatted_date":"2026-10-09", "xlabel":"", "value":"6050", "percentage":"-5.47", "change":-350, "open":"6025", "high":"6075", "low":"5975", "volume":"870986"}, {"date":"1791738000000", "formatted_date":"2026-10-12", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"6050", "high":"6100", "low":"5950", "volume":"979651"}, {"date":"1791824400000", "formatted_date":"2026-10-13", "xlabel":"", "value":"5950", "percentage":"-7.03", "change":-450, "open":"5925", "high":"5950", "low":"5850", "volume":"1227169"}, {"date":"1791910800000", "formatted_date":"2026-10-14", "xlabel":"", "value":"6125", "percentage":"-4.30", "change":-275, "open":"6000", "high":"6125", "low":"5950", "volume":"1363031"}, {"date":"1791997200000", "formatted_date":"2026-10-15", "xlabel":"", "value":"6075", "percentage":"-5.08", "change":-325, "open":"6075", "high":"6125", "low":"6025", "volume":"1347632"}, {"date":"1792083600000", "formatted_date":"2026-10-16", "xlabel":"", "value":"5175", "percentage":"-19.14", "change":-1225, "open":"5825", "high":"5825", "low":"5175", "volume":"12410982"}], "timeframe":"1m", "xaxisopt":"", "previous":6400, "line_weight":2, "previous_timeframe_price":{"date":"", "formatted_date":"", "xlabel":"", "value":"", "percentage":"", "change":0, "open":"", "high":"", "low":"", "volume":""}, "chart_type":"PRICE_CHART_TYPE_CANDLE", "interval_in_minutes":0, "allowed_chart_type":["PRICE_CHART_TYPE_LINE", "PRICE_CHART_TYPE_CANDLE"], "max_candles":24}, "message":"Successfully retrieved company daily chart"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_chart_candle_bbca_1y.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_bonus.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_bonus.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"bonus":[{"company_id":"1000003000","company_symbol":"PPGL","corp_action_active":true,"sahambonus_id":"7732","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"6 : 4","stocksplit_created":"","stocksplit_cumdate":"2026-11-02","stocksplit_exdate":"2026-11-03","stocksplit_factor":"1.6667","stocksplit_new":"4","stocksplit_old":"6","stocksplit_paymentdate":"2026-11-25","stocksplit_recdate":"2026-11-04","event_note":""},{"company_id":"397","company_symbol":"SPMA","corp_action_active":false,"sahambonus_id":"7730","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"100 : 30","stocksplit_created":"","stocksplit_cumdate":"2026-07-08","stocksplit_exdate":"2026-07-09","stocksplit_factor":"1.3","stocksplit_new":"30","stocksplit_old":"100","stocksplit_paymentdate":"2026-07-30","stocksplit_recdate":"2026-07-10","event_note":""},{"company_id":"457","company_symbol":"WINS","corp_action_active":false,"sahambonus_id":"7731","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"71 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-05-25","stocksplit_exdate":"2026-05-26","stocksplit_factor":"1.0141","stocksplit_new":"1","stocksplit_old":"71","stocksplit_paymentdate":"2026-06-18","stocksplit_recdate":"2026-05-29","event_note":""},{"company_id":"1000004517","company_symbol":"MEJA","corp_action_active":false,"sahambonus_id":"7727","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"6 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-16","stocksplit_exdate":"2026-04-17","stocksplit_factor":"1.1667","stocksplit_new":"1","stocksplit_old":"6","stocksplit_paymentdate":"2026-05-08","stocksplit_recdate":"2026-04-20","event_note":""},{"company_id":"1000004196","company_symbol":"CNMA","corp_action_active":false,"sahambonus_id":"7728","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"50 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-14","stocksplit_exdate":"2026-04-15","stocksplit_factor":"1.02","stocksplit_new":"1","stocksplit_old":"50","stocksplit_paymentdate":"2026-04-28","stocksplit_recdate":"2026-04-16","event_note":""},{"company_id":"284","company_symbol":"MEGA","corp_action_active":false,"sahambonus_id":"7723","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"1 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-09","stocksplit_exdate":"2026-04-10","stocksplit_factor":"2","stocksplit_new":"1","stocksplit_old":"1","stocksplit_paymentdate":"2026-04-30","stocksplit_recdate":"2026-04-13","event_note":""},{"company_id":"1000003506","company_symbol":"WGSH","corp_action_active":false,"sahambonus_id":"7722","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"1 : 1","stocksplit_created":"","stocksplit_cumdate":"2026-04-02","stocksplit_exdate":"2026-04-06","stocksplit_factor":"2","stocksplit_new":"1","stocksplit_old":"1","stocksplit_paymentdate":"2026-04-24","stocksplit_recdate":"2026-04-07","event_note":""},{"company_id":"1000000034","company_symbol":"RISE","corp_action_active":false,"sahambonus_id":"7721","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"25 : 12","stocksplit_created":"","stocksplit_cumdate":"2026-01-19","stocksplit_exdate":"2026-01-20","stocksplit_factor":"1.48","stocksplit_new":"12","stocksplit_old":"25","stocksplit_paymentdate":"2026-02-09","stocksplit_recdate":"2026-01-21","event_note":""},{"company_id":"43","company_symbol":"ASRM","corp_action_active":false,"sahambonus_id":"7720","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"20 : 1","stocksplit_created":"","stocksplit_cumdate":"2025-11-12","stocksplit_exdate":"2025-11-13","stocksplit_factor":"1.0500","stocksplit_new":"1","stocksplit_old":"20","stocksplit_paymentdate":"2025-12-05","stocksplit_recdate":"2025-11-14","event_note":""},{"company_id":"397","company_symbol":"SPMA","corp_action_active":false,"sahambonus_id":"7719","sahambonus_iqp_id":"","sahambonus_lastupdate":"","sahambonus_new_price":0,"sahambonus_new_share":0,"sahambonus_ratio":"100 : 30","stocksplit_created":"","stocksplit_cumdate":"2025-11-07","stocksplit_exdate":"2025-11-10","stocksplit_factor":"1.3000","stocksplit_new":"30","stocksplit_old":"100","stocksplit_paymentdate":"2025-11-25","stocksplit_recdate":"2025-11-11","event_note":""}]},"message":"Successfully retrieved stock bonus data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend_bbca.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_dividend_bbca.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_ipo.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_ipo.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex_bbca.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_pubex_bbca.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"pubex":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2026-09-09","puexp_id":"156753","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"16:00:00","puexp_venue":"Public Expose Live 2026 yang tercantum pada website idx.co.id"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2025-09-11","puexp_id":"87667","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"16:00:00","puexp_venue":"Public Expose Live 2025 yang tercantum pada website idx.co.id"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2024-08-28","puexp_id":"36803","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:00:00","puexp_venue":"Public Expose Live 2024 Informasi Jadwal Pubex Live 2024 dan pendaftaran peserta di website www.idx.co.id"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2023-11-29","puexp_id":"20156","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Public Expose Live 2023"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2022-09-14","puexp_id":"19470","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"10:00:00","puexp_venue":"Public Expose Live 2022"},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"puexp_date":"2021-09-08","puexp_id":"965","puexp_iqp_id":"","puexp_lastupdate":"","puexp_time":"15:00:00","puexp_venue":"Public Expose Live 2021\t\t\t"}]},"message":"Successfully retrieved Public Expose data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_reversesplit.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_reversesplit.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"stock_reverse":[]},"message":"Successfully retrieved reverse split data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rightissue.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rightissue.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups_bbca.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_rups_bbca.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"rups":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2026-03-12","rups_id":"1460182","rups_time":"14:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia Jl. M.H. Thamrin No. 1 Jakarta 10310","rups_eligible_date":"2026-02-09","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2025-03-12","rups_id":"860258","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia, Jl. M.H. Thamrin No. 1, Jakarta, 10310","rups_eligible_date":"2025-02-07","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2024-03-14","rups_id":"385363","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA Grand Indonesia Lt 18.Jl. MH Thamrin No 1 Jakarta Selatan","rups_eligible_date":"2024-02-07","company_name":"","company_icon_url":""},{"company_id":"0","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2023-03-16","rups_id":"368621","rups_time":"09:30","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA","rups_eligible_date":"2023-02-13","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2022-03-17","rups_id":"365637","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia\nJl. M.H. Thamrin No. 1\nJakarta","rups_eligible_date":"2022-02-14","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2021-09-23","rups_id":"356628","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia\nJl. M.H. Thamrin No. 1\nJakarta","rups_eligible_date":"2021-08-27","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2021-03-29","rups_id":"314659","rups_time":"09:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA, Grand Indonesia, Lt 19. Jl. M.H Thamrin No 1. Jakarta\t\t\t\t ","rups_eligible_date":"2021-02-24","company_name":"","company_icon_url":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"rups_created":"","rups_datahash":"","rups_date":"2020-07-30","rups_id":"314133","rups_time":"10:00","rups_iqp_agenda":"","rups_iqp_id":"","rups_iqp_rec_dt":"","rups_iqp_remark":"","rups_iqp_result":"","rups_iqp_revised_date":"","rups_iqp_type":"","rups_venue":"Menara BCA Grand Indonesia, Lt. 19. Jl. M.H. Thamrin No. 1. Jakarta\t\t\t ","rups_eligible_date":"2020-07-03","company_name":"","company_icon_url":""}]},"message":"Successfully retrieved RUPS/Shareholders meeting data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stock_dividend.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stock_dividend.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"stock_dividend":[]},"message":"Successfully retrieved stock dividend data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"stocksplit":[{"company_id":"601","company_symbol":"PRDA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-12-17","stocksplit_exdate":"2026-12-18","stocksplit_factor":"5","stocksplit_id":"6100","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2026-12-21","event_note":""},{"company_id":"600","company_symbol":"PBSA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-10-13","stocksplit_exdate":"2026-10-14","stocksplit_factor":"2","stocksplit_id":"6099","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2026-10-15","event_note":""},{"company_id":"488","company_symbol":"MLPT","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-07-20","stocksplit_exdate":"2026-07-21","stocksplit_factor":"25","stocksplit_id":"6095","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"25","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 25","stocksplit_recdate":"2026-07-22","event_note":""},{"company_id":"1000003487","company_symbol":"RMKE","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-07-16","stocksplit_exdate":"2026-07-17","stocksplit_factor":"5","stocksplit_id":"6094","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2026-07-20","event_note":""},{"company_id":"354","company_symbol":"RAJA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-07-15","stocksplit_exdate":"2026-07-16","stocksplit_factor":"5","stocksplit_id":"6092","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2026-07-17","event_note":""},{"company_id":"1000004216","company_symbol":"CYBR","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-05-12","stocksplit_exdate":"2026-05-13","stocksplit_factor":"2","stocksplit_id":"6091","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2026-05-18","event_note":""},{"company_id":"1000004217","company_symbol":"CYBR-W","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-05-12","stocksplit_exdate":"2026-05-13","stocksplit_factor":"2","stocksplit_id":"6093","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2026-05-18","event_note":""},{"company_id":"143","company_symbol":"DSSA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2026-04-08","stocksplit_exdate":"2026-04-09","stocksplit_factor":"25","stocksplit_id":"6089","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"25","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 25","stocksplit_recdate":"2026-04-10","event_note":""}]},"message":"Successfully retrieved stock split data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit_bbca.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_stocksplit_bbca.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"stocksplit":[{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2021-10-12","stocksplit_exdate":"2021-10-13","stocksplit_factor":"5","stocksplit_id":"3091","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"5","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 5","stocksplit_recdate":"2021-10-14","event_note":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2008-01-25","stocksplit_exdate":"2008-01-28","stocksplit_factor":"2","stocksplit_id":"3","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2008-01-30","event_note":""},{"company_id":"54","company_symbol":"BBCA","corp_action_active":false,"stocksplit_created":"","stocksplit_cumdate":"2004-06-07","stocksplit_exdate":"2004-06-08","stocksplit_factor":"2","stocksplit_id":"1061","stocksplit_iqp_id":"","stocksplit_lastupdate":"","stocksplit_lock":0,"stocksplit_new":"2","stocksplit_new_price":0,"stocksplit_new_share":0,"stocksplit_old":"1","stocksplit_ratio":"1 : 2","stocksplit_recdate":"2004-06-10","event_note":""}]},"message":"Successfully retrieved stock split data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_tenderoffer.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_tenderoffer.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"data":{"tender":[{"company_id":"46","company_name":"Astra Otoparts Tbk.","company_symbol":"AUTO","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-11-11","tender_id":"434","tender_paydate":"2026-11-18","tender_percentage":"4.95","tender_price":"3600","tender_shares":"238442293","tender_start":"2026-10-13","event_note":"","tender_price_formatted":"3,600"},{"company_id":"1000003921","company_name":"Hassana Boga Sejahtera Tbk.","company_symbol":"NAYZ","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-11-03","tender_id":"433","tender_paydate":"2026-11-10","tender_percentage":"45.84","tender_price":"75","tender_shares":"1169011472","tender_start":"2026-10-05","event_note":"","tender_price_formatted":"75"},{"company_id":"1000004066","company_name":"Era Media Sejahtera Tbk.","company_symbol":"DOOH","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-11-03","tender_id":"432","tender_paydate":"2026-11-09","tender_percentage":"33.91","tender_price":"148","tender_shares":"2624548624","tender_start":"2026-10-05","event_note":"","tender_price_formatted":"148"},{"company_id":"409","company_name":"Solusi Tunas Pratama Tbk.","company_symbol":"SUPR","corp_action_active":true,"tender_created":"","tender_datahash":"","tender_end":"2026-10-21","tender_id":"431","tender_paydate":"2026-11-02","tender_percentage":"74.39","tender_price":"45000","tender_shares":"729102","tender_start":"2026-09-24","event_note":"","tender_price_formatted":"45,000"},{"company_id":"278","company_name":"Mitrabahtera Segara Sejati Tbk.","company_symbol":"MBSS","corp_action_active":false,"tender_created":"","tender_datahash":"","tender_end":"2026-10-08","tender_id":"430","tender_paydate":"2026-10-15","tender_percentage":"17.50","tender_price":"2915","tender_shares":"306259839","tender_start":"2026-09-09","event_note":"","tender_price_formatted":"2,915"},{"company_id":"194","company_name":"Inti Bangun Sejahtera Tbk.","company_symbol":"IBST","corp_action_active":false,"tender_created":"","tender_datahash":"","tender_end":"2026-10-02","tender_id":"429","tender_paydate":"2026-10-14","tender_percentage":"0.05","tender_price":"5400","tender_shares":"650832","tender_start":"2026-09-04","event_note":"","tender_price_formatted":"5,400"}]},"message":"Successfully retrieved tender offer data"}
|
||||||
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_warrant.json
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_corpaction_warrant.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt
vendored
Normal file
1
crates/idx-core/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
%7B%22state%22%3A%7B%22access%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-19T04%3A43%3A28Z%22%7D%2C%22refresh%22%3A%7B%22token%22%3A%22eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU%22%2C%22expired_at%22%3A%222026-10-25T04%3A43%3A28Z%22%7D%2C%22user%22%3A%7B%22username%22%3A%22demo%22%7D%7D%2C%22version%22%3A0%7D
|
||||||
13
crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json
vendored
Normal file
13
crates/idx-core/tests/fixtures/stockbit_exodus_login_refresh_constructed.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"message": "Successfully refresh token",
|
||||||
|
"data": {
|
||||||
|
"access": {
|
||||||
|
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InVzZSI6ImRlbW8iLCJzZXMiOiJzZXNzaW9uLTEiLCJkdmMiOiJkZXZpY2UtMSIsInVpZCI6MSwiY291IjoiSUQifSwiZXhwIjoxNzkyMzg1MDA4LCJpYXQiOjE3OTIyOTg2MDgsImlzcyI6IlNUT0NLQklUIiwianRpIjoidGVzdCIsIm5iZiI6MTc5MjI5ODYwOCwidmVyIjoidjEifQ.dGVzdC1zaWduYXR1cmU",
|
||||||
|
"expired_at": "2026-10-19T04:43:28Z"
|
||||||
|
},
|
||||||
|
"refresh": {
|
||||||
|
"token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3QiLCJ0eXAiOiJKV1QifQ.eyJkYXRhIjp7InR5cCI6InJlZnJlc2giLCJ1c2UiOiJkZW1vIiwic2VzIjoic2Vzc2lvbi0xIiwiZHZjIjoiZGV2aWNlLTEiLCJ1aWQiOjEsImNvdSI6IklEIn0sImV4cCI6MTc5MjkwMzQwOCwiaWF0IjoxNzkyMjk4NjA4LCJpc3MiOiJTVE9DS0JJVCIsImp0aSI6InRlc3QiLCJuYmYiOjE3OTIyOTg2MDgsInZlciI6InYxIn0.dGVzdC1zaWduYXR1cmU",
|
||||||
|
"expired_at": "2026-10-25T04:43:28Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
416
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json
vendored
Normal file
416
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder.json
vendored
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
{
|
||||||
|
"message": "Successfully majorholder data",
|
||||||
|
"data": {
|
||||||
|
"is_more": true,
|
||||||
|
"movement": [
|
||||||
|
{
|
||||||
|
"id": "1000006115",
|
||||||
|
"name": "OPTIMUS VISION GLOBAL",
|
||||||
|
"symbol": "PEGE",
|
||||||
|
"date": "02 Nov 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "249,112,774",
|
||||||
|
"percentage": "6.59",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "251,612,774",
|
||||||
|
"percentage": "6.66",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+2,500,000",
|
||||||
|
"percentage": "+0.07",
|
||||||
|
"formatted_value": "2,500,000"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_FOREIGN",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "120",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "120"
|
||||||
|
},
|
||||||
|
"formatted": "120"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "300000000"
|
||||||
|
},
|
||||||
|
"formatted": "300.00M"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "Pembelian",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000006115",
|
||||||
|
"name": "OPTIMUS VISION GLOBAL",
|
||||||
|
"symbol": "PEGE",
|
||||||
|
"date": "02 Nov 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "246,212,774",
|
||||||
|
"percentage": "6.52",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "249,112,774",
|
||||||
|
"percentage": "6.59",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+2,900,000",
|
||||||
|
"percentage": "+0.08",
|
||||||
|
"formatted_value": "2,900,000"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_FOREIGN",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "122",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "122"
|
||||||
|
},
|
||||||
|
"formatted": "122"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "353800000"
|
||||||
|
},
|
||||||
|
"formatted": "353.80M"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "Pembelian",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000006115",
|
||||||
|
"name": "OPTIMUS VISION GLOBAL",
|
||||||
|
"symbol": "PEGE",
|
||||||
|
"date": "02 Nov 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "242,512,774",
|
||||||
|
"percentage": "6.42",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "243,712,774",
|
||||||
|
"percentage": "6.45",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+1,200,000",
|
||||||
|
"percentage": "+0.03",
|
||||||
|
"formatted_value": "1,200,000"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_FOREIGN",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "123",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "123"
|
||||||
|
},
|
||||||
|
"formatted": "123"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "147600000"
|
||||||
|
},
|
||||||
|
"formatted": "147.60M"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "Pembelian",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000006115",
|
||||||
|
"name": "OPTIMUS VISION GLOBAL",
|
||||||
|
"symbol": "PEGE",
|
||||||
|
"date": "02 Nov 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "241,674,474",
|
||||||
|
"percentage": "6.40",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "242,512,774",
|
||||||
|
"percentage": "6.42",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+838,300",
|
||||||
|
"percentage": "+0.02",
|
||||||
|
"formatted_value": "838,300"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_FOREIGN",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "119",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "119"
|
||||||
|
},
|
||||||
|
"formatted": "119"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "99757700"
|
||||||
|
},
|
||||||
|
"formatted": "99.76M"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "Pembelian",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000006115",
|
||||||
|
"name": "OPTIMUS VISION GLOBAL",
|
||||||
|
"symbol": "PEGE",
|
||||||
|
"date": "02 Nov 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "241,650,974",
|
||||||
|
"percentage": "6.40",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "241,674,474",
|
||||||
|
"percentage": "6.40",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+23,500",
|
||||||
|
"percentage": "+0.0006",
|
||||||
|
"formatted_value": "23,500"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_FOREIGN",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "118",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "118"
|
||||||
|
},
|
||||||
|
"formatted": "118"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "2773000"
|
||||||
|
},
|
||||||
|
"formatted": "2.77M"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "Pembelian",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/PEGE.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000004481",
|
||||||
|
"name": "RUDI R. SUTANTRA",
|
||||||
|
"symbol": "ATLA",
|
||||||
|
"date": "30 Oct 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "2,530,254,200",
|
||||||
|
"percentage": "40.81",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "2,520,254,200",
|
||||||
|
"percentage": "40.65",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "-10,000,000",
|
||||||
|
"percentage": "-0.16",
|
||||||
|
"formatted_value": "10,000,000"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_SELL",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: KSEI",
|
||||||
|
"type": "SOURCE_TYPE_KSEI"
|
||||||
|
},
|
||||||
|
"price_formatted": "",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "AG",
|
||||||
|
"group": "BROKER_GROUP_FOREIGN"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "21"
|
||||||
|
},
|
||||||
|
"formatted": "21"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "210000000"
|
||||||
|
},
|
||||||
|
"formatted": "210.00M"
|
||||||
|
},
|
||||||
|
"is_estimated_price": true,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/ATLA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "12606",
|
||||||
|
"name": "NUSANTARA UTAMA INVESTAMA",
|
||||||
|
"symbol": "BUVA",
|
||||||
|
"date": "30 Oct 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "18,508,539,715",
|
||||||
|
"percentage": "62.51",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "18,508,539,715",
|
||||||
|
"percentage": "61.54",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "0",
|
||||||
|
"percentage": "-0.97",
|
||||||
|
"formatted_value": "0"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_RIGHT_ISSUE",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: KSEI",
|
||||||
|
"type": "SOURCE_TYPE_KSEI"
|
||||||
|
},
|
||||||
|
"price_formatted": "",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "YU",
|
||||||
|
"group": "BROKER_GROUP_FOREIGN"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "353"
|
||||||
|
},
|
||||||
|
"formatted": "353"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
"formatted": "-"
|
||||||
|
},
|
||||||
|
"is_estimated_price": true,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BUVA.png?version=1785918174174738481"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14074",
|
||||||
|
"name": "WELLY THOMAS",
|
||||||
|
"symbol": "SGER",
|
||||||
|
"date": "30 Oct 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "1,285,644,762",
|
||||||
|
"percentage": "8.25",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "1,341,269,756",
|
||||||
|
"percentage": "8.61",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+55,624,994",
|
||||||
|
"percentage": "+0.36",
|
||||||
|
"formatted_value": "55,624,994"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_TRANSFER",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: KSEI",
|
||||||
|
"type": "SOURCE_TYPE_KSEI"
|
||||||
|
},
|
||||||
|
"price_formatted": "",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "OD",
|
||||||
|
"group": "BROKER_GROUP_GOVERNMENT"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "794"
|
||||||
|
},
|
||||||
|
"formatted": "794"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "44166245236"
|
||||||
|
},
|
||||||
|
"formatted": "44.17B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": true,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/SGER.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
430
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json
vendored
Normal file
430
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca.json
vendored
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
{
|
||||||
|
"message": "Successfully majorholder data",
|
||||||
|
"data": {
|
||||||
|
"is_more": true,
|
||||||
|
"movement": [
|
||||||
|
{
|
||||||
|
"id": "1000000439",
|
||||||
|
"name": "HENDRA TANUMIHARDJA",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "25 Mar 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "193,206",
|
||||||
|
"percentage": "0.0002",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "341,139",
|
||||||
|
"percentage": "0.0003",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+147,933",
|
||||||
|
"percentage": "+0.0001",
|
||||||
|
"formatted_value": "147,933"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "6,982",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "6982"
|
||||||
|
},
|
||||||
|
"formatted": "6,982"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "1032868206"
|
||||||
|
},
|
||||||
|
"formatted": "1.03B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000000438",
|
||||||
|
"name": "A. WIDODO MULYONO",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "25 Mar 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "440,838",
|
||||||
|
"percentage": "0.0004",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "681,407",
|
||||||
|
"percentage": "0.0006",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+240,569",
|
||||||
|
"percentage": "+0.0002",
|
||||||
|
"formatted_value": "240,569"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "6,982",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "6982"
|
||||||
|
},
|
||||||
|
"formatted": "6,982"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "1679652758"
|
||||||
|
},
|
||||||
|
"formatted": "1.68B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000000437",
|
||||||
|
"name": "GREGORY HENDRA LEMBONG",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "25 Mar 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "1,531,282",
|
||||||
|
"percentage": "0.0012",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "2,666,921",
|
||||||
|
"percentage": "0.0022",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+1,135,639",
|
||||||
|
"percentage": "+0.0010",
|
||||||
|
"formatted_value": "1,135,639"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "6,982",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "6982"
|
||||||
|
},
|
||||||
|
"formatted": "6,982"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "7929031498"
|
||||||
|
},
|
||||||
|
"formatted": "7.93B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000000209",
|
||||||
|
"name": "SANTOSO",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "25 Mar 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "3,269,028",
|
||||||
|
"percentage": "0.0027",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "3,764,062",
|
||||||
|
"percentage": "0.0031",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+495,034",
|
||||||
|
"percentage": "+0.0004",
|
||||||
|
"formatted_value": "495,034"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "6,982",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "6982"
|
||||||
|
},
|
||||||
|
"formatted": "6,982"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "3456327388"
|
||||||
|
},
|
||||||
|
"formatted": "3.46B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14421",
|
||||||
|
"name": "TONNY KUSNADI",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "25 Mar 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "7,502,058",
|
||||||
|
"percentage": "0.0061",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "7,819,950",
|
||||||
|
"percentage": "0.0063",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+317,892",
|
||||||
|
"percentage": "+0.0002",
|
||||||
|
"formatted_value": "317,892"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "6,982",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_KOMISARIS"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "6982"
|
||||||
|
},
|
||||||
|
"formatted": "6,982"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "2219521944"
|
||||||
|
},
|
||||||
|
"formatted": "2.22B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14418",
|
||||||
|
"name": "FRENGKY CHANDRA KUSUMA",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "25 Mar 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "2,429,926",
|
||||||
|
"percentage": "0.0020",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "2,835,916",
|
||||||
|
"percentage": "0.0023",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+405,990",
|
||||||
|
"percentage": "+0.0003",
|
||||||
|
"formatted_value": "405,990"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "6,982",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "6982"
|
||||||
|
},
|
||||||
|
"formatted": "6,982"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "2834622180"
|
||||||
|
},
|
||||||
|
"formatted": "2.83B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14417",
|
||||||
|
"name": "HARYANTO TIARA BUDIMAN",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "25 Mar 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "1,057,378",
|
||||||
|
"percentage": "0.0009",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "1,436,806",
|
||||||
|
"percentage": "0.0012",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+379,428",
|
||||||
|
"percentage": "+0.0003",
|
||||||
|
"formatted_value": "379,428"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "6,982",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "6982"
|
||||||
|
},
|
||||||
|
"formatted": "6,982"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "2649166296"
|
||||||
|
},
|
||||||
|
"formatted": "2.65B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14416",
|
||||||
|
"name": "VERA EVE LIM",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "25 Mar 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "2,731,601",
|
||||||
|
"percentage": "0.0022",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "3,281,460",
|
||||||
|
"percentage": "0.0027",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+549,859",
|
||||||
|
"percentage": "+0.0005",
|
||||||
|
"formatted_value": "549,859"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "6,982",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "6982"
|
||||||
|
},
|
||||||
|
"formatted": "6,982"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "3839115538"
|
||||||
|
},
|
||||||
|
"formatted": "3.84B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
220
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json
vendored
Normal file
220
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_bbca_p2.json
vendored
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
{
|
||||||
|
"message": "Successfully majorholder data",
|
||||||
|
"data": {
|
||||||
|
"is_more": false,
|
||||||
|
"movement": [
|
||||||
|
{
|
||||||
|
"id": "14418",
|
||||||
|
"name": "FRENGKY CHANDRA KUSUMA",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "22 Mar 24",
|
||||||
|
"previous": {
|
||||||
|
"value": "1,891,049",
|
||||||
|
"percentage": "0.0015",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "2,107,984",
|
||||||
|
"percentage": "0.0017",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+216,935",
|
||||||
|
"percentage": "+0.0002",
|
||||||
|
"formatted_value": "216,935"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "10,108",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "10108"
|
||||||
|
},
|
||||||
|
"formatted": "10,108"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "2192778980"
|
||||||
|
},
|
||||||
|
"formatted": "2.19B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14417",
|
||||||
|
"name": "HARYANTO TIARA BUDIMAN",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "22 Mar 24",
|
||||||
|
"previous": {
|
||||||
|
"value": "561,695",
|
||||||
|
"percentage": "0.0005",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "776,099",
|
||||||
|
"percentage": "0.0006",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+214,404",
|
||||||
|
"percentage": "+0.0001",
|
||||||
|
"formatted_value": "214,404"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "10,108",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "10108"
|
||||||
|
},
|
||||||
|
"formatted": "10,108"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "2167195632"
|
||||||
|
},
|
||||||
|
"formatted": "2.17B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14416",
|
||||||
|
"name": "VERA EVE LIM",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "22 Mar 24",
|
||||||
|
"previous": {
|
||||||
|
"value": "1,912,261",
|
||||||
|
"percentage": "0.0016",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "2,212,324",
|
||||||
|
"percentage": "0.0018",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+300,063",
|
||||||
|
"percentage": "+0.0002",
|
||||||
|
"formatted_value": "300,063"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "10,108",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "10108"
|
||||||
|
},
|
||||||
|
"formatted": "10,108"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "3033036804"
|
||||||
|
},
|
||||||
|
"formatted": "3.03B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "14415",
|
||||||
|
"name": "LIANAWATY SUWONO",
|
||||||
|
"symbol": "BBCA",
|
||||||
|
"date": "22 Mar 24",
|
||||||
|
"previous": {
|
||||||
|
"value": "2,012,880",
|
||||||
|
"percentage": "0.0016",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "2,264,685",
|
||||||
|
"percentage": "0.0018",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+251,805",
|
||||||
|
"percentage": "+0.0002",
|
||||||
|
"formatted_value": "251,805"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_BUY",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "10,108",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [
|
||||||
|
"SHAREHOLDER_BADGE_DIREKTUR"
|
||||||
|
],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "10108"
|
||||||
|
},
|
||||||
|
"formatted": "10,108"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "2545244940"
|
||||||
|
},
|
||||||
|
"formatted": "2.55B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/BBCA.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
314
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json
vendored
Normal file
314
crates/idx-core/tests/fixtures/stockbit_exodus_majorholder_p2.json
vendored
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
{
|
||||||
|
"message": "Successfully majorholder data",
|
||||||
|
"data": {
|
||||||
|
"is_more": false,
|
||||||
|
"movement": [
|
||||||
|
{
|
||||||
|
"id": "13898",
|
||||||
|
"name": "CGS INTERNATIONAL SEKURITAS INDONESIA",
|
||||||
|
"symbol": "ENRG",
|
||||||
|
"date": "29 Oct 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "3,600,545,611",
|
||||||
|
"percentage": "9.04",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "3,100,545,614",
|
||||||
|
"percentage": "7.78",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "-499,999,997",
|
||||||
|
"percentage": "-1.25",
|
||||||
|
"formatted_value": "499,999,997"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_CROSS",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "715",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "715"
|
||||||
|
},
|
||||||
|
"formatted": "715"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "357499997855"
|
||||||
|
},
|
||||||
|
"formatted": "357.50B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "Reverse REPO",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/ENRG.png?version=1750036449822958153"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "12202",
|
||||||
|
"name": "MAYBANK SEKURITAS INDONESIA",
|
||||||
|
"symbol": "ENRG",
|
||||||
|
"date": "28 Oct 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "2,380,252,500",
|
||||||
|
"percentage": "5.97",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "2,442,752,500",
|
||||||
|
"percentage": "6.13",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+62,500,000",
|
||||||
|
"percentage": "+0.16",
|
||||||
|
"formatted_value": "62,500,000"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_CROSS",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "724"
|
||||||
|
},
|
||||||
|
"formatted": "724"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "45250000000"
|
||||||
|
},
|
||||||
|
"formatted": "45.25B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": true,
|
||||||
|
"purpose": "Penambahan jaminan saham untuk transaksi repo",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/ENRG.png?version=1750036449822958153"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000000448",
|
||||||
|
"name": "TRINUGRAHA THOHIR HARMONI",
|
||||||
|
"symbol": "VISI",
|
||||||
|
"date": "28 Oct 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "246,000,000",
|
||||||
|
"percentage": "7.70",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "270,144,900",
|
||||||
|
"percentage": "8.45",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+24,144,900",
|
||||||
|
"percentage": "+0.76",
|
||||||
|
"formatted_value": "24,144,900"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_PRIVATE_PLACEMENT",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "994",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "994"
|
||||||
|
},
|
||||||
|
"formatted": "994"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "24000030600"
|
||||||
|
},
|
||||||
|
"formatted": "24.00B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "PMTHMETD",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/VISI.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000005952",
|
||||||
|
"name": "WIBOWO GROUP CAPITAL",
|
||||||
|
"symbol": "MBSS",
|
||||||
|
"date": "15 Oct 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "1,443,766,800",
|
||||||
|
"percentage": "82.50",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "1,576,605,000",
|
||||||
|
"percentage": "90.09",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "+132,838,200",
|
||||||
|
"percentage": "+7.59",
|
||||||
|
"formatted_value": "132,838,200"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_TENDER_OFFER",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: IDX",
|
||||||
|
"type": "SOURCE_TYPE_IDX"
|
||||||
|
},
|
||||||
|
"price_formatted": "2,915",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "",
|
||||||
|
"group": "BROKER_GROUP_UNSPECIFIED"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "2915"
|
||||||
|
},
|
||||||
|
"formatted": "2,915"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "387223353000"
|
||||||
|
},
|
||||||
|
"formatted": "387.22B"
|
||||||
|
},
|
||||||
|
"is_estimated_price": false,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/MBSS.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000006017",
|
||||||
|
"name": "JEFRI NEDI",
|
||||||
|
"symbol": "FUJI",
|
||||||
|
"date": "15 Oct 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "109,431,600",
|
||||||
|
"percentage": "8.42",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "109,251,600",
|
||||||
|
"percentage": "8.40",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "-180,000",
|
||||||
|
"percentage": "-0.01",
|
||||||
|
"formatted_value": "180,000"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_SELL",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: KSEI",
|
||||||
|
"type": "SOURCE_TYPE_KSEI"
|
||||||
|
},
|
||||||
|
"price_formatted": "",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "CP",
|
||||||
|
"group": "BROKER_GROUP_FOREIGN"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "298"
|
||||||
|
},
|
||||||
|
"formatted": "298"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "53640000"
|
||||||
|
},
|
||||||
|
"formatted": "53.64M"
|
||||||
|
},
|
||||||
|
"is_estimated_price": true,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/FUJI.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1000004481",
|
||||||
|
"name": "RUDI R. SUTANTRA",
|
||||||
|
"symbol": "ATLA",
|
||||||
|
"date": "15 Oct 26",
|
||||||
|
"previous": {
|
||||||
|
"value": "2,638,254,200",
|
||||||
|
"percentage": "42.56",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"current": {
|
||||||
|
"value": "2,610,254,200",
|
||||||
|
"percentage": "42.10",
|
||||||
|
"formatted_value": ""
|
||||||
|
},
|
||||||
|
"changes": {
|
||||||
|
"value": "-28,000,000",
|
||||||
|
"percentage": "-0.45",
|
||||||
|
"formatted_value": "28,000,000"
|
||||||
|
},
|
||||||
|
"marker": "",
|
||||||
|
"is_posted": false,
|
||||||
|
"cmh_id": "0",
|
||||||
|
"nationality": "NATIONALITY_TYPE_LOCAL",
|
||||||
|
"action_type": "ACTION_TYPE_SELL",
|
||||||
|
"data_source": {
|
||||||
|
"label": "Sumber: KSEI",
|
||||||
|
"type": "SOURCE_TYPE_KSEI"
|
||||||
|
},
|
||||||
|
"price_formatted": "",
|
||||||
|
"broker_detail": {
|
||||||
|
"code": "AG",
|
||||||
|
"group": "BROKER_GROUP_FOREIGN"
|
||||||
|
},
|
||||||
|
"badges": [],
|
||||||
|
"price": {
|
||||||
|
"raw": {
|
||||||
|
"value": "29"
|
||||||
|
},
|
||||||
|
"formatted": "29"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"raw": {
|
||||||
|
"value": "812000000"
|
||||||
|
},
|
||||||
|
"formatted": "812.00M"
|
||||||
|
},
|
||||||
|
"is_estimated_price": true,
|
||||||
|
"purpose": "",
|
||||||
|
"icon_url": "https://assets.stockbit.com/logos/companies/ATLA.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue