Auto trade bot guide
Build a Bot
Receive Sentinel signals, decide entries, size risk and place orders on your own exchange account.
https://ribqa.com · public, no account needed to read signals
- Ways in
- 3REST · SSE · MCP
- Endpoints named
- 16from the API catalog
- Cost model
- 0.12%per trade, fees and funding
- Settling guard
- 96hbefore a trade enters a rate
1. Get signals
REST · SSE stream · MCP
| Route | Needs | Use it for |
|---|---|---|
| REST, public | Nothing | Polling the pending list and the ledger on a schedule. |
| SSE stream | Session token or a stream ticket | Signals, vetoes and invalidations the moment they are published. |
| MCP server | API key from Account > API keys | AI agents and scripts that speak JSON-RPC. Read-only: no orders. |
| Method | Path | Access | What it is |
|---|---|---|---|
| GET | /api/public/signals/pending | public | Published signals that have not matured yet. |
| GET | /api/public/signals | public | Published signals visible without an account. |
| GET | /api/public/signals/{id} | public | One published signal: levels, engines, combo, plan, veto context, outcome net of the cost model, and headlines naming its coin within 24 h. |
| GET | /api/public/ledger/recent | public | Newest published signals with their outcome and net result, up to 500. |
# Published signals with no outcome yet. No account, no header.
curl "https://ribqa.com/api/public/signals/pending?limit=50"
# Response: {"signals":[...],"data":[...],"total":N}. signals and data are the same list.
# One signal with its plan, veto context and outcome:
curl "https://ribqa.com/api/public/signals/<id>"| Method | Path | Access | What it is |
|---|---|---|---|
| POST | /api/v1/auth/login | public | Password sign-in; returns a token or a 2FA challenge. |
| POST | /api/v1/auth/login/2fa | public | Second step of a 2FA sign-in, with a TOTP or recovery code. |
| POST | /api/v1/auth/refresh | public | Exchange a refresh token for a new access token. |
| POST | /api/v1/stream/ticket | session | Issue a short-lived single-use ticket for the SSE stream. |
| GET | /api/v1/stream | publicneeds bearer token or stream ticket | SSE event stream; it carries no auth middleware, but the handler itself requires a bearer header or a stream ticket. |
# Sign in with your own credentials. This page stores none.
curl -X POST "https://ribqa.com/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"<your-email>","password":"<your-password>"}'
# Response: access_token, refresh_token, expires_in (seconds), user.
# With two-factor on, the response is two_factor_required, challenge and
# expires_in instead, and the tokens come from /api/v1/auth/login/2fa.
export TOKEN="<access_token>"# access_token lifetime: 7200s default, 43200s with remember_me,
# 86400s with keep_signed_in. Refresh before it expires.
curl -X POST "https://ribqa.com/api/v1/auth/refresh" \
-H "Content-Type: application/json" \
-d '{"refresh_token":"<refresh_token>"}'
# The refresh token is rotated. Store the new one from the response.# Option A: bearer header.
curl -N "https://ribqa.com/api/v1/stream" -H "Authorization: Bearer $TOKEN"
# Option B: a single-use ticket, for clients that cannot set headers.
curl -X POST "https://ribqa.com/api/v1/stream/ticket" -H "Authorization: Bearer $TOKEN"
# Response: {"ticket":"...","expires_in_seconds":45,"stream_path":"/api/v1/stream"}
curl -N "https://ribqa.com/api/v1/stream?ticket=<ticket>"
# A ticket is valid once, for 45 seconds. Request one per connection.
# ?token=<access_token> is refused in production.| Event | Payload |
|---|---|
| first frame, unnamed | {"event":"connected","client":"..."} |
| signal | A full signal object, the same shape as the REST row. |
| invalidation | {"signal_id","symbol","reason"} |
| veto | signal_id, symbol, direction, reason_code, reason_text, trigger, BTC context, vetoed_at |
| btc_chart_read | id, read_at, price, direction, previous_direction, confidence, reason |
| heartbeat | {"ts"}, every 15 seconds. A gap means the connection is gone. |
| Method | Path | Access | What it is |
|---|---|---|---|
| POST | /api/v1/mcp | publicneeds MCP API key for tools/call | MCP remote (Streamable HTTP, JSON-RPC 2.0). initialize, ping and tools/list are open; tools/call needs an MCP API key. |
# API key: Account > API keys. Shown once; keep it out of URLs.
curl -s https://ribqa.com/api/v1/mcp \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SENTINEL_MCP_KEY" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"get_open_signals","arguments":{"limit":20}}}'Every value in angle brackets or after a $ is a placeholder. Put your own credentials in your own shell, never in a query string: the stream ticket exists so a URL never has to carry a token. The full tool list is on the MCP page.
2. Read a signal
20 fields
| Field | Type | What it means |
|---|---|---|
| id | string | Signal id. The same value on the stream frame and the REST row. |
| symbol | string | Binance pair name, for example SOLUSDT. |
| market_type | spot | futures | Which Binance market the candles came from. futures is a USDT-M perpetual with no spot market. Absent on older rows means spot. |
| timeframe | string | Candle interval the setup was detected on. Published signals are 4h. |
| direction | long | short | Side of the setup. |
| mode | smc_only | ind_only | hybrid | Which engine family produced it: structure, indicators, or both. |
| entry | number | Entry price. A signal whose entry is never touched is not a trade. |
| sl | number | Stop loss, placed at 2.5x ATR. |
| tp | number[] | Three targets, nearest first: tp[0] is TP1, tp[1] TP2, tp[2] TP3. The ledger books a win at TP1. |
| rr | number | Planned reward:risk from entry, stop and TP1. An intention recorded before entry. |
| confidence | number, 0 to 1 | Evidence score: how much of the engine stack agreed. Measured AUC 0.496, so it is not a win probability. |
| confluence | { source, score, weighted }[] | Per-engine contribution behind that score. |
| combo | string | Most specific matched combo id, empty for an unmatched signal. |
| regime | Bull | Bear | Sideways | Transition | Market regime recorded at scan time. The only regime value known before entry. |
| investment_score | number | Internal ranking score. Not a return estimate. |
| management_plan | { breakeven_at_r, partial_at_r, partial_fraction } | Stop to breakeven at breakeven_at_r R; partial_fraction of the position off at partial_at_r R. Absent when the signal is unmanaged. |
| regenerated | boolean | This signal replaced an earlier one for the same setup. |
| regenerated_at | RFC3339 | When that replacement happened. |
| created_at | RFC3339 | Publication time. The 96-hour settling guard keys off this, not off evaluation time. |
| expires_at | RFC3339 | End of the signal's window. A position still open at this point ages out unresolved. |
A signal is a setup, not a fill. Your bot decides whether the entry was touched, and the ledger on this site counts a position only once it was. The entry fill rate is published on the signals ledger, with its denominator.
confidence is an evidence score, not a probability. Its measured AUC is 0.496, so ranking or sizing by it does not separate winners from losers.
3. Decide entries
filters · drift · BTC veto
| Check | Why |
|---|---|
| New since you started | The pending list holds every published signal without an outcome, including old ones. Act on ids you have not seen, or check created_at and expires_at. |
| Entry drift | Compare the live price with entry before you enter. Aleph Edge refuses an entry when price has already crossed TP1 or the stop, or moved more than 0.5% against entry. |
| market_type | futures means a USDT-M perpetual with no spot market. A spot-only bot must skip it. |
| Direction and symbol | Your own lists. A spot account cannot short; skip short signals there. |
| Engine and combo | confluence[0].source is the primary engine and combo the matched combo id. The live table below is keyed the same way. |
| BTC veto context | A veto event on the stream, or a row in /api/public/signals/vetoed, cancels a signal. btcd_veto_long and pulse_alert on /api/v1/market/btc-macro are what Aleph Edge reads to pause new longs. |
| Method | Path | Access | What it is |
|---|---|---|---|
| GET | /api/public/signals/vetoed | public | Signals the guard cancelled, with the reason; registered only when the repository can list them. |
| GET | /api/v1/market/btc-macro | public | BTC macro context: trend state, volatility and dominance. |
| GET | /api/public/stats | public | Public summary counters from the ML outcome store, cached for 60 seconds. |
| Term | What happened | Where it lands |
|---|---|---|
| Veto | Sentinel cancelled a published signal because BTC's regime turned, or BTC moved more than 2% in an hour against it. | Its own feed, and its own line on every money panel. |
| Vetoed, never entered | The entry was never touched before the cancel. | Not a trade. It is a decision of ours, and counting it would let us grade our own vetoes. |
| Vetoed, already entered | The position existed and was closed early by the guard. | veto_closed_count, veto_closed_total_pnl_percent, and the net twins beside them. |
A veto close is excluded from the win rate because it has no verdict: the thesis was never allowed to reach its target or its stop. It is also excluded from the headline money scope, and published on its own line, so nothing is hidden and the two reconcile: entered_positions plus veto_closed_count equals all_entered_positions.
Engines, live, matured cohort
Loading the live engine table.
4. Risk
sizing · stop · target · plan
stop_distance = abs(entry - sl) / entry notional = capital * risk_pct / 100 / (stop_distance + 0.0012) leverage = min(notional / capital, your_leverage_cap)
| Decision | What the signal gives you | Note |
|---|---|---|
| Size | entry and sl. | With a 1% risk and a 3% stop, a stop-out costs about 1% of capital including the 0.12% cost. Aleph Edge defaults to 1% and accepts 0.1% to 5%. |
| Stop | sl, placed at 2.5x ATR. | Place it on the exchange when you open the position. A stop that lives only in your script is gone when the script is. |
| Target | tp[0] to tp[2]: TP1, TP2, TP3. | The published win rate is measured at TP1. A farther target is reached less often; measure its rate on your own paper run. |
| Breakeven | management_plan.breakeven_at_r. | Move the stop to entry once price has gone that many R in your favor. A later hit there is a scratch, not a win. |
| Partial | management_plan.partial_at_r and partial_fraction. | Take that fraction off at that many R, but only when that level is nearer than your target. |
| Time | expires_at. | The ledger ages a position out at expiry. Aleph Edge closes at the market price 72 hours after it. |
5. Execute on an exchange
your keys, your account
| Rule | Detail |
|---|---|
| Your own exchange API | Sentinel never holds exchange keys and never places exchange orders. No route of the API or the MCP server trades. |
| Trade-only key | No withdraw permission. Restrict it to your IP where the exchange allows it. |
| Symbol mapping | symbol is a Binance name. Other exchanges spell pairs differently, for example BTC-USDT-SWAP on OKX. |
| Protective orders | Stop and take profit as exchange-side orders, placed right after the fill. |
| Idempotency | A client order id per signal id, so a retry after a timeout cannot open a second position. |
| One position per signal | The same signal can arrive more than once, for example after a reconnect. Keep the ids you have acted on. |
import time, requests
API = "https://ribqa.com"
RISK_PCT, CAPITAL, COST = 1.0, 1000.0, 0.0012 # 0.12% round trip + funding
MAX_DRIFT = 0.005 # skip if price moved 0.5% against entry
def pending():
r = requests.get(f"{API}/api/public/signals/pending", params={"limit": 100}, timeout=10)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "60")))
return []
r.raise_for_status()
return r.json()["signals"]
def price(sig):
host = "fapi" if sig.get("market_type") == "futures" else "api"
path = "/fapi/v1/ticker/price" if host == "fapi" else "/api/v3/ticker/price"
r = requests.get(f"https://{host}.binance.com{path}", params={"symbol": sig["symbol"]}, timeout=10)
return float(r.json()["price"])
seen = {s["id"] for s in pending()} # act only on signals published after start
while True:
time.sleep(200) # anonymous limit is 20 requests an hour
for sig in pending():
if sig["id"] in seen:
continue
seen.add(sig["id"])
long = sig["direction"] == "long"
entry, sl, tp1, now = sig["entry"], sig["sl"], sig["tp"][0], price(sig)
drift = (now - entry) / entry * (1 if long else -1)
crossed = (now >= tp1 or now <= sl) if long else (now <= tp1 or now >= sl)
if drift > MAX_DRIFT or crossed:
print("skip", sig["symbol"], f"drift {drift:+.2%}")
continue
stop = abs(entry - sl) / entry
notional = CAPITAL * RISK_PCT / 100 / (stop + COST)
print(sig["symbol"], sig["direction"], f"notional {notional:.0f} USDT", "sl", sl, "tp1", tp1)
# place_order(...) your exchange client, on a paper or testnet account firstPrefer not to write the execution side yourself? Aleph Edge is our desktop bot for these signals. It runs in paper mode today.
6. Paper-test first
testnet or paper account
The published ledger is gross of execution effects
Run the bot on an exchange testnet or a paper account before any real balance, long enough to see a run of stop-outs. Then compare your results with /api/public/ledger/recent signal by signal: the gap between the two is your execution cost.
7. Costs and settling
0.12% per trade · 96-hour guard
| Component | Value | Basis |
|---|---|---|
| Taker fee, per side | 0.05% | Binance USDT-M futures, VIP0, no BNB discount. |
| Round trip fee | 0.10% | Two sides of one position. |
| Funding, per trade | 0.02% | About two 8-hour intervals over a median hold. A long pays it, a short receives it. |
| Modelled cost per trade | 0.12% | Round trip plus funding. Every net figure published here is charged this. |
net_total = gross_total - (0.10 * n) - (0.02 * (n_long - n_short))
Funding is not symmetric. Fees are paid by whoever trades, both directions; funding is a transfer, and while the rate is positive a long pays it and a short receives it. Charging a flat 0.12% to every trade understates the short side, which is the side this project is judged on.
Every pnl_percent the API publishes is gross, price distance only. The cost model is applied on top, once, and the net fields are named net_*. veto_closed_total_pnl_percent is gross, like every pnl_percent; its net twin is veto_closed_net_total_pnl_percent. The live cost_model object is on the ML stats payload, so a client can read the current assumption rather than copy these four numbers.
| Outcome | Definition | In the win rate? |
|---|---|---|
| success | Take profit reached. | Yes, once matured. |
| fail | Stop loss reached. | Yes, once matured. |
| settling | Decisive, but the signal was published less than 96 hours ago. | No, not yet. Published as maturing_count, maturing_wins, maturing_losses. |
| scratch | Stop ratcheted to entry under the management plan and hit there, with no offsetting partial. | No. Neither a win nor a loss. Published as scratch_count with its own average. |
| invalidated | Closed early by the BTC guard. | No. See vetoes above. |
| expired_unresolved | Aged out of its window without touching either barrier. | No, but it stays in the money. |
Why 96 hours
A breakeven scratch is outside the rate because it has no verdict, not because it is convenient. It is published on its own so the rate can never be raised by quietly scratching losers, and its measured average is published with it: assuming a scratch costs nothing is an assumption, and this one was tested.
8. Limits
per minute · per hour
| Tier | Per minute | Per hour | Bucket |
|---|---|---|---|
| Anonymous | 30 | 20 | By client IP |
| Free | 120 | 100 | By user id |
| Trial | 60 | 500 | By user id |
| Gold | 300 | 2000 | By user id |
| Platinum | 900 | 8000 | By user id |
| Aleph | 1500 | 20000 | By user id |
Both limiters run on every request and whichever is exceeded first returns the 429. Anonymous callers get 20 requests an hour, so a public-data bot should poll on a schedule, not in a loop.
| On a 429 | Do this |
|---|---|
| Retry-After | Sleep for exactly that many seconds. It is on every 429 and it is authoritative. |
| Response body | JSON: {"error":"rate_limit_exceeded","tier":"...","retry_after":N}, with "window":"minute" when the minute bucket was the one that tripped. Parse it as JSON; the Content-Type says text/plain. |
| X-RateLimit-Remaining | Present on every response, alongside X-RateLimit-Limit, X-RateLimit-Reset and X-RateLimit-Tier. Back off before you reach zero rather than after. |
| X-RateLimit-Minute-Remaining | The minute bucket's own counter, with its own Limit and Reset headers. Watch both. |
| Do not | Retry immediately, and do not spread the same key across parallel workers. The bucket is per user, not per connection. |
Honesty note
| Method | Path | Access | What it is |
|---|---|---|---|
| GET | /api/public/stats | public | Public summary counters from the ML outcome store, cached for 60 seconds. |
| GET | /api/public/ml/stats | public | Aggregated ML outcome statistics; registered only when the ML service is wired. |
| GET | /api/v1/ml/stats | session | The same aggregate as the public route, behind a session. |
Every ratio on this site carries its denominator. A win rate with no n is not a measurement, and a percentage taken over one population printed beside a percentage taken over another is the defect this page exists to avoid: matured counts and raw observation counts are different numbers and are never mixed.
Below 20 settled trades no rate is published at all, on this page or anywhere else on the site. Our outcomes also cluster by day, so a raw sample size overstates the evidence behind it; effective_sample_size on the ML stats payload is the honest count.
This is data, not financial advice