Intro
Breadth first, depth second. One scan_trade_calls per venue returns ranked non-HOLD cells with HOLDs counted; one get_trade_call per shortlisted coin returns the full call and its factor_ledger. This post ships that loop, runnable from the Python standard library, with the live tool names and parameters. The published record, verbatim: 91.0% PFE win rate across 885,090+ verified calls, Merkle-anchored on Base L2 (the track record).
What does one scan return, and what does it leave out?
scan_trade_calls returns two shapes: a top-level envelope and a list of bare calls[] cells. The envelope carries scanned, eligible_non_hold, holds, errors, partial, calls[], _algovault and _receipts. Each bare cell carries call, coin, confidence, exchange, regime and timeframe — and nothing else.
HOLD cells are left out by default and only counted in holds. That is intentional: the loop is looking for actionable non-HOLD cells, and a HOLD is a valid call, not an error. Set includeHolds true if you want HOLD rows in the array.
The bare cell has no reasoning and no factor_ledger. That is why the second call exists. Per the tool's schema description, includeReasoning on the scan enriches each non-HOLD cell with price, the top drivers and a one-line explanation; the full factor_ledger still comes from get_trade_call. errors above zero and partial true are normal states — log them, do not swallow them.
The default lens is oi (open interest). Other lenses — volume, gainers, losers, movers, funding, volatility — live in the volume-lens post, the open-interest lens post, and the funding and volatility lens post. Mapping screener filters to scan parameters is covered separately. This loop sets only exchange, timeframe, topN, limit and keeps the default rankBy.
Implementation walkthrough: the client and the venue list
Snippet A is a stdlib MCP client. Comments carry the transport details in one clause — the hand-rolled client pitfalls (the 406 without both accept types, the User-Agent refusal, SSE parsing) are covered separately in the market-data-to-trade-call howto. What matters here: a refusal can arrive without isError, so the client tests code and error_code on the payload too.
Venues come from the live exchange enum in tools/list. Never hardcode a venue list; never print its length. The public figure is 5 derivatives venues, and it changes.
import json, urllib.request
MCP_URL = "https://api.algovault.com/mcp"
HEADERS = {"content-type": "application/json",
"accept": "application/json, text/event-stream", # both, or the server answers 406
"user-agent": "scan-call-loop/1.0"} # urllib's default UA was refused (403)
def rpc(method, params, id_):
body = json.dumps({"jsonrpc": "2.0", "id": id_, "method": method, "params": params}).encode()
with urllib.request.urlopen(urllib.request.Request(MCP_URL, body, HEADERS), timeout=60) as r:
text = r.read().decode()
for line in text.splitlines(): # SSE frame or plain JSON
if line.startswith("data:"):
return json.loads(line[5:])
return json.loads(text)
def tool(name, args, id_):
result = rpc("tools/call", {"name": name, "arguments": args}, id_)["result"]
payload = json.loads(result["content"][0]["text"])
refused = result.get("isError") or "code" in payload or "error_code" in payload
return {"_error": payload} if refused else payload # a quota refusal can arrive without isError
rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "scan-call-loop", "version": "1"}}, 1)
schema = next(t for t in rpc("tools/list", {}, 2)["result"]["tools"]
if t["name"] == "scan_trade_calls")["inputSchema"]
venues = schema["properties"]["exchange"]["enum"] # the live list, never hardcoded
print("first venues in schema:", venues[:3])
The initialize handshake is stateless: no Mcp-Session-Id needed. The transport spec is here and the tools contract is here.
Implementation walkthrough: scan, shortlist, call, log
Snippet B is the loop. For each venue in a chosen subset, run one scan, count refusals, keep non-HOLD cells. Then rank the shortlist across venues by confidence and call get_trade_call on each coin's own exchange and timeframe — the venue the cell came from. Call it on any other venue and the second answer is answering a different question.
Log call, confidence, regime, factor_ledger size, timestamp and timeframe, and the _receipts.verification_uri. Read _algovault.quota.remaining after each answered call — the payload tells you where you stand rather than any assumption about per-call cost.
WATCH, TIMEFRAME = ["BINANCE", "BYBIT"], "1h" # any subset of `venues`
targets = [v for v in WATCH if v in venues]
shortlist, refused = [], 0
for n, venue in enumerate(targets):
scan = tool("scan_trade_calls", {"exchange": venue, "timeframe": TIMEFRAME,
"topN": 30, "limit": 5}, 10 + n)
if "_error" in scan: # UPSTREAM_RATE_LIMIT, TIER_LIMIT_REACHED, ...
e, refused = scan["_error"], refused + 1
print(venue, "scan refused:", e.get("error_code") or e.get("code"),
"| retry:", e.get("retry_after_seconds") or e.get("resets_at"))
continue
print(venue, "scan:", {k: scan[k] for k in ("scanned", "eligible_non_hold", "holds", "errors", "partial")})
shortlist += [c for c in scan["calls"] if c["call"] != "HOLD"]
for i, c in enumerate(sorted(shortlist, key=lambda c: -c["confidence"])[:2]):
full = tool("get_trade_call", {"coin": c["coin"], "exchange": c["exchange"],
"timeframe": c["timeframe"]}, 20 + i)
if "_error" in full:
e = full["_error"]
print(c["coin"], "refused:", e.get("error_code") or e.get("code"),
"| retry:", e.get("retry_after_seconds") or e.get("resets_at"))
continue
r = full["_receipts"]
print(c["exchange"], c["coin"], "| scan:", c["call"], c["confidence"], "| call:", full["call"],
full["confidence"], full["regime"], "| ledger:", len(r["factor_ledger"]),
"| at:", full["timestamp"], full["timeframe"], "| receipt:", r["verification_uri"],
"| quota left:", full["_algovault"]["quota"]["remaining"])
if targets and refused == len(targets):
print("every scan was refused: no data this cycle, which is not an empty shortlist")
elif not shortlist:
print("no non-HOLD calls this cycle: log the empty scan, wait for the next bar")
Below is an example full get_trade_call payload, fetched at draft time for coin BTC on timeframe 15m with _algovault.exchange BINANCE. It is not the loop's output — the loop calls get_trade_call on each shortlisted cell's own exchange and timeframe. _receipts.track_record is elided so the lede stays the only rate citation.
{
"call": "HOLD",
"confidence": 44,
"price": 83644.8,
"indicators": {
"funding_rate": -0.00001574,
"funding_state": "ELEVATED",
"oi_change_pct": -0.09,
"volume_24h": 6896667325.96,
"trend_persistence": "MEDIUM",
"breakout_pending": "INACTIVE"
},
"regime": "RANGING",
"reasoning": "Funding at -0.0016% is unusually negative for BTC over 14 days: shorts pay longs → bullish. Against: regime is ranging with the moving averages inside the noise band → bearish. Turns directional if funding normalises.",
"timestamp": 1790560811,
"coin": "BTC",
"timeframe": "15m",
"_algovault": {
"tool": "get_trade_call",
"exchange": "BINANCE",
"quota": { "remaining": 63, "binding": "monthly" }
},
"_receipts": {
"verdict": "HOLD",
"conviction_pct": 44,
"regime": "RANGING",
"factor_ledger": [
{ "factor": "funding_state", "direction": "bullish", "value": "-0.0016%", "contributes": true, "strength": "primary" },
{ "factor": "regime", "direction": "bearish", "value": "ranging", "contributes": true, "strength": "primary" },
{ "factor": "price_change_24h", "direction": "bearish", "value": "down", "contributes": true, "strength": "supporting" }
],
"track_record": { … },
"verification_uri": "https://algovault.com/track-record",
"disclaimer": "Informational analytics, not investment advice. Past performance does not guarantee future results."
}
}
The verification_uri points at the published record, not a per-call proof — the payload carries no call id and no expiry. Log timestamp and timeframe beside it so a reader knows when the call was made and where its record lives. Cross-verify at the verify page.
What does one cycle look like?
Snippet C shows two cycles: one answered, one where the daily allowance was spent. An empty shortlist is a normal result — log it and wait for the next bar. A cycle where every scan was refused is not an empty shortlist; it is no data at all, and the loop must say so.
Each call in the loop is computed for the venue you passed in exchange. "Cross-exchange" here means the loop spans venues — never that one call fuses every venue.
first venues in schema: ['HL', 'BINANCE', 'BYBIT']
BINANCE scan: {'scanned': …, 'eligible_non_hold': …, 'holds': …, 'errors': …, 'partial': False}
BYBIT scan: {'scanned': …, 'eligible_non_hold': …, 'holds': …, 'errors': …, 'partial': False}
BYBIT … | scan: BUY … | call: BUY … … | ledger: … | at: … 1h | receipt: https://algovault.com/track-record | quota left: …
# a cycle after the daily allowance is spent:
BINANCE scan refused: TIER_LIMIT_REACHED | retry: …
BYBIT scan refused: TIER_LIMIT_REACHED | retry: …
every scan was refused: no data this cycle, which is not an empty shortlist
A pitfall to avoid: treating the loop's plumbing as optional
Five things break a loop that only reads calls[] and moves on.
errors and partial are signal, not noise. A missing coin from the scan is not a HOLD — it is a coin the scan could not evaluate this cycle. Log the counters and the partial flag before you touch calls.
Refusals arrive in more than one shape. A venue refusal sets isError, carries UPSTREAM_RATE_LIMIT with retry_after_seconds and often a suggestion naming other venues — back off, or move venue. A spent allowance carries TIER_LIMIT_REACHED with resets_at — stop until reset. The scan version of the allowance refusal arrives without isError. A client that tests only isError reads the refusal as data. Test for code and error_code on the payload too.
Call on the cell's own exchange and timeframe. The depth call must use the same venue and timeframe the shortlisted cell reported. Any other venue is a different question with a different answer.
verification_uri is not a per-call proof. It is the record page for the whole run of calls. Log timestamp and timeframe alongside it so a specific call is locatable.
Quota is on the payload. Every answered call carries _algovault.quota.remaining. Read it, do not guess. Free tier is 200 calls/month, keyless.
FAQ
Why two tools? Breadth wants small, ranked cells across many coins. Depth wants the full call — regime, drivers, factor ledger, reasoning — for one coin. Cramming both into one tool would make either the scan too heavy or the call too thin. Two tools, one loop.
Which venues should I scan? Whichever subset of the live exchange enum matches your universe. The snippet reads the enum from tools/list and filters — never hardcode.
What if every scan comes back empty? That is a normal cycle. Log eligible_non_hold zero and wait for the next bar. It is different from every scan being refused, which is a plumbing state.
How do I stretch the free tier? Widen the timeframe, narrow the venue subset, and skip depth calls when the scan cell already gives you enough. Read _algovault.quota.remaining after each answered call.
What's Next?
Run get_trade_call free — 200 calls/month →
⭐ Star the repo to follow new exchanges and signals: https://github.com/AlgoVaultLabs/crypto-quant-signal-mcp



Top comments (0)