IDX Screener API · dev.multiscreener.qomu.ai

IDX Screener REST API#

Programmatic access to the IDX Screener platform: six screening models, and the market-data layer they are built on (quotes, broker flow, negotiated-market activity, turning-point signals, market breadth).

This document is for developers integrating another application against this API. If you maintain the API itself, the operator notes are at the end.


1. Getting access#

Ask the platform owner for an API key. You will receive a string like:

idxs_live_eXRikWS_xKgM8O7LUqEabv1gHiYrvLWId7FXBhPiOa8

The key is shown to the issuer exactly once and is stored only as a hash, so it cannot be recovered — if you lose it, you need a new one. Treat it as a password:

Authenticating#

Send the key on every request, either way:

Authorization: Bearer idxs_live_xxxxxxxx
X-API-Key: idxs_live_xxxxxxxx

Only /health is unauthenticated.

Identifying your end users#

Your API key identifies your application, not a person. To record which of your users a run was for, send an opaque id for that user on every run:

X-End-User: user_8417

This header is required on POST /api/v1/models/{id}/runs. A run without it is rejected with 422 missing_end_user.

What to send:

Ids are namespaced per API key, so your user_1 and another consumer's user_1 are never mixed. Read the history back from the usage endpoints in section 7.

Scopes#

Your key carries one or more scopes. Calling an endpoint outside them returns 403 insufficient_scope.

