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.
- Interactive reference (Swagger UI):
/api/docs - OpenAPI spec:
/api/openapi.json— import it into Postman, Insomnia, or an SDK generator - API version: 1.0.0
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:
- Keep it in an environment variable or secret manager, never in source control.
- Never ship it in front-end code. Anyone who views your page source can read it, and the key carries your full scopes.
- Call this API from your backend, and proxy for your browser clients.
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:
- Stable per user, for that user's lifetime. A fresh value per request — a new UUID each time, say — makes every per-user total meaningless, and we cannot detect that from our side.
- Opaque.
[A-Za-z0-9._-], 1–64 characters. Anything else is rejected with400 invalid_end_userrather than quietly cleaned up, because an id we altered is one your own queries would never match. - Not personal data. Send your internal user key, not an email, name or phone
number —
@is rejected for exactly this reason. These ids persist in our ledger for the retention window, and your users' expectations are your responsibility as much as ours.
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
queued → running → succeeded 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:
resultis present only whenstatusissucceeded. Onfailedyou get anerrorobject instead, with codemodel_error(the model rejected the request, e.g. no positions supplied) ormodel_crashed(a bug — report it with therequest_id).- The full result is kept for one hour, then evicted. After that the job id still resolves — see When a job goes cold below — but the picks are gone. Fetch them inside that window, or re-run.
- Jobs are private to the key that created them. Another key's job id returns
404, not403. - Jobs are held in memory, so a server restart drops them. Results are only meaningful for the trading day they were computed on anyway.
- Run concurrency on the server is small (2 by default). Under load your job
waits in
queuedrather than being rejected.
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_resultrows 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:
- Every response that depends on market data carries the date it reflects
(
date,latest_dateortarget_date). Surface it in your UI. If your users might act on a stale answer, compare it against today and say so. GET /api/healthreportsdata_loaded. Right after a restart it isfalseand the first data request pays a one-off load cost of several seconds. Warming the cache with a cheap call after deploys avoids a slow first request for a user.
10. Versioning#
Paths are versioned (/api/v1/...). Within v1 we may add endpoints, add fields to
responses, and add optional parameters. So:
- Ignore unknown fields rather than failing on them.
- Do not depend on key order in an object.
- Removals or changes of meaning come with a new version prefix.
- New
statusvalues can appear withinv1—interruptedwas added this way. Treat an unrecognised status as a non-terminal failure rather than crashing.
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:
- Both processes must be able to write
data/, not just the DB file. WAL createsusage.db-walandusage.db-shmalongside it. If the API and the bot run as different OS users, the second one's writes fail — and they fail silently, by design, so that a ledger problem can never break a model run.GET /api/healthreportsusage.writableandusage.rows; check it after a deploy, and checkls -l data/usage.db*after each process's first write. - Keep this database on local disk. WAL needs working shared memory and advisory locking, which network filesystems do not provide reliably. The failure mode there is corruption, not an error message.
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.
docs/API.md in the idx-screener repository. This page is rendered from that file, so the repo stays the single source of truth.