Ownership data
Since March 2026 KSEI compiles, and IDX publishes monthly, the list of every
shareholder holding more than 1 % of a listed company ("Pemegang Saham di
Atas 1%"). idx ownership imports those files into a local SQLite database
and answers questions from it offline. One command reads another source:
idx ownership institutions asks MSN Money live for the institutions and funds
that hold a stock (see the last section).
KSEI also publishes, on its own site, a monthly composition of every
security's holders by investor type, local and foreign ("Kepemilikan Efek").
It names no holders; idx keeps it in separate tables and reads it with
idx ownership composition (see KSEI composition).
Source files
| period | format | where |
|---|---|---|
| 30 Oct 2026 onwards | XLSX, 13 columns (CONTROLLER_OR_AFFILIATE after INVESTOR_CLASSIFICATION) | idx.co.id, "Data Kepemilikan Saham" page, Media/.../*-satu-persen.xlsx |
| June to September 2026 | XLSX, 12 columns | same page |
| February to May 2026 | PDF announcement | idx.co.id announcements ("Pemegang Saham di atas 1% (KSEI)") |
Columns: DATE, SHARE_CODE, ISSUER_NAME, INVESTOR_NAME, INVESTOR_CLASSIFICATION
(INVESTOR_TYPE in the first releases), LOCAL_FOREIGN, NATIONALITY, DOMICILE, HOLDINGS_SCRIPLESS, HOLDINGS_SCRIP, TOTAL_HOLDING_SHARES, PERCENTAGE, and from
the register as of 30 Oct 2026 CONTROLLER_OR_AFFILIATE (CONTROLLER,
AFFILIATE OF THE CONTROLLER or blank) after INVESTOR_CLASSIFICATION.
Columns are found by name, so both layouts import; the "di Atas 5 %" and
investor-type workbooks on the same page lack required columns and are
rejected with a clear message. idx ownership discover --family lists them
all:
--family | title on the page | file | importable |
|---|---|---|---|
above1 (default) | Pemegang Saham di Atas 1% per 31 Agustus 2026 | idx.co.id Media/.../peng-YYYY-MM-NNNNN-satu-persen.xlsx | yes |
above5 | Pemegang Saham di Atas 5% per 23 September 2026 (a daily register: holders above 5 %, per account, with the day's change) | idx.co.id peng-YYYY-MM-DD-NNNNN-lima-persen.xlsx | no, listed only |
investor-type | Data Kepemilikan Saham Perusahaan Tercatat per Tipe Investor per 31 Agustus 2026 | idx.co.id peng-YYYY-MM-NNNNN-tipe.xlsx | no, listed only |
classification | Data Kepemilikan Saham Perusahaan Tercatat Berdasarkan Klasifikasi Investor per 31 Agustus 2026 | idx.co.id peng-YYYY-MM-NNNNN-klasifikasi.xlsx | no, listed only |
composition | Kepemilikan Efek 30 Oktober 2026 | ksei.co.id storage/Download/BalanceposEfekYYYYMMDD.zip | yes |
all | every family above |
idx ownership discover # the above-1 % register, every month listed
idx ownership discover --family all --limit 2 # every family, newest two each
idx ownership discover --family composition --limit 24 # two years of KSEI archives
--limit counts per family (0: all). Without it every idx.co.id file is
listed, as before, and one ksei.co.id page (eight months). Each entry carries
family and importable in JSON. A family whose source fails becomes a
warning while the others answer; the command fails only when every family
failed (with the JSON code blocked when Cloudflare refused idx.co.id).
Each row carries its own DATE. A file is filed under its most common date
(as_of); rows with another date are kept but left out of that release's
holdings (see Database).
Getting the files
idx.co.id is behind Cloudflare bot management, so a plain HTTP client gets a
challenge page. ownership import --latest|--all and ownership discover try,
in order:
- a browser-shaped request (Chrome user agent and headers);
- a
curl-impersonatebinary onPATH(curl_chrome131,curl_chrome124,curl_chrome116,curl-impersonate-chrome,curl-impersonate; override withIDX_CURL_IMPERSONATE=/path/to/binary); - the GitHub mirror (below): the same files, downloaded from the
repository's
ownership-datarelease and checked against the SHA-256 in itsindex.json. A warning says when the mirror was used.IDX_OWNERSHIP_MIRROR=<url>points at another mirror,offdisables it;IDX_OWNERSHIP_SOURCE=mirrorskips idx.co.id,idxnever uses the mirror.
--file <path> and --url <url> import one file directly; --url also
accepts file:// paths. idx ownership discover lists what is available,
where it comes from and which months are already imported.
When Cloudflare refuses idx.co.id, with a challenge ("Just a moment...",
response header cf-mitigated: challenge) or a block page ("Attention
Required! | Cloudflare"), and no curl-impersonate binary gets through, the
error has the JSON code blocked (exit 3, as any network failure) and names
the kind of refusal and the ways out: retry later or from another network,
the mirror, curl-impersonate, or a download in a browser plus
import --file. --latest, --all and discover still fall back to the
mirror, and the warning says which refusal it was. Any other failure (an
outage, a 404) keeps its HTTP status and is not called Cloudflare.
The mirror
.github/workflows/ownership.yml runs scripts/ownership_mirror.py at 08:17,
14:17 and 20:17 WIB. idx.co.id blocks cloud networks: GitHub-hosted runners
get a Cloudflare challenge even with a browser TLS fingerprint, and requests
from Cloudflare Workers are refused. The job therefore needs a runner on an
ordinary (home or office) connection:
- Repository → Settings → Actions → Runners → New self-hosted runner, and
follow the three commands on any always-on machine with Git,
ghand Python 3.10+ (Windows, macOS or Linux). Install it as a service. - Repository → Settings → Secrets and variables → Actions → Variables:
OWNERSHIP_RUNNER=self-hosted.
Without that variable the job runs on ubuntu-latest, cannot reach
idx.co.id, and only rebuilds the parts and the snapshot from the files
already mirrored (so a newer idx parser still reaches the web terminal and
idx ownership sync); it fails once the newest
register is 45 days old, so a stale mirror shows up as a red run. Each run of
the script:
- fetches the IDX page with curl_cffi
(browser TLS fingerprint), saves it, and parses it with
idx ownership discover(one parser for CLI and CI); when that fails it carries on with the months already mirrored, and fails the run if the index lists a month whose file is missing from the release (it would otherwise drop that month); - reuses files already mirrored, downloads new or re-published ones (a new IDX URL for the same month counts as re-published) and checks they are real XLSX/PDF files;
- imports every month, oldest first, into a fresh database with
idx ownership import --file; any parse failure fails the run, so a broken file never reaches the mirror; - exports each month as JSON parts of 1,000 rows (one row per holder and
issuer, the holder's accounts summed; fields as in
idx ownership ticker -o jsonplusaccountsandcontroller_or_affiliate) and writesindex.json.rowsis the number of exported rows (positions), which the Worker checks;linesis the register lines behind them:
{
"schema": "idx.ownership.mirror.v1",
"generated_at": "2026-11-07T01:17:40Z",
"source_page": "https://www.idx.co.id/id/perusahaan-tercatat/data-kepemilikan-saham/",
"releases": [
{ "as_of": "2026-09-30", "title": "Pemegang Saham di Atas 1% per 30 September 2026",
"format": "xlsx", "source_url": "https://www.idx.co.id/Media/.../peng-2026-09-00024-satu-persen.xlsx",
"file": "ksei-above1-2026-09-30.xlsx", "sha256": "804b3bb7…", "bytes": 563730,
"rows": 7151, "issuers": 961, "entities": 5145,
"lines": 7154, "positions": 7151, "other_date_rows": 0, "controller_column": false,
"parts": [ { "file": "ksei-above1-2026-09-30.part01.json", "sha256": "…", "rows": 1000 } ] }
]
}
- builds the prebuilt snapshot of the same database with the hidden
idx ownership build-snapshot:ownership-v{V}-{newest}-{sha12}.sqlite.gzandsnapshot-v{V}.json(see Snapshot and sync). It is published when its content digest differs from the published manifest's, also when idx.co.id was unreachable, so GitHub-hosted runners keep it current after a parser or schema change; the script refuses a file above 100 MiB; - uploads only what changed: raw files and parts, then
index.json, then the snapshot file; it checks GitHub's SHA-256 digest of the uploaded file and only then uploads the manifest. It deletes assets nothing references any more, keeping the manifests of the three newest schema versions, the file each names and, for one more run, the file the replaced manifest named. The release is a prerelease so it never becomes the repository's "latest" release.
The web terminal's Worker (web/src/ownership-sync.js) loads the parts into
D1 on its minute cron, newest month first, one part per minute, within a daily
rows-written budget (OWN_DAILY_WRITE_BUDGET, default 60,000 per UTC day,
inside D1's free tier of 100,000). It keeps OWN_KEEP_RELEASES months
(default 6). A month is published only after its row count matches the index;
a re-published IDX file (new SHA-256) is reloaded, and so is a month whose
parts changed in an index written by idx 0.8.2 or later (parts_key), so
every kept month reloads once after the mirror is rebuilt with summed
accounts. A reload first deletes the copy D1 holds, which costs as many writes
as loading it (about 21,600 each way for one month) and hides the month until
the new copy is in, so the budget charges deletes too and a reload starts
only when both fit in what is left of the day. With the default budget that
is one reload a day; months not reloaded yet stay readable as their older
copy, with a warning. /api/ownership/status shows progress. There is no
upload endpoint: the only input is the repository's own release.
Running the workflow by hand: Actions → "ownership data" → Run workflow
(tick "dry run" to parse without uploading). From any machine idx.co.id
accepts, with gh logged in:
pip install curl_cffi && python scripts/ownership_mirror.py --idx target/debug/idx --repo fzn0x/idx
(add --dry-run to change nothing).
KSEI composition (Kepemilikan Efek)
KSEI lists one archive per month at
<https://www.ksei.co.id/id/publikasi/data-dan-statistik/kepemilikan-efek>
(eight months per page, newest first; ?page=N for older ones), a few days
after the month's last trading day: BalanceposEfek<YYYYMMDD>.zip, about
125 KB, the date being that last trading day. The ZIP holds one
pipe-separated text file, Balancepos<YYYYMMDD>.txt, CRLF line endings:
Date|Code|Type|Sec. Num|Price|Local IS|Local CP|Local PF|Local IB|Local ID|Local MF|Local SC|Local FD|Local OT|Total|Foreign IS|Foreign CP|Foreign PF|Foreign IB|Foreign ID|Foreign MF|Foreign SC|Foreign FD|Foreign OT|Total
30-OCT-2026|BBCA|EQUITY|123275050000|4980|2310613490|752250260|302459300|517672400|12030567470|1072720030|119977978|72753960|15254200|17194269088|666444425|575351467|4718402998|2382605437|324800480|20393323810|457281188|320342605|5420869622|35259422032
One row per security: shares listed (Sec. Num), the price, and the shares
held by local and by foreign holders in nine investor types (IS insurance, CP
corporate, PF pension fund, IB financial institution, ID individual, MF
mutual fund, SC securities company, FD foundation, OT others), each side with
its total. idx stores the EQUITY rows (1,005 on 30 Oct 2026) and counts the
other types (bonds, sukuk, warrants, rights, funds, crowdfunding) without
storing them. The header must match exactly; another layout is refused, not
guessed at.
Scripless only. The buckets cover shares held in KSEI accounts. Shares
still held as certificates (scrip) are outside the archive: BBCA's rows add
up to 42.55 % of its listed shares on 30 Oct 2026, because its controlling
holder's 54.94 % stake is scrip. Percentages are therefore taken over the
scripless total, and scripless_pct (scripless shares over listed shares) is
always shown next to them. Never read the composition as a percent of listed
shares.
idx ownership composition BBCA # newest month, nine types, local vs foreign
idx ownership composition TLKM --as-of 2026-09-30
idx ownership import --latest --family composition # newest archive not imported yet
idx ownership import --all --family composition # every listed month (up to 10 pages)
idx ownership import --file BalanceposEfek20261030.zip # or the TXT inside
idx ownership import --url https://www.ksei.co.id/storage/Download/BalanceposEfek20261030.zip
ownership composition downloads the newest month on first use, and later
asks ksei.co.id again only once a newer month can exist (after the end of
the month following the newest imported one; the listing is cached 12 h).
--as-of downloads a listed month that is missing. --offline never
downloads. The envelope's provider is ksei when the call imported,
local-db otherwise, and a warning names what was imported. The report gives
the stock's local and foreign shares and percents, the nine types in KSEI's
order, and every imported month with the foreign percent's change in
percentage points (idx.ownership.composition.v1). -o csv writes one row
per investor type (symbol, as_of, code, investor_type, local_shares, foreign_shares, local_pct, foreign_pct) however many months are imported;
the history is in the table and the JSON.
Composition imports never touch the register: releases, ticker,
entity, changes and foreign read exactly what they read before, and a
composition month and a register month can share a date. In an import report
a composition month has family: "composition", rows and issuers = the
stocks stored, entities 0, other_securities the rows of other types, and
positions, other_date_rows, controller_column null.
Archives are read in memory, never unpacked to disk. A ZIP is refused when an
entry's path leaves the archive (absolute, a drive, ..), when it holds more
than 16 entries, or when its text file unpacks to more than 64 MiB; a
download stops at 32 MiB. Rows with a bad cell (a date, a count with
separators, a non-positive Sec. Num) are skipped with a warning naming the
line; a row whose types do not add up to KSEI's total keeps the total, with a
warning.
ksei.co.id has no bot protection (nginx and S3, any user agent). The web terminal does not carry the composition yet: a Worker has not been checked against ksei.co.id, so the tool is CLI and MCP only.
Snapshot and sync
idx ownership sync fills or refreshes the local database with one download:
a prebuilt SQLite file CI builds from the mirrored IDX files, instead of
downloading and parsing every month.
idx ownership sync # install the snapshot, then any newer month
idx ownership sync --force # replace a database holding months the snapshot lacks
idx ownership status # local database, installed snapshot, mirror, what sync would do
idx --offline ownership status # the local part only
The mirror release carries, per database schema version V (PRAGMA user_version, 4 today):
| asset | content |
|---|---|
snapshot-v{V}.json | the manifest, schema idx.ownership.snapshot.v1, uploaded last |
ownership-v{V}-{newest as_of}-{sha12}.sqlite.gz | the database, vacuumed and gzipped; sha12 is the start of its SHA-256, so a published file never changes |
{
"schema": "idx.ownership.snapshot.v1",
"generated_at": "2026-11-06T03:10:55Z",
"built_by": "idx 0.8.2",
"db_schema_version": 4,
"file": "ownership-v4-2026-09-30-d4b3c324d27f.sqlite.gz",
"compression": "gzip",
"bytes": 6736237,
"sha256": "d4b3c324d27f…",
"sqlite_bytes": 20283392,
"sqlite_sha256": "…",
"content_sha256": "…",
"tables": { "aliases": 6293, "composition": 0, "composition_releases": 0, "entities": 5740, "holding_lines": 57505, "holdings": 57451, "releases": 8, "resolutions": 0 },
"releases": [
{ "as_of": "2026-09-30", "format": "xlsx", "sha256": "804b3bb7…",
"source_url": "https://www.idx.co.id/Media/yqjhhsee/peng-2026-09-00024-satu-persen.xlsx",
"rows": 7154, "holdings": 7151, "issuers": 961, "entities": 5145 }
]
}
sha256 and bytes describe the .sqlite.gz, sqlite_sha256 and
sqlite_bytes the decompressed file, releases[].sha256 the IDX file each
month came from (the same as in index.json), rows its register lines and
holdings the positions stored. content_sha256 is a digest of every table
with import times left out: two builds of the same files by the same parser
give the same digest, so CI republishes only when the data, the parser or the
schema changed. For the 8 months from February to September 2026 the file is
20.3 MB, 6.7 MB gzipped (the register lines and the summed positions are both
stored).
What sync does, first match wins:
| local database | action |
|---|---|
written by a newer idx (schema above V) | refused (exit 6) unless --force |
the mirror has no snapshot-v{V}.json, or one of a kind this idx cannot read (a later manifest schema or compression; a warning says so) | import the monthly files it lacks (imported) |
| missing or without releases | install the snapshot (installed) |
has months neither the snapshot nor index.json has, a month re-imported from another file since the install, or rows in a table the snapshot leaves empty | keep it, import the months it lacks (imported or up_to_date, the months in kept); --force replaces it (updated, the months in dropped) |
| installed from this very snapshot and still holding its months | nothing (up_to_date) |
| anything else: the snapshot has more months, a re-published file, a parser fix or a new schema, or the database was built by local imports | replace it with the snapshot (updated) |
After installing, sync imports any month index.json lists that the
snapshot does not have yet (a snapshot built before the newest file, or an
older idx installing a frozen older version), from the raw files with their
SHA-256 checked. It also imports again a month the database holds exactly as
its snapshot carried it while index.json lists another file for it (IDX
re-published the month after that snapshot was built); status lists those
in outdated. A month imported locally from another file is never touched.
Files are compared by the SHA-256 of the IDX file, never the SQLite files
themselves: idx rewrites a database's header when it opens it.
Safety:
- Nothing is written before every check passed: the manifest (schema,
version, file name, sizes, hashes, at most 1 MiB), the download (at most
bytes, then size and SHA-256), the decompressed file (size, SHA-256,PRAGMA quick_check, schema version, page size, rows per table, each release's date, file hash, lines and positions, and the recorded content digest). Its schema must be exactly this idx's tables and indexes plussnapshot_info: a view, a trigger or any other object is refused, so nothing in a download runs in the database later. A failed check is a provider error (exit 3) and leaves the database untouched. - The download URL is always the mirror's base plus the manifest's
file, a bare asset name: a manifest cannot point anywhere else. - Replacing a database first copies it to
<database>.bak(overwriting an older one), then copies the snapshot in with SQLite's online backup API: one write transaction into the live file. Other idx processes reading it (anidx mcpcall) keep their view until they finish; nothing is renamed or deleted under them. - One sync of a database runs at a time: parallel calls of one
idx mcpwait for each other, and other idx processes wait on an exclusive SQLite lock on<database>.sync.lock, an empty file left in place. A sync that waited finds the database up to date instead of installing it twice. - The installed file keeps the snapshot's
snapshot_infotable (generated_at, built_by, content digest, the months and file hashes it held), which is howsyncandstatusrecognise it later. Queries ignore it.
--offline makes sync fail with exit 5 and status report the local part
(exit 0, remote: null, a warning). IDX_OWNERSHIP_MIRROR=<url> points both
at another mirror (file:// works), off disables them (sync exit 6).
IDX_OWNERSHIP_SOURCE does not apply: sync is a mirror operation. In MCP the
same is one tool, idx_ownership_snapshot, with action: "sync" or
"status" and force.
When V changes, the manifests of the three newest versions and their files
stay on the release: an idx that knows an older schema keeps installing its
own (older) snapshot and imports the newer months from the raw files.
Building one by hand (what the mirror script does, from any database whose months were all imported by idx 0.8.2 or later):
IDX_OWNERSHIP_DB=build/ownership/ownership.sqlite \
idx ownership build-snapshot --out build/snap --sources build/ownership/out/index.json
build-snapshot is hidden from --help. It never changes the database it
reads: one of another schema version is refused, not migrated. --sources
records each month's idx.co.id URL from the mirror's index.json (when the
file's SHA-256 matches) and --generated-at sets the build time (default
now), which also replaces every imported_at.
Database
Default path: %LOCALAPPDATA%\idx\ownership.sqlite on Windows,
$XDG_DATA_HOME/idx/ownership.sqlite or ~/.local/share/idx/ownership.sqlite
elsewhere. Override with IDX_OWNERSHIP_DB or ownership.db in the config.
Tables (schema version 4, kept in PRAGMA user_version; version 3 adds
resolutions, version 4 the two composition tables):
releasesone row per imported month (as_ofunique; re-importing a month replaces it), with the source path/URL and SHA-256 of the file, the register lines stored (row_count), thepositionsbuilt from them, theother_date_rowsleft out and whether the file hadCONTROLLER_OR_AFFILIATE(controller_column).entitiesone row per normalised holder: upper-cased alphanumerics, single spaces, legal-form tokensPT,TBK,PERSEROremoved.PT ALAMTRI RESOURCES INDONESIA TBKandAlamtri Resources Indonesia, Tbk.share one entity.aliasesevery raw spelling seen, pointing at its entity.holding_linesevery register line as printed, keyed by (release, sheet row), with its ownreport_date, entity, investor type, controller flag, local/foreign, nationality, domicile, scripless/scrip/total shares and percent. Nothing is dropped here.holdingsthe positions every query reads: one row per (release, symbol, entity), built from the release's lines dated itsas_of. A holder's lines are summed (shares and percent;accountscounts them); the holder's spelling is the largest line's, and type, controller flag, local/foreign, nationality and domicile come from the largest line that has a value.resolutionsyour spelling rules fromidx ownership resolve(version 3):merge(one holder key folded into another) ormap(one spelling moved to a holder), the holder it now belongs to, the issuer-months combined with--foldand when it was made. Every import applies them.composition_releases(version 4) one row per imported KSEI composition month (as_ofunique; re-importing replaces it), with the format (ziportxt), source, SHA-256, the stocks stored and the other securities left out.compositionone row per (month, stock): shares listed, price, the nine local and nine foreign investor-type counts and both totals, as printed.snapshot_info(only in a database installed byidx ownership sync): key/value rows naming the snapshot it came from. Queries never read it.
Why one holder has several lines: KSEI computes holdings per SID, and per account for scrip held without an SID (the register's own disclaimer). A holder typically appears once for its scripless C-BEST position and once for a scrip (eBAE) account, often spelled differently and without the L/A flag or investor type on the scrip line. idx 0.8.1 and older kept only the last of those lines; for example KREN's KRESNA PRIMA INVEST showed 4.01 % instead of 9.44 % + 4.01 % = 13.45 % on 30 Sep 2026.
Percentages are rounded to four decimals on import to remove binary noise in
the source (1.1499999999999999 becomes 1.15). Numbers stored as text are
read per column: a share count may use ., , or spaces as thousands
separators and an all-zero decimal tail (2.500.000,00); in a percentage a
single . or , is the decimal mark (7,19 is 7.19, 1,000 is 1.0). A
percentage outside 0 to 100 or a fractional share count skips the row with a
warning naming the cell. Nothing else is altered.
Upgrades: opening a database migrates it in place (one transaction; a file
written by a newer idx is refused). Months imported by idx 0.8.1 or older stay
readable but have no lines: their positions are blank, accounts is
null, and every query that reads them warns until they are re-imported with
idx ownership import --all --force (or --file).
Queries
idx ownership ticker BBCA # holders above 1 %, newest month
idx ownership ticker BBCA --as-of 2026-07-31
idx ownership entity "Dwimuria Investama" # every position of one holder
idx ownership changes --from 2026-07-31 --to 2026-08-31 --symbol BBCA
idx ownership changes --from 2026-02-27 --to 2026-08-31 --min-change 1 --limit 0
idx ownership changes --from 2026-07-31 --to 2026-08-31 --limit 100 --offset 100 # page 2
idx ownership foreign BBCA # foreign vs local share, month by month
idx ownership foreign # every issuer's foreign change, newest two months
idx ownership releases
ticker and entity list positions: a holder on several register lines
(scrip and scripless accounts) is one row with accounts > 1 (an Accts
column in the table). From the 30 Oct 2026 register each row also carries
controller_or_affiliate, and controller_reported says whether the release
has that column (a Control column in the table).
changes compares two imported months per (symbol, entity), each side's
accounts summed: new holders (absent before: crossed 1 % or newly listed),
exited holders (absent after) and changed positions, sorted by absolute
percentage-point move. Rows carry local_foreign (L/A), accounts_from,
accounts_to and controller_or_affiliate. A holder with the same shares on
both sides is not a change when its percent did not move, or, when a side has
several lines, moved by no more than their rounding (each line is printed to
0.01 pp, so n summed lines can drift by 0.005 × n pp: scrip converted to
scripless). With one line on each side any move counts, since the issued
share count changed. The summary block is computed over every match
before --limit/--offset paging: counts of new, exited, increased and
decreased holders, net percentage points split by foreign/local and by KSEI
investor type, per-issuer flow (with the foreign part), and the top rises and
falls. For a single symbol the net figures are exact; across the register they
are a flow indicator.
foreign sums the L/A flag KSEI prints on each listed holder. With a
symbol it gives one issuer's disclosed foreign percent, holder counts and
month-over-month change per release; without one it ranks every issuer by the
change in disclosed foreign stake between two releases. Sums cover holders
above 1 % only, so this is the disclosed foreign stake, not total foreign
ownership.
JSON schemas: idx schema ownership-holders|ownership-entity|ownership-changes| ownership-foreign|ownership-foreign-flow|ownership-releases|ownership-import| ownership-discover|ownership-search|ownership-cross-holders| ownership-concentration|ownership-graph|ownership-resolve-candidates| ownership-resolution|ownership-resolutions|ownership-composition| ownership-snapshot-sync|ownership-status|ownership-snapshot| ownership-institutions.
Finding a holder
idx ownership search salim # case-insensitive, any spelling
idx ownership search "salim anthoni" # words in any order
idx ownership search "uob kay hian" --limit 5
idx ownership entity --exact "ANTHONI SALIM" # one holder's positions, by key
search matches the normalised name (entity_key) and every spelling KSEI
printed in any imported month. Each match says how it matched: exact (the
query normalises to the key), spelling (one of the printed spellings, case
and spacing ignored), prefix, contains, or words (every query word
starts a word of the key or a spelling, in any order). Exact and spelling
matches come first, then holders listed in the selected release (--as-of,
default newest) before those that left the register, then prefix, contains
and word matches, then the holders with more issuers. A query needs 2 letters
or digits. entity --exact takes the key and lists that one holder, where
plain entity lists every holder whose key contains the name. entity
reads one release (newest by default), so for a holder that left the
register pass its last_seen as --as-of; the line under the search table
prints that command for the first match.
Holders across issuers
idx ownership cross-holders # holders above 1 % in at least 2 issuers
idx ownership cross-holders --type individual # people only
idx ownership cross-holders --origin foreign --min-issuers 5
A holder's accounts in one issuer are summed first, so an issuer counts once.
total_pct adds stakes in different companies: it measures breadth and is
not a share of anything. --type is a case-insensitive substring of KSEI's
investor type (mutual fund matches MUTUAL FUNDS), --origin the A/L
flag; a holder without a type or flag never passes those filters. Custodian
and nominee accounts (securities companies, private banks) lead the ranking
because the register names the account holder, not the beneficial owner: on
30 Sep 2026 UOB KAY HIAN PRIVATE LIMITED is listed in 64 issuers. A holder
printed two ways counts as two until you merge them (below).
Concentration
idx ownership concentration # most concentrated first, by HHI
idx ownership concentration --ascending # most widely held first
idx ownership concentration --by top1 --limit 50
idx ownership concentration BBCA BBRI TLKM
Definition, per issuer and release:
- Holders: those listed above 1 % for the issuer, each holder's accounts summed into one position.
hhi= the sum of the squared percentages of those holders, on the 0-100 scale, so it runs from 0 to 10,000 (10,000 means one holder owns everything). Nothing is rounded before the last step.top1_pctandtop_holder(ties by name),top3_pct(the three largest, fewer when fewer are listed),listed_pct(their sum),holders_listed.undisclosed_pct= max(0, 100 -listed_pct): what the register does not show. It is left out of the HHI (treated as fully dispersed), so the HHI is a lower bound of the full figure. It is not IDX's free float, a narrower regulatory figure.controller_pct: the stakes flagged CONTROLLER or AFFILIATE OF THE CONTROLLER, from the 30 Oct 2026 register on (nullfor earlier releases).
The reference CLI 0xrsydn/idx-cli uses the same definition over register rows;
idx sums a holder's accounts first (two accounts of 9.44 % and 4.01 % add
180.9 points, not 105.2), keeps the decimals (BBCA scores 3,021.91 rather than
3,021 on 30 Sep 2026) and calls the remainder undisclosed_pct. No upper
bound is published: KSEI lists accounts, so a holder below 1 % per account is
not guaranteed below 1 % in total.
Network graph
idx ownership graph BBCA # holders of BBCA and their other holdings
idx ownership graph "Anthoni Salim" --depth 3
idx ownership graph AADI --min-pct 5 # major stakes only
idx ownership graph BBRI --dot | dot -Tsvg -o bbri.svg
The register is a bipartite graph: holders on one side, issuers on the other,
a link for each holding above 1 %. The walk starts at a ticker (or, when ROOT
is not a ticker with holders or --holder is given, at the holder whose key
ROOT normalises to, else at the best search match, with a warning naming
it) and goes breadth-first for --depth hops (1-6, default 2): from a ticker,
hop 1 is its holders and hop 2 their other issuers. Within a hop the strongest
links come first, so --max-nodes (default 100, at most 1000) keeps the
important part; reaching it sets truncated and warns. Links are every
holding between two reached nodes. Node ids are ticker:<SYMBOL> and
holder:<entity_key>, the same in every database. --dot prints a
deterministic Graphviz digraph (holders as ellipses, issuers as boxes, the
percent on each edge, the root in bold) and wins over -o; -o csv prints
the links.
Sizes on the 30 Sep 2026 register: BBCA at depth 2 has 13 nodes; DRS LO KHENG
HONG has 106 at depth 2 and 1,774 at depth 4; the custodian UOB KAY HIAN
reaches 452 at depth 2 (44 with --min-pct 5).
Spelling changes and merges
KSEI sometimes prints one holder differently from month to month, and
changes then shows an exit plus a new holder. Between 31 Aug and 30 Sep
2026, SURONO SUBEKTI became DRS SURONO SUBEKTI (18 issuers), UOB KAY HIAN PTE
LTD became UOB KAY HIAN PRIVATE LIMITED (54 issuers), and in all 232 of the
1,451 change rows idx 0.8.1 reported were one name leaving an issuer and
another entering it with exactly the same share count.
idx ownership resolve candidates # suggestions with their evidence
idx ownership resolve merge "DRS SURONO SUBEKTI" "SURONO SUBEKTI" --dry-run
idx ownership resolve merge "DRS SURONO SUBEKTI" "SURONO SUBEKTI"
idx ownership resolve map "IR. T. PERMADI RACHMAT" "T PERMADI RACHMAT"
idx ownership resolve list
idx ownership resolve undo 1
candidates (also unresolved) pairs holders on two kinds of evidence:
renamed, the same share count left one name and reached the other in the
same issuer between two consecutive imported months; and similar_name, the
same words once legal forms (PT, TBK, LTD, PTE, LIMITED,
CORPORATION, ...) and titles (DRS, DR, IR, H, HJ, ...) are dropped,
in any order (names of one word are left out). Pairs listed in one issuer in
one month are marked listed_together. These are suggestions, and both kinds
have false positives: an identical share count is also what a real block sale
looks like (MANDOM CORPORATION to KALON MIDCO HOLDINGS CO LTD in TCID), and
holders listed together are often different people (CAUDLE GAVIN ARNOLD and
GAVIN ARNOLD CAUDLE in PALM).
merge KEEP MERGE... moves every spelling and register line of each MERGE
holder to KEEP, in every imported month, and every later import follows (a
new spelling whose key was merged away joins KEEP too). map SPELLING HOLDER
moves one printed spelling (--new makes HOLDER a new holder). Holders are
named by key or display name, exactly; search shows both and every
spelling. When both are listed in the same issuer in one month the command
stops unless you pass --fold, which adds the stakes up. --dry-run reports
the counts and writes nothing. undo ID deletes a rule and moves back what it
moved; months with register lines are rebuilt from them, so even a fold
splits again exactly. A merge of a holder that had been merged into earlier
re-points those rules too, so undo reverts exactly one rule.
The rules live in the ownership database (resolutions), so they survive
re-imports (import --all --force re-applies them) but not a new database
file. Months imported by idx 0.8.1 or older have no register lines: a rule
edits their positions in place, a fold there cannot be split again, and a
warning asks to re-import them. The rules are local to the CLI and the MCP
server reading this file; the GitHub mirror and the web terminal keep the
automatic grouping.
Caveats
- Holders below 1 % are not published, so
listed_pctis below 100 %. - Entity grouping is heuristic: it merges spelling variants, not legally
distinct vehicles with similar names. The raw spelling is kept on every
line. Two different holders whose names normalise alike in one ticker are
summed into one position (they used to replace each other). Titles
(DRS, IR) and word order are not normalised;
resolvemerges those by hand. - The register names account holders: custodians and nominees (securities companies, private banks) hold for clients, so cross-holder rankings and graphs show them as hubs.
- The web terminal's
ownership searchcovers the selected month only (its spellings), and it has noresolve: your rules stay in your database. - Attributes of a summed position come from its largest line that has them:
a scrip line without an L/A flag counts with its holder's flagged line in
ownership foreign. - Rows dated other than the release's
as_ofare kept but never summed into it; the import,releases(other_date_rows) and a not-found message say so. None of the 2026 IDX files mixes dates. CONTROLLER_OR_AFFILIATEcomes from the issuer's Form E009 for the previous month, forwarded by IDX to KSEI, so it can lag a month.- PDF imports depend on the text layer of the announcement; numbers are checked but a reformatted PDF may need the parser updated.
Institutional and fund holders (MSN)
idx ownership institutions BBCA
idx -o json ownership institutions TLKM | jq '.data.flow'
idx -o csv ownership institutions BBRI > holders.csv
The KSEI register stops at 1 %, so most foreign funds never appear in it.
ownership institutions fills that gap from MSN Money: the ownershipSummary
block of the same Finance/Equities response that stocks profile and
stocks fundamental read through the MSN provider (one request, cached with
the fundamentals TTL, 6 h by default, served stale with --offline). Nothing
is imported or stored in the database.
Each report carries:
institutions_pct,top10_institutions_pct,funds_pct: percent of shares outstanding held by all institutions, the ten largest, and funds, as MSN reports them (not sums of the listed rows).float_pct: the free float as a percent of shares outstanding, as MSN reports it (BBCA 38.2 %, consistent with Yahoo's institutions against institutions-of-float ratio).holders[]: up to 15 institutions (kind: institution) and 20 funds (kind: fund), each in MSN's rank order, withshares,shares_change(bought or sold since that holder's previous report),pct_outstanding,pct_outstanding_change(percentage points of the company),pct_portfolio(funds only: the stock's weight in the fund, in percent),report_dateandsignal.flow.institutionsandflow.funds: counts of each signal and the sum ofshares_changeper list.
signal is computed by idx from shares and shares_change: exited when
the holding is 0 after a sale, new when the change equals the whole
holding, otherwise buyer, seller or unchanged by the sign of the change.
It is null when either number is missing.
Read it with care:
- Every holder reports on its own date (BBCA: from 2025-12-31 to 2026-10-31 on
2026-11-06), so a change is "since that holder's last filing", not over one
common period.
oldest_reportandnewest_reportgive the span; exited holders are often the oldest rows. - Funds are part of their managers' institutional positions (Fidelity the manager is an institution, Fidelity's funds are funds): never add the two lists or their percentages.
- Not used on purpose: MSN's
marketValue(a holder-level total, identical for one manager across stocks), itschange(the share change at the report-date price, in an unstated currency that looks like USD; neither is the value of this position in rupiah) andinsiderOwnershipPercent(1.21 % for BBCA, whose controlling holder has 54.94 % in the KSEI register). - Coverage: on 2026-11-06, 420 of the 887 IDX stocks MSN lists had
institutions, 381 had the block with empty lists and 86 had none. A stock
without holders gives an empty report with a warning (exit 0); a ticker MSN
does not list on IDX exits 4;
--provider yahooexits 6 (the defaultautoreaches MSN). - Bing's
hedgefunddataproviderAPI, which some tools use for holders, carries US 13F filings only: it answers{"securityOwnerships":[]}for every IDX stock, so idx does not use it.
The web terminal does not run this command yet: MSN has not been checked from
the site's Cloudflare Worker. Schema: idx schema ownership-institutions
(idx.ownership.institutions.v1).