IDX Screener API · dev.multiscreener.qomu.ai

FAIR — API Integration Guide#

Endpoint-by-endpoint mapping for the FAIR mobile flow: which call to fire, in what order, with what payload, what comes back, and what to do next.

This is the flow-specific companion to API.md, which is the full reference. Where they disagree, API.md is authoritative on general behaviour and this document is authoritative on the FAIR sequence.

Two models are used, and they are not what their UI labels suggest:

FAIR screen API model_id Platform name
Screener Saham model_f_combined Model F: Combined ML + Signals
Portofolio Recovery recovery_v4 Model E: Portfolio Recovery

model_f_combined is the right id even though the screen is described as "confluence" — it is the hybrid that blends the ML probability with confluence signals, and it is the only model exposing prob_weight and signal_penalty, which the filter sheet uses. Model D (confluence_filter) is a different, signal-only model and is not part of this flow.


0. What you need before you start#

Base URL   https://dev.multiscreener.qomu.ai/api
API key    idxs_live_xxxxxxxxxxxx     (issued per app, keep it server-side)
Scopes     models:read, models:run    (+ usage:read if you show history)

This environment pins the analysis date to 2026-09-10#

Every run analyses 2026-09-10, whatever you send as analysis_date. A later date (or omitting it) is silently clamped back; an earlier date is honoured, so historical testing still works.

This is deliberate. The dataset is refreshed out of band and its most recent day is sometimes partial — on 2026-09-16 the broker summary covered 206 of ~835 tickers. For integration work you want results that are stable and complete, not freshest: the same request returns the same picks every time, so you can write assertions against them. 2026-09-10 is a fully healthy trading day.

The effective date is always echoed back in the run's params and in result.as_of — never assume, read it. Treat these numbers as shape-correct, not trading-accurate. The pin lifts when this environment is promoted.

Every endpoint in this document is under /api. That prefix is deliberate: this host also serves the human-facing docs at the root, and a path should say whether it is meant for a client or a browser. Concatenating the base URL above with any path below gives you the full URL.

Every request carries two headers:

Authorization: Bearer idxs_live_xxxxxxxx     ← identifies YOUR APP
X-End-User: nasabah_00471                    ← identifies THE CUSTOMER

X-End-User must be your own stable, opaque customer id — the same value every time for that person. Not an email, name, or account number that is itself sensitive: @ is rejected, and the value is stored for reporting. It is required on every model run and on both consent calls.

Never put the API key in the mobile app binary. It carries your app's full scopes and cannot be rotated per device. Call this API from your backend and have the app talk to your backend.


1. The sequence#

APP OPEN
  │
  ├─ 1. GET  /api/v1/consent                     ──▶ accepted? 
  │        │
  │        ├─ false ─▶ show disclaimer ─▶ 2. POST /api/v1/consent ─▶ 201 ─┐
  │        └─ true ──────────────────────────────────────────────────┤
  │                                                                   ▼
  ├─ 3. GET /api/v1/models/model_f_combined/params   (cache; drives the filter sheet)
  │     GET /api/v1/models/recovery_v4/params
  │
  ├─ SCREENER SAHAM                    ├─ PORTOFOLIO RECOVERY
  │   4a. POST .../model_f_combined/runs   4b. POST .../recovery_v4/runs
  │        ?view=compact&wait=30                ?view=compact&wait=30
  │            │                                     │
  │            ├─ 200 ─▶ render                      ├─ 200 ─▶ render
  │            └─ 202 ─▶ 5. GET /api/v1/jobs/{id} ──────┘  (poll every 3s)
  │
  └─ 6. (optional) GET /api/v1/usage/end-users/{id}/events   ← "riwayat analisa saya"

2. Step-by-step#

Step 1 — Check the disclaimer gate#

Fire this on entry to the FAIR menu, before rendering either screen.

GET /api/v1/consent?document_id=fair_disclaimer
Authorization: Bearer idxs_live_xxxx
X-End-User: nasabah_00471
{
  "end_user": "nasabah_00471",
  "document_id": "fair_disclaimer",
  "required_version": "2026-09-01",
  "accepted": false,
  "accepted_version": null,
  "accepted_at": null
}

Next: - accepted: true → skip to Step 3, show the screens (keep the persistent disclaimer banner visible). - accepted: false → show the blocking disclaimer sheet. Display the text for required_version. Do not let the user through until Step 2 succeeds. - 503 consent_unavailable → keep the gate closed and retry. Never treat a failed check as acceptance.

