idxby Midori Research
Kepemilikandocs/ownership.mdVersion 0.8.2

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

periodformatwhere
30 Oct 2026 onwardsXLSX, 13 columns (CONTROLLER_OR_AFFILIATE after INVESTOR_CLASSIFICATION)idx.co.id, "Data Kepemilikan Saham" page, Media/.../*-satu-persen.xlsx
June to September 2026XLSX, 12 columnssame page
February to May 2026PDF announcementidx.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:

--familytitle on the pagefileimportable
above1 (default)Pemegang Saham di Atas 1% per 31 Agustus 2026idx.co.id Media/.../peng-YYYY-MM-NNNNN-satu-persen.xlsxyes
above5Pemegang 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.xlsxno, listed only
investor-typeData Kepemilikan Saham Perusahaan Tercatat per Tipe Investor per 31 Agustus 2026idx.co.id peng-YYYY-MM-NNNNN-tipe.xlsxno, listed only
classificationData Kepemilikan Saham Perusahaan Tercatat Berdasarkan Klasifikasi Investor per 31 Agustus 2026idx.co.id peng-YYYY-MM-NNNNN-klasifikasi.xlsxno, listed only
compositionKepemilikan Efek 30 Oktober 2026ksei.co.id storage/Download/BalanceposEfekYYYYMMDD.zipyes
allevery 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:

  1. a browser-shaped request (Chrome user agent and headers);
  2. a curl-impersonate binary on PATH (curl_chrome131, curl_chrome124, curl_chrome116, curl-impersonate-chrome, curl-impersonate; override with IDX_CURL_IMPERSONATE=/path/to/binary);
  3. the GitHub mirror (below): the same files, downloaded from the repository's ownership-data release and checked against the SHA-256 in its index.json. A warning says when the mirror was used. IDX_OWNERSHIP_MIRROR=<url> points at another mirror, off disables it; IDX_OWNERSHIP_SOURCE=mirror skips idx.co.id, idx never 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:

  1. Repository → Settings → Actions → Runners → New self-hosted runner, and follow the three commands on any always-on machine with Git, gh and Python 3.10+ (Windows, macOS or Linux). Install it as a service.
  2. 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:

  1. 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);
  2. 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;
  3. 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;
  4. 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 json plus accounts and controller_or_affiliate) and writes index.json. rows is the number of exported rows (positions), which the Worker checks; lines is 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 } ] }
  ]
}
  1. builds the prebuilt snapshot of the same database with the hidden idx ownership build-snapshot: ownership-v{V}-{newest}-{sha12}.sqlite.gz and snapshot-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;
  2. 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):

assetcontent
snapshot-v{V}.jsonthe manifest, schema idx.ownership.snapshot.v1, uploaded last
ownership-v{V}-{newest as_of}-{sha12}.sqlite.gzthe 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 databaseaction
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 releasesinstall 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 emptykeep 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 monthsnothing (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 importsreplace 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 plus snapshot_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 (an idx mcp call) 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 mcp wait 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_info table (generated_at, built_by, content digest, the months and file hashes it held), which is how sync and status recognise 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):

  • releases one row per imported month (as_of unique; re-importing a month replaces it), with the source path/URL and SHA-256 of the file, the register lines stored (row_count), the positions built from them, the other_date_rows left out and whether the file had CONTROLLER_OR_AFFILIATE (controller_column).
  • entities one row per normalised holder: upper-cased alphanumerics, single spaces, legal-form tokens PT, TBK, PERSERO removed. PT ALAMTRI RESOURCES INDONESIA TBK and Alamtri Resources Indonesia, Tbk. share one entity.
  • aliases every raw spelling seen, pointing at its entity.
  • holding_lines every register line as printed, keyed by (release, sheet row), with its own report_date, entity, investor type, controller flag, local/foreign, nationality, domicile, scripless/scrip/total shares and percent. Nothing is dropped here.
  • holdings the positions every query reads: one row per (release, symbol, entity), built from the release's lines dated its as_of. A holder's lines are summed (shares and percent; accounts counts 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.
  • resolutions your spelling rules from idx ownership resolve (version 3): merge (one holder key folded into another) or map (one spelling moved to a holder), the holder it now belongs to, the issuer-months combined with --fold and when it was made. Every import applies them.
  • composition_releases (version 4) one row per imported KSEI composition month (as_of unique; re-importing replaces it), with the format (zip or txt), source, SHA-256, the stocks stored and the other securities left out.
  • composition one 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 by idx 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_pct and top_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 (null for 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_pct is 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; resolve merges 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 search covers the selected month only (its spellings), and it has no resolve: 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_of are 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_AFFILIATE comes 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, with shares, 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_date and signal.
  • flow.institutions and flow.funds: counts of each signal and the sum of shares_change per 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_report and newest_report give 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), its change (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) and insiderOwnershipPercent (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 yahoo exits 6 (the default auto reaches MSN).
  • Bing's hedgefunddataprovider API, 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).

From docs/ownership.md on the main branch, version 0.8.2. Back to the top