Scope Grants
models:read List models, read their parameter schemas
models:run Submit model runs, poll and list your own jobs
data:read All /api/v1/data/* endpoints
usage:read Your own usage history and your own end users
usage:admin Usage across every key and channel — operator only, not issued to consumers

GET /api/v1/scopes returns this table at runtime.


2. Quickstart#

Run a model and get its picks. The three steps are: submit, poll, read.

curl#

export IDX_KEY="idxs_live_xxxxxxxx"
export IDX_API="https://your-host.example.com"

# 1. What can I run?
curl -s -H "Authorization: Bearer $IDX_KEY" "$IDX_API/v1/models" | jq '.models[].id'

# 2. Submit a run (202 Accepted, returns a job id).
#    X-End-User says which of YOUR users this run is for.
curl -s -X POST "$IDX_API/v1/models/confluence_filter/runs" \
  -H "Authorization: Bearer $IDX_KEY" \
  -H "X-End-User: user_8417" \
  -H "Content-Type: application/json" \
  -d '{"params": {"max_picks": 5, "min_signals": 2}}'

# 3. Poll until it finishes
curl -s -H "Authorization: Bearer $IDX_KEY" "$IDX_API/v1/jobs/job_f4ee4ba42788429e" | jq '.status'

For the faster models you can skip the polling loop with ?wait=:

# Hold the connection up to 30s; returns 200 with the result if it finishes,
# or 202 with the job id if it does not.
curl -s -X POST "$IDX_API/v1/models/confluence_filter/runs?wait=30" \
  -H "Authorization: Bearer $IDX_KEY" -H "X-End-User: user_8417" \
  -H "Content-Type: application/json" \
  -d '{"params": {"max_picks": 5}}'

Python#

import os, time, requests

API = os.environ["IDX_API"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['IDX_KEY']}"

def run_model(model_id, end_user, params=None, timeout=300):
    """
    Submit a model run and block until it finishes. Returns the result dict.

    `end_user` is your own stable id for the person this run is for; it is
    required, and it is what makes per-user history work.
    """
    r = session.post(
        f"{API}/api/v1/models/{model_id}/runs",
        json={"params": params or {}},
        headers={"X-End-User": end_user},
    )
    r.raise_for_status()
    job = r.json()

    if job["status"] == "succeeded":        # finished inside the request
        return job["result"]

    job_id = job["job_id"]
    deadline = time.time() + timeout
    while time.time() < deadline:
        time.sleep(3)                        # 2-5s is the right cadence
        job = session.get(f"{API}/api/v1/jobs/{job_id}").json()
        if job["status"] == "succeeded":
            return job["result"]
        if job["status"] == "failed":
            raise RuntimeError(f"{model_id} failed: {job['error']['message']}")
    raise TimeoutError(f"{model_id} did not finish within {timeout}s")

result = run_model("confluence_filter", "user_8417", {"max_picks": 5})
for pick in result["df_result"]:
    print(pick["Ticker"], pick["Signals"], pick["Last Close"])

JavaScript (Node)#

const API = process.env.IDX_API;
const headers = {
  Authorization: `Bearer ${process.env.IDX_KEY}`,
  "Content-Type": "application/json",
};

async function runModel(modelId, endUser, params = {}, timeoutMs = 300_000) {
  const res = await fetch(`${API}/api/v1/models/${modelId}/runs`, {
    method: "POST",
    headers: { ...headers, "X-End-User": endUser },
    body: JSON.stringify({ params }),
  });
  if (!res.ok) throw new Error((await res.json()).error.message);
  let job = await res.json();
  if (job.status === "succeeded") return job.result;

  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, 3000));
    job = await (await fetch(`${API}/api/v1/jobs/${job.job_id}`, { headers })).json();
    if (job.status === "succeeded") return job.result;
    if (job.status === "failed") throw new Error(job.error.message);
  }
  throw new Error(`${modelId} timed out`);
}

3. Models#

Six models are published. Each answers "which IDX stocks look interesting right now", by a different method.

id Name Category What it does
regime_adaptive_v6 Model A: Regime Adaptive swing Adapts its picks to the prevailing market regime (bull / sideways / bear)
gann_classic_v7 Model B: Cycle Analysis swing Cycle-based entry timing plus a regime-adaptive ML model
gross_alpha_v10 Model C: General Purpose swing ML momentum screener driven by gross broker-summary data
confluence_filter Model D: Confluence Filter swing Pure technical confluence (Stochastic, MACD, MA cross) — no ML, no training
recovery_v4 Model E: Portfolio Recovery recovery Trajectory analysis for positions already underwater
model_f_combined Model F: Combined ML + Signals swing Hybrid of the ML momentum score and the confluence signals

GET /api/v1/models/{id} adds a plain-language explanation of each model — what it does, when it suits, and how to set it — written for traders rather than engineers. It is in Bahasa Indonesia.

Parameters#

Every model takes parameters, all optional. Anything you omit takes the model's default. Fetch the schema before you build a request:

curl -s -H "Authorization: Bearer $IDX_KEY" \
  "$IDX_API/v1/models/confluence_filter/params" | jq
{
  "model_id": "confluence_filter",
  "params": {
    "analysis_date": { "type": "date",    "default": "today", "description": "Tanggal analisis" },
    "lookback":      { "type": "integer", "default": 5, "min": 1, "max": 10 },
    "min_signals":   { "type": "integer", "default": 2, "min": 1, "max": 4 },
    "max_picks":     { "type": "integer", "default": 20 }
  }
}

Parameter types map to JSON as you would expect: integer and float are numbers, boolean is a real boolean (not "true"), string must be one of options when that key is present, date is YYYY-MM-DD or the literal "today", and array is a JSON array.

Unknown parameter names are rejected with 422. This is deliberate: a typo like max_pick would otherwise run silently with the wrong setting.

{"error": {"code": "invalid_request",
  "message": "Unknown parameter(s) for model 'confluence_filter': max_pick. Valid parameters are: analysis_date, lookback, ...",
  "request_id": "req_8dd4052e062d"}}

min/max bounds and options are enforced the same way.

recovery_v4 needs positions#

Model E analyses a portfolio you already hold, so an empty run is an error, not an empty result. Pass your positions:

{"params": {"horizon": 20,
            "positions": [{"ticker": "BBCA", "pnl_input": -12.5, "avg_price_input": 6500}]}}

horizon must be one of 10, 20, 30 or 40.


4. Running a model#

Model runs are asynchronous. Every run retrains the model against current data, which takes roughly 9–16 seconds depending on the model, and the first run after a server restart also pays a one-off cost to load the dataset from disk.

POST /api/v1/models/{model_id}/runs   ->  202 Accepted
{
  "job_id": "job_f4ee4ba42788429e",
  "kind": "model_run",
  "target": "confluence_filter",
  "status": "running",
  "params": { "max_picks": 2 },
  "created_at": "2026-09-17T16:02:11.353503+00:00",
  "started_at": "2026-09-17T16:02:11.353702+00:00",
  "finished_at": null,
  "duration_seconds": 0.0,
  "poll_url": "/api/v1/jobs/job_f4ee4ba42788429e"
}

Then poll GET /api/v1/jobs/{job_id} every 2–5 seconds. status moves through queuedrunningsucceeded or failed.

GET /api/v1/jobs/job_f4ee4ba42788429e   ->  200 OK
{
  "job_id": "job_f4ee4ba42788429e",
  "status": "succeeded",
  "target": "confluence_filter",
  "duration_seconds": 14.88,
  "finished_at": "2026-09-17T16:02:26.230762+00:00",
  "result": { "df_result": [ ... ], "diag": { ... }, "target_date": "2026-06-11" }
}

Rules worth coding against:

GET /api/v1/jobs?limit=20 lists your recent jobs, without their results.

When a job goes cold#

A job is served from one of two tiers, and source tells you which:

source When result summary
live Within the hour, still in memory present on success present
ledger Evicted, or the server restarted absent present

A cold job returns 200, not 404, with result_available: false:

{
  "job_id": "job_f4ee4ba42788429e",
  "status": "succeeded",
  "target": "confluence_filter",
  "source": "ledger",
  "result_available": false,
  "params": {"max_picks": 2},
  "duration_seconds": 14.88,
  "end_user": "user_8417",
  "summary": {
    "pick_count": 2,
    "tickers": ["AADI", "BBCA"],
    "target_date": "2026-06-11"
  }
}

Branch on result_available, not on status — a cold job can be succeeded and still have no picks to give you.

The rule in one line: the ledger remembers what you asked and what came back in summary; it does not keep the picks.

There is also a fifth status, interrupted: the run started and never reported back, because the server died mid-run. Treat it as a failure you may retry.

404 now means only that the job never existed, or that it predates the retention window.

Reading the result#

Result shape varies by model, but every screener returns df_result (the picks, one object per row) and diag (how the universe was narrowed).

{
  "df_result": [
    { "Ticker": "AADI", "Signals": 3,
      "Signal Detail": "📊 MACD (4d ago) | 📈 MACD (4d ago) | ✨ MA (TODAY)",
      "Stoch %K": 95.0, "MACD Hist": "+2.041%", "MA Spread": "+0.23%",
      "Vol Ratio": "1.0x", "Last Close": "8,050", "Daily Val": "139.4B",
      "RSI": 49.5, "BB Width": 0.93 }
  ],
  "diag": { "total_scanned": 94, "total_hits": 15, "after_seller_filter": 2, "final": 2 },
  "target_date": "2026-06-11"
}

Important: df_result rows carry display-formatted strings, not only numbers. These rows are rendered directly in the platform's own UI, so several fields are pre-formatted for human reading: "Last Close": "8,050" (thousands separator), "Daily Val": "139.4B" (abbreviated IDR), "MACD Hist": "+2.041%" (signed percent string), "Vol Ratio": "1.0x". Others — Stoch %K, RSI, BB Width, Signals — are genuine numbers.

Do not assume a field is numeric because it looks like a quantity. Parse defensively, and pin the behaviour with a test against the real payload. Which fields are strings differs per model. If you need clean numeric fields, ask the platform owner — it is a change on their side, not something you can work around reliably.

target_date is the trading date the analysis ran against. Always surface it — it tells you how fresh the answer is.


5. Market data#

All /api/v1/data/* endpoints are synchronous and return in well under a second once the server's cache is warm. They need the data:read scope.

Endpoint Returns
GET /api/v1/data/quotes?tickers=BBCA,TLKM Latest close and daily change
GET /api/v1/data/stocks/{ticker}?days=20 OHLCV plus ~30 computed indicators
POST /api/v1/data/compare Snapshots for up to 10 tickers at once
GET /api/v1/data/market-overview Regime, breadth, movers, aggregate stats
GET /api/v1/data/stocks/{ticker}/brokers Top buyers and sellers for one day
GET /api/v1/data/stocks/{ticker}/brokers/window?days=10 Per-broker totals over a window
GET /api/v1/data/stocks/{ticker}/brokers/{broker}/timeline One broker's daily flow on one stock
GET /api/v1/data/brokers/{broker}/stocks?days=5 What one broker has been accumulating
GET /api/v1/data/flows?flow_type=foreign Market-wide institutional flow scan
GET /api/v1/data/stocks/{ticker}/nego Negotiated-market value, price and premium
GET /api/v1/data/stocks/{ticker}/nego/brokers Broker breakdown of the nego market
GET /api/v1/data/nego · GET /api/v1/data/nego/window Stocks with notable nego activity
GET /api/v1/data/stocks/{ticker}/turning-point Reversal signal for one stock
POST /api/v1/data/turning-points Batch reversal signals (much cheaper than looping)

Tickers are case-insensitive; bbca and BBCA are the same. At most 50 tickers per call (10 for /compare).

Examples#

// GET /api/v1/data/quotes?tickers=BBCA,TLKM
{ "quotes": {
    "BBCA": { "close": 5825.0, "change_pct": 3.1, "date": "2026-06-11" },
    "TLKM": { "close": 2870.0, "change_pct": 2.14, "date": "2026-06-11" } } }

A ticker with no data on the latest trading day is omitted from quotes rather than returned as null — check for presence before indexing.

// GET /api/v1/data/market-overview
{ "date": "2026-06-11", "total_stocks": 958, "liquid_stocks": 216,
  "regime": "BEAR", "regime_score": -0.4,
  "breadth": { "advancing": 284, "declining": 435, "above_ma20_pct": 39.4 },
  "averages": { "daily_return_pct": -0.23, "rsi": 48.5 } }
// GET /api/v1/data/stocks/BBCA/turning-point
{ "ticker": "BBCA", "date": "2026-06-11", "signal": "DISTRIBUTION_RISK", "score": 2,
  "factors": ["asing keluar 4/5 hari",
              "smart money divergence negatif (asing jual, ritel beli)"],
  "close": 5825.0, "ret_10d_pct": -2.51 }

factors are human-readable strings in Bahasa Indonesia; branch on signal and score, and treat factors as display text.

// GET /api/v1/data/stocks/BBCA/brokers?top_n=2
{ "ticker": "BBCA", "date": "2026-06-11", "market": "REG", "close": 5825.0,
  "broker_count": 81,
  "totals": { "foreign_net_value": 326062130000, "retail_net_value": -263315832500, ... },
  "top_buyers": [
    { "broker": "ZP", "buy_shares": 103785300, "buy_value": 595747070000,
      "avg_buy_price": 5740.0, "net_value": 144186835000,
      "is_retail": false, "is_foreign": true } ],
  "top_sellers": [ ... ] }

Unlike model results, the data endpoints return numbers as numbers. Values are in IDR, share counts in shares (not lots).


6. Errors#

Every failure uses one envelope:

{"error": {"code": "invalid_api_key",
           "message": "The supplied API key is not valid.",
           "request_id": "req_a1b2c3d4e5f6"}}

Branch on code — it is stable. Show message to a human. Quote request_id (also returned as the X-Request-ID header on every response) when reporting a problem; it matches the server's log line.

Status code Meaning
401 missing_api_key No Authorization or X-API-Key header
401 invalid_api_key Unknown or revoked key
403 key_disabled Key exists but has been disabled
403 insufficient_scope Key lacks the scope this endpoint needs
404 model_not_found No such model id — check GET /api/v1/models
404 job_not_found Wrong id, expired (>1h), or belongs to another key
404 not_found No data for that ticker/date
400 invalid_end_user X-End-User has characters outside [A-Za-z0-9._-] or is over 64 chars
422 missing_end_user X-End-User was not sent on a run submission
422 invalid_request Bad parameter name, type, or out of range
422 invalid_cursor The cursor value did not come from this API
404 usage_event_not_found No such usage event for your key
503 usage_unavailable The usage ledger could not be read
422 too_many_tickers Over the per-call ticker limit
429 rate_limited Too many requests — see Retry-After
500 internal_error Server bug. Report it with the request_id
503 data_unavailable Backing dataset missing or failed to load
503 model_unavailable Model configured but failed to load on the server

A failed model run is not an HTTP error. Submitting works (202), polling works (200), and the failure shows up as "status": "failed" in the job body. Check status, not just the HTTP code.


7. Usage and history#

Every model run is recorded durably, against the end user you asserted. These endpoints need the usage:read scope and only ever return your own data — scoping happens in the query itself, not as a filter afterwards.

Endpoint Returns
GET /api/v1/usage/events Your runs, newest first
GET /api/v1/usage/events/{event_id} One run in full
GET /api/v1/usage/summary?group_by= Totals by model, end_user, status or day
GET /api/v1/usage/end-users Which of your users have used this, and how much
GET /api/v1/usage/end-users/{id}/events One user's history
curl -s -H "Authorization: Bearer $IDX_KEY" \
  "$IDX_API/v1/usage/end-users/user_8417/events?limit=20"
{
  "events": [{
    "event_id": "ue_3f1c9ab27d4e5601",
    "ts": "2026-09-17T16:02:11.353503+00:00",
    "channel": "api",
    "end_user": "user_8417",
    "model_id": "confluence_filter",
    "params": {"max_picks": 2},          // exactly what the model received
    "status": "succeeded",
    "duration_seconds": 14.88,
    "summary": {"pick_count": 2, "tickers": ["AADI", "BBCA"],
                "target_date": "2026-06-11"},
    "job_id": "job_f4ee4ba42788429e",
    "request_id": "req_a1b2c3d4e5f6"
  }],
  "count": 1,
  "next_cursor": "eyJ0cyI6MTc1...",
  "has_more": false
}

Filters on /events: end_user, model_id, status, since, until (ISO date, ISO datetime, or unix seconds), limit (1–200).

Pagination is cursor-based. Pass the previous response's next_cursor as cursor; stop when has_more is false. Do not construct cursors yourself — an unrecognised one returns 422 invalid_cursor.

curl -s -H "Authorization: Bearer $IDX_KEY" \
  "$IDX_API/v1/usage/summary?group_by=end_user&since=2026-09-01"
{"group_by": "end_user", "total_runs": 41,
 "rows": [{"key": "user_8417", "runs": 27, "succeeded": 26, "failed": 1,
           "avg_duration_seconds": 12.4, "last_run_at": "2026-09-17T16:02:11+00:00"}]}

What is recorded: who (your asserted end user), which model, the exact parameter dict the model received, when, how long it took, whether it succeeded, and a summary of the output — pick count, the tickers picked, and the trading date analysed.

What is not recorded: the full result. And /api/v1/data/* reads are not recorded at all — only model runs.

Retention is 180 days by default. Ask the operator if you need a different window for your own compliance needs.


8. Rate limits#

Each key has a per-minute request limit (60 by default). Successful responses carry your budget:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57

Over the limit you get 429 with a Retry-After header in seconds. Back off for that long — retrying sooner just burns your budget. The limit counts requests, not compute, so a polling loop at 3-second intervals costs 20 requests a minute.


9. Data freshness#

The platform analyses a dataset that is refreshed out of band, not live tick data. Two consequences:


10. Versioning#

Paths are versioned (/api/v1/...). Within v1 we may add endpoints, add fields to responses, and add optional parameters. So:

One narrowing worth calling out: GET /api/v1/jobs/{id} used to return 404 once a finished job aged out of memory. It now returns 200 with result_available: false and a summary. If you were treating 404 as "re-run", switch that check to result_available.


Operator notes#

For whoever runs this service.

Deploying#

pip install -r requirements.txt   # adds fastapi + uvicorn
uvicorn api.server:app --host 127.0.0.1 --port 8600

Bind to 127.0.0.1 and terminate TLS at nginx, matching how the Streamlit app (port 8508) and the SGX app (8501) are already fronted on this box. Never expose it on 0.0.0.0 without TLS — an API key sent over plain HTTP is a key in the clear.

Suggested nginx block:

location /api/v1/ {
    proxy_pass http://127.0.0.1:8600;
    proxy_set_header Host $host;
    proxy_read_timeout 120s;       # comfortably over the ?wait= ceiling of 60s
}
location = /health { proxy_pass http://127.0.0.1:8600/health; }

Managing keys#

python scripts/api_keys.py create acme-app --scopes models:read,models:run,data:read
python scripts/api_keys.py create dashboard --scopes data:read --rate-limit 120
python scripts/api_keys.py list
python scripts/api_keys.py disable acme-app     # revoke, keep the record
python scripts/api_keys.py delete acme-app --yes

Keys live in config/api_keys.yaml (mode 600) as SHA-256 digests. The plaintext is printed once at creation and never stored. The running server re-reads the file when its mtime changes, so create, disable and delete all take effect within a second, with no restart.

Keep config/api_keys.yaml out of any repo or backup that others can read. It does not contain usable secrets, but it does map consumers to access levels.

Configuration#

Variable Default Purpose
IDX_API_KEYS_PATH config/api_keys.yaml Key file location
IDX_API_JOB_WORKERS 2 Concurrent model runs
IDX_API_JOB_TTL 3600 Seconds a finished job is retained
IDX_API_MAX_JOBS 500 Max retained jobs before eviction
IDX_API_CORS_ORIGINS (empty) Comma-separated allowed origins; CORS off when empty
IDX_API_LOG_LEVEL INFO Log level
IDX_USAGE_DB data/usage.db Usage ledger location
IDX_USAGE_RETENTION_DAYS 180 Retention window (0 = keep forever)
IDX_USAGE_MAX_ROWS 500000 Hard row ceiling
IDX_USAGE_BUSY_TIMEOUT_MS 5000 SQLite busy timeout

The usage ledger#

data/usage.db records every model run from all three channels — the REST API, the Telegram bot, and the scheduled daily broadcast — in one table. Inspect it with:

python scripts/usage.py stats             # rows, size, retention, writability
python scripts/usage.py top --by model --days 30
python scripts/usage.py top --by end_user
python scripts/usage.py recent --channel telegram
python scripts/usage.py export --days 30 --out usage.csv
python scripts/usage.py prune --days 180 --yes

Over REST, /api/v1/admin/usage/events, /summary, /end-users and /keys do the same across all keys and channels. They need a key with usage:admin — issue that only to yourself, never to a consumer. In the bot, /usage (admin DM only) gives a 7-day rollup.

Two operational cautions:

Pruning runs daily from the bot's scheduler at 03:30, and opportunistically from the API at most once a day — so retention still happens if either process is down.

Running the tests#

pip install -r requirements.txt
python -m pytest tests/ -q

tests/test_usage_store.py runs in under a second (the ledger deliberately has no heavy imports). tests/test_attribution.py takes ~30s because importing the bot pulls in the Gemini and Telegram clients.

Raise IDX_API_JOB_WORKERS only with memory headroom in mind: each concurrent run pins a large DataFrame and trains a model, and this box already runs Streamlit and the Telegram bot.

Publishing another model#

A model appears in the API when it has an entry in PARAM_SCHEMAS (core/screener_service.py). That rule keeps the public surface to models with a documented, validatable contract, and is why the backtest runners (backtest_lab, confluence_backtest, model_f_backtest, recovery_backtest) and gann_planner_v9 are registered internally but not exposed. Add a schema entry and the model is published — no route changes.

Source: docs/API.md in the idx-screener repository. This page is rendered from that file, so the repo stays the single source of truth.