Bumping the disclaimer text server-side changes required_version, which flips everyone back to accepted: false automatically and re-gates them. Your app should drive the sheet off required_version, not off a locally stored flag.

Step 2 — Record the acceptance#

Fire when the user ticks the box and taps "Setuju & Lanjutkan".

POST /api/v1/consent
Authorization: Bearer idxs_live_xxxx
X-End-User: nasabah_00471
Content-Type: application/json

{
  "document_id": "fair_disclaimer",
  "document_version": "2026-09-01",
  "accepted": true,
  "locale": "id-ID",
  "client_version": "1.0.0"
}
201 Created
{
  "consent_id": "cs_57ff3944c2f149a3",
  "ts": 1789696014.67,
  "end_user": "nasabah_00471",
  "document_id": "fair_disclaimer",
  "document_version": "2026-09-01",
  "accepted": true
}

Next: 201 → open the gate. Anything else → keep it closed.

Status code Meaning
409 stale_document_version You showed old text. Re-fetch Step 1, show the current version, resubmit
422 consent_not_accepted You sent accepted: false — don't call this for a refusal
503 consent_write_failed Not recorded. Keep the gate closed and retry

Records are append-only, so re-accepting a new version is a new POST, and the history of what was agreed and when is preserved for audit.

Step 3 — Load the parameter schemas#

Fetch once per app session (or cache for a day) and drive the filter sheet from it, rather than hardcoding names, defaults and ranges.

GET /api/v1/models/model_f_combined/params
{
  "model_id": "model_f_combined",
  "params": {
    "min_price":       {"type":"integer","default":500,"min":100,"max":5000},
    "min_daily_val":   {"type":"integer","default":10000000000,"min":100000000,"max":100000000000},
    "min_probability": {"type":"float","default":65,"min":30,"max":100},
    "min_signals":     {"type":"integer","default":2,"min":1,"max":4},
    "prob_weight":     {"type":"float","default":0.6,"min":0.3,"max":0.8},
    "signal_penalty":  {"type":"float","default":0.3,"min":0.0,"max":0.5},
    "strategy":        {"type":"string","default":"Inverse Signal",
                        "options":["Inverse Signal","Gated","Hybrid Score","Intersection","Compare All"]},
    "target_gain":     {"type":"float","default":0.10,"min":0.05,"max":0.30},
    "stop_loss":       {"type":"float","default":0.05,"min":0.02,"max":0.15},
    "max_picks":       {"type":"integer","default":10,"min":1,"max":20}
  }
}

Mapping to the filter sheet:

Sheet control Parameter Note
Min Price (IDR) min_price Stepper, ±50
Min Daily Value (B IDR) min_daily_val Sheet shows billions; send raw rupiah (10,00 B → 10000000000)
Min Probability (%) min_probability 30–100
Min Signals (confluence) min_signals 1–4. The sheet's slider starts at 0; the model's floor is 1
Probability Weight prob_weight 0.3–0.8
Signal Penalty signal_penalty 0.0–0.5. Only affects the default Inverse Signal strategy

Two things the mockup should change:

recovery_v4 is much smaller:

{
  "model_id": "recovery_v4",
  "params": {
    "analysis_date": {"type":"date","default":"today"},
    "horizon":       {"type":"integer","default":20,"options":[10,20,30,40]},
    "positions":     {"type":"array","default":[]}
  }
}

horizon must be exactly one of 10/20/30/40 — the mockup's slider is continuous 10–40 and must snap to those four values, or it returns 422.

Step 4a — Run the Screener (Model F)#

Fired by "Jalankan Model F" / START.

POST /api/v1/models/model_f_combined/runs?view=compact&wait=30
Authorization: Bearer idxs_live_xxxx
X-End-User: nasabah_00471
Content-Type: application/json

{
  "params": {
    "min_price": 500,
    "min_daily_val": 10000000000,
    "min_probability": 65,
    "min_signals": 2,
    "prob_weight": 0.6,
    "signal_penalty": 0.3,
    "max_picks": 10
  }
}

Two query parameters do the work:

Response when it finished in time (200):

{
  "job_id": "job_9f2c...",
  "status": "succeeded",
  "source": "live",
  "result_available": true,
  "duration_seconds": 17.55,
  "end_user": "nasabah_00471",
  "result": {
    "model_id": "model_f_combined",
    "view": "compact",
    "as_of": "2026-06-11",
    "pick_count": 10,
    "levels": {"target_gain_pct": 10.0, "stop_loss_pct": 5.0, "entry_spread_pct": 2.0},
    "picks": [
      {"ticker":"ENRG","close":1380,"probability":83.9,"signals":0,
       "entry_low":1380,"entry_high":1408,"stop":1311,"target":1518},
      {"ticker":"ISAT","close":1895,"probability":81.6,"signals":0,
       "entry_low":1895,"entry_high":1933,"stop":1800,"target":2084}
    ],
    "model_quality": {"win_rate": 0.55, "payoff": 1.8, "candidates": 94},
    "funnel": {"total_liquid": 94, "after_probability": 94, "after_signals": 94, "final": 0}
  }
}

Response when it needs longer (202): go to Step 5.

{"job_id":"job_9f2c...","status":"running","result_available":false,
 "poll_url":"/api/v1/jobs/job_9f2c..."}

Card field mapping:

Mockup element Field Note
tk-ticker ticker
tk-close "Rp 2.680" close A number. Format for id-ID yourself
prob-ring "75.1%" probability Number, one decimal
"N sinyal konfluensi" + dots signals Integer 0–4
Entry entry_lowentry_high
Stop stop = close × (1 − stop_loss)
Target target = close × (1 + target_gain)
Data freshness banner as_of "Menggunakan data per …"

levels tells you the percentages behind those prices, so you can label the card "Target +10%" without re-deriving them. These follow the target_gain / stop_loss you sent — if you send target_gain: 0.15, target is +15%.

Step 4b — Run Portfolio Recovery (Model E)#

POST /api/v1/models/recovery_v4/runs?view=compact&wait=30

{
  "params": {
    "horizon": 20,
    "positions": [
      {"ticker": "BBCA", "pnl_input": 5.18},
      {"ticker": "GOTO", "pnl_input": -41.81},
      {"ticker": "MEDC", "pnl_input": -12.01}
    ]
  }
}

Each position takes either pnl_input (percent, as the mockup's "PnL %" column collects) or avg_price_input (your average buy price). Send one. pnl_input: -41.81 means down 41.81%.

An empty positions array fails the run — the model analyses a portfolio you hold, so there is nothing to analyse. It arrives as HTTP 200 with "status": "failed" and error.message: "⚠️ No positions entered.", not as an HTTP error. Keep the "Jalankan" button disabled until at least one row is filled, so a user never spends 5 seconds to be told that.

Response (200), ~4.5s:

{
  "status": "succeeded",
  "result": {
    "model_id": "recovery_v4",
    "view": "compact",
    "as_of": "2026-09-18",
    "horizon_days": 20,
    "summary": {"total": 3, "winners": 0, "neutral": 2, "losers": 1},
    "positions": [
      {
        "ticker": "GOTO",
        "pnl_pct": -41.81,
        "current_price": 56,
        "avg_price": 96.24,
        "outlook": "BEARISH",
        "bucket": "loser",
        "reason": "Recovery unlikely: need +72%, P75 only +8%",
        "restore_target": {"price": 60.35, "change_pct": 7.77},
        "support_level": {"price": 51.83, "change_pct": -7.44},
        "exit_window": {"day_from": 6, "day_to": 10},
        "probabilities": {"up": 54.7, "down": 33.2, "neutral": 12.1},
        "trajectory": {"CRASH":12.6,"DECLINE":20.6,"SIDEWAYS":12.1,"CLIMB":25.5,"RALLY":29.2}
      }
    ]
  }
}

Card field mapping:

Mockup element Field
Stat grid: Total / Winners / Neutral / Losers summary.total / .winners / .neutral / .losers
pos-ticker positions[].ticker
pos-pnl "-41,81%" pnl_pct (number)
pos-desc reason
outlook badge (bear/neutral/bull) outlook — or bucket (loser/neutral/winner) for styling
traj-bar segments trajectory — five states, percentages summing to ~100
Restore Target "54 (+7,9%)" restore_target.price and .change_pct
Support Level "46 (-7,9%)" support_level.price and .change_pct
Profit Window "Day 5–10" exit_window.day_from.day_to

The mockup's traj-labels currently read "Ret / Rat / PWin". Those three don't map cleanly to anything the model returns. The closest honest equivalents are probabilities.up / .down / .neutral, and the stacked bar itself should be driven by trajectory. Worth settling before build.

Step 5 — Poll (only if you got 202)#

GET /api/v1/jobs/job_9f2c...

Poll every 3 seconds. Faster gains nothing. Give up after ~5 minutes.

{"status": "running", "result_available": false}

then

{"status": "succeeded", "source": "live", "result_available": true, "result": { … }}

status is one of queued, running, succeeded, failed, interrupted.

Branch on result_available, not on status. After an hour the job still resolves with 200 and source: "ledger", but result_available is false and you get only a summary — the picks are gone. Re-run to get them back.

Step 6 — Usage history (optional)#

If you add a "riwayat analisa" screen:

GET /api/v1/usage/end-users/nasabah_00471/events?limit=20

Returns that customer's past runs with parameters, timing and a summary of what was picked — so you don't have to store any of it yourself. Needs usage:read.


3. The three result states#

Your flow diagram's Sukses / Kosong / Error branches map exactly:

Sukses#

status: "succeeded" and pick_count > 0 (or position_count > 0). Render the cards.

Kosong — "tidak ada hasil"#

status: "succeeded" and pick_count: 0. This is a success, not an error — HTTP 200, job succeeded, the filters simply matched nothing. The response carries an empty block built for this screen:

"result": {
  "pick_count": 0,
  "empty": {
    "reason": "no_results",
    "message": "Tidak ada saham yang lolos kriteria pada tanggal analisis ini. Coba longgarkan filter.",
    "funnel": {"total_liquid": 94, "after_probability": 94, "after_signals": 94, "final": 0},
    "suggestions": [
      {"label":"Min Signals","current":"4","suggested":"1",
       "why":"cukup 1 sinyal teknikal — kandidat melebar."},
      {"label":"Min Probability","current":"99%","suggested":"84%",
       "why":"probability threshold internal turun, kandidat melebar."}
    ]
  }
}

Render message, then suggestions as tappable chips that reopen the filter sheet with the suggested value pre-set. The copy is already in Bahasa Indonesia.

Ignore funnel in the UI. Its intermediate counts are unreliable for Model F — after_probability does not reflect the strategy's own filtering. Use pick_count to decide empty vs not.

Error#

Any non-2xx, or status: "failed".

{"error": {"code": "invalid_request",
           "message": "Parameter 'min_probability' must be <= 100, got 150.0.",
           "request_id": "req_a1b2c3d4"}}

Show a retry and the message. Log request_id — it matches the server log line.

Status code What the app should do
401 missing_api_key, invalid_api_key Config bug. Don't retry
403 insufficient_scope Your key lacks a scope. Don't retry
422 missing_end_user You forgot X-End-User. Don't retry
400 invalid_end_user Bad id format. Don't retry
422 invalid_request Show the message, reopen the filter sheet
429 rate_limited Back off for Retry-After seconds
503 data_unavailable Transient. Retry with backoff
500 internal_error Report with request_id
status: "failed" Model-side failure. Show error.message, offer retry

A failed run is not an HTTP error: submit returns 202, polling returns 200, and the failure appears inside the job body. Always check status.


4. Things that will bite you#

1. Data is not live. The dataset is refreshed out of band. Every result carries as_of — surface it in the freshness banner, and if it is more than a couple of days old, say so. Users acting on stale analysis while the UI implies live data is the worst failure mode here.

2. Runs cost ~15 seconds and retrain every time. There is no cheap "refresh". Don't fire a run on screen focus, on pull-to-refresh, or on every filter tweak — only on an explicit START tap. The whole platform runs two concurrent model workers, so parallel runs queue rather than speed up.

3. Rate limits are per API key, not per user. All your customers share one budget. Respect X-RateLimit-Remaining and Retry-After.

4. min_probability used to be silently ignored by Model F. Fixed on 2026-09-18. If you tested against an earlier build and concluded the slider did nothing, retest — it filters correctly now.

5. Prices are numbers, not strings, in view=compact. The full view mixes formatted strings into results ("Last Close": "8,050"). Compact never does. One more reason to use compact.

6. X-End-User must be stable. A fresh id per request still works, but makes per-user history and reporting meaningless, and nothing will warn you.


5. Quick reference#

# Method Path When Next on success
1 GET /api/v1/consent FAIR menu opened accepted → 3; else → 2
2 POST /api/v1/consent User agrees → 3
3 GET /api/v1/models/{id}/params Session start, cached → 4a / 4b
4a POST /api/v1/models/model_f_combined/runs?view=compact&wait=30 START on Screener 200 → render; 202 → 5
4b POST /api/v1/models/recovery_v4/runs?view=compact&wait=30 START on Recovery 200 → render; 202 → 5
5 GET /api/v1/jobs/{job_id} Every 3s after a 202 succeeded → render
6 GET /api/v1/usage/end-users/{id}/events History screen → render

Health check for your monitoring, no auth required: GET /api/healthdata_loaded tells you whether the first run will pay a cold-start penalty.

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