Intro
You can hand-build a crypto trade call from raw candles in a few dozen lines of Python. But every choice inside those lines — indicator set, RSI flavour, thresholds, venue, whether the open candle counts — is yours to defend to your agent. One get_trade_call over MCP returns the decision alongside the fields that explain it. This post builds both, side by side, with the Python standard library. The published record, verbatim: 91.1% PFE win rate across 828,279+ verified calls, Merkle-anchored on Base L2 (the track record).
What does market data look like when your agent fetches it?
The Hyperliquid public info API returns candles from one POST. No key. The Hyperliquid info-endpoint docs document candleSnapshot: a request body of {"type":"candleSnapshot","req":{"coin":"BTC","interval":"1h","startTime":MILLISECONDS}} returns a JSON array whose entries carry short keys — t, T, s, i, o, c, h, l, v, n. Prices are strings. Time buckets are milliseconds. The newest candle in the reply is still open: its close will keep moving until the interval ticks over.
That last fact matters. Every indicator you compute on top of these candles inherits an unresolved final bar. Your agent has to know whether the number it just read is a closed observation or a live tick. This is the first hidden decision the hand-built path takes silently: does the last candle count?
Everything downstream — the moving averages, the RSI, the crossover, the "is trend intact" test — is a chain of choices layered on top of raw prices. Fetching data is the easy half. Turning it into a defensible decision is the work.
Implementation walkthrough, part one: build the decision by hand
Snippet A does three things: it fetches closing prices from Hyperliquid, it computes a fast EMA, a slow EMA, and a 14-period RSI, and it applies a naive BUY / SELL / HOLD rule. It is a strawman that exposes the decisions you are making — never a benchmark.
import json, time, urllib.request
HL_INFO = "https://api.hyperliquid.xyz/info"
MCP_URL = "https://api.algovault.com/mcp"
UA = {"user-agent": "hand-vs-call/1.0"} # urllib's default UA was refused (403) at authoring
def post_json(url, body, headers=None):
req = urllib.request.Request(url, data=json.dumps(body).encode(),
headers={"content-type": "application/json", **UA, **(headers or {})})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read().decode()
def closes(coin="BTC", interval="1h", hours=120):
start = int((time.time() - hours * 3600) * 1000)
raw = post_json(HL_INFO, {"type": "candleSnapshot",
"req": {"coin": coin, "interval": interval, "startTime": start}})
return [float(c["c"]) for c in json.loads(raw)] # oldest first; the last candle is still open
def ema(xs, n):
k, e = 2 / (n + 1), xs[0]
for x in xs[1:]:
e = x * k + e * (1 - k)
return e
def rsi(xs, n=14): # simple averages, not Wilder smoothing
deltas = [b - a for a, b in zip(xs[-n - 1:-1], xs[-n:])]
gain = sum(d for d in deltas if d > 0) / n
loss = sum(-d for d in deltas if d < 0) / n
return 100.0 if loss == 0 else 100 - 100 / (1 + gain / loss)
px = closes()
fast, slow, r = ema(px, 20), ema(px, 50), rsi(px)
decision = "BUY" if fast > slow and r < 70 else "SELL" if fast < slow and r > 30 else "HOLD"
print("hand-built:", {"bars": len(px), "ema20>ema50": fast > slow, "rsi14": round(r, 1), "decision": decision})
Count the choices that just walked past you. Two indicators, out of dozens. ema20 and ema50, not ema10/ema30 or ema12/ema26. A simple-average RSI, not Wilder smoothing. Thresholds 70 and 30, borrowed from a Welles Wilder book. The 1h interval, on Hyperliquid, on BTC. And the still-open final candle, silently included. Every one of those is a value your agent must justify — or blindly inherit.
Implementation walkthrough, part two: replace it with one call
Snippet B asks the same question with one get_trade_call over MCP. Same file, same standard library. Two headers matter, and one refusal shape matters.
MCP_HEADERS = {"accept": "application/json, text/event-stream"} # both, or the server answers 406
def rpc(method, params, id_):
text = post_json(MCP_URL, {"jsonrpc": "2.0", "id": id_, "method": method, "params": params}, MCP_HEADERS)
for line in text.splitlines(): # the reply is an SSE frame ...
if line.startswith("data:"):
return json.loads(line[5:])
return json.loads(text) # ... or plain JSON
def get_trade_call(args, id_):
for attempt in (1, 2): # one retry after an upstream refusal
res = rpc("tools/call", {"name": "get_trade_call", "arguments": args}, id_)["result"]
payload = json.loads(res["content"][0]["text"])
if not res.get("isError"):
return payload
if attempt == 1 and payload.get("error_code") == "UPSTREAM_RATE_LIMIT":
time.sleep(payload.get("retry_after_seconds", 10))
else:
raise SystemExit(payload) # a venue refusal names other venues; a quota refusal carries resets_at
rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "hand-vs-call", "version": "1"}}, 1)
call = get_trade_call({"coin": "BTC", "timeframe": "1h", "exchange": "HL"}, 2) # same venue as the candles
print("get_trade_call:", {k: call[k] for k in ("call", "confidence", "regime", "timeframe", "timestamp")})
print("factor_ledger:", [(f["factor"], f["direction"], f["strength"]) for f in call["_receipts"]["factor_ledger"]])
print("receipt:", call["_receipts"]["verification_uri"], "| quota left:", call["_algovault"]["quota"]["remaining"])
Below is an example get_trade_call payload fetched at draft time — coin BTC, timeframe 15m, _algovault.exchange BINANCE (Snippet B asks for 1h on HL, so its output shape matches but its values will differ).
{
"call": "HOLD",
"confidence": 8,
"price": 86332.9,
"indicators": {
"funding_rate": 0.00003371,
"funding_state": "NORMAL",
"oi_change_pct": -0.74,
"oi_change_window": "24h",
"volume_24h": 13943069833.73,
"trend_persistence": "MEDIUM",
"breakout_pending": "IMMINENT",
"underlying_session": "ALWAYS_OPEN"
},
"regime": "RANGING",
"reasoning": "Regime is ranging with the moving averages inside the noise band → bullish. Funding at +0.0034% sits in BTC's normal 14-day band: no crowd pressure either way. Becomes actionable if the breakout resolves.",
"timestamp": 1790128816,
"coin": "BTC",
"timeframe": "15m",
"_algovault": {
"tool": "get_trade_call",
"exchange": "BINANCE",
"quota": { "remaining": 76, "binding": "monthly", "…": "…" }
},
"_receipts": {
"verdict": "HOLD",
"conviction_pct": 8,
"regime": "RANGING",
"factor_ledger": [
{ "factor": "regime", "direction": "bullish", "value": "ranging", "contributes": true, "strength": "primary" },
{ "factor": "price_change_24h", "direction": "bullish", "value": "up", "contributes": true, "strength": "supporting" },
{ "factor": "funding_state", "direction": "neutral", "value": "+0.0034%", "contributes": true, "strength": "none" },
{ "factor": "trend_persistence", "direction": "neutral", "value": "MEDIUM", "contributes": true, "strength": "none" },
{ "factor": "breakout_pending", "direction": "neutral", "value": "IMMINENT", "contributes": true, "strength": "none" },
{ "factor": "oi_change_pct", "direction": "neutral", "value": "-0.7%", "contributes": false, "strength": "none" },
{ "factor": "volume_24h", "direction": "neutral", "value": "$13.94B", "contributes": false, "strength": "none" }
],
"track_record": { … },
"verification_uri": "https://algovault.com/track-record",
"disclaimer": "Informational analytics, not investment advice. Past performance does not guarantee future results."
}
}
Notice what a single call returns. A verdict (call) and a confidence (confidence). A regime tag (regime). Concrete features (indicators) — funding, open-interest change, volume, trend persistence, an underlying-session flag. A factor ledger showing which terms contributed and how strongly. An audit receipt with a verification URL. A quota block with a live remaining count. All of that in one return value.
What does the hand-built path leave your agent to decide?
Snippet C shows both outputs, shape only — every run will differ.
hand-built: {'bars': …, 'ema20>ema50': …, 'rsi14': …, 'decision': …}
get_trade_call: {'call': …, 'confidence': …, 'regime': …, 'timeframe': '1h', 'timestamp': …}
factor_ledger: [('price_change_24h', …, …), ('funding_state', …, …), ('regime', …, …), …]
receipt: https://algovault.com/track-record | quota left: …
The table below maps each decision the hand-built path makes silently to the field get_trade_call returns for it.
| You choose (hand-built) |
get_trade_call returns |
|---|---|
| Indicator set, lengths, RSI flavour |
_receipts.factor_ledger[] — factor, direction, value, contributes, strength
|
Thresholds (70 / 30, EMA cross) |
call + confidence
|
| Regime detection (or omitted entirely) |
regime (RANGING, TRENDING_UP, TRENDING_DOWN, …) |
| Funding, open interest, volume — fetch + weigh |
indicators.funding_state, oi_change_pct, volume_24h, trend_persistence
|
| Venue (single-exchange, no cross-check) |
exchange parameter — cross-venue composite is the default |
| Freshness / whether the open candle counts |
timestamp + timeframe (no expiry field — the call is stateless per timeframe) |
| Audit trail — "why did the model say this?" |
_receipts.track_record, verification_uri, disclaimer
|
| Cost accounting |
_algovault.quota — remaining, binding, daily
|
Read this as a checklist, not a benchmark. The two paths are answering the same question with different amounts of work behind each cell.
Why can the two paths disagree?
Here is one dated observation, from a single authoring run. Snippet A's naive rule printed SELL. Snippet B's get_trade_call returned HOLD, regime TRENDING_DOWN, with a factor ledger in which only price_change_24h carried a non-none strength. A reader's run will differ; the two may well agree.
Why did they diverge in that run? The hand-built rule looked at two indicators on closing prices, on one venue, and picked a side. The factor ledger showed the call weighed more terms and found most of them at strength: none — nothing pushing hard enough to justify a directional call. HOLD is a valid call; it means the composite of measured factors did not clear the conviction bar. Neither output is a trade instruction, and neither is being scored against the other. We provide the thesis; agents decide execution. The published record on the track record page is the standalone measurement.
A pitfall to avoid: the client details that break a hand-rolled MCP call
This post owns the hand-rolled client pitfalls in full — sibling posts point here.
-
No dual
Acceptheader →406. The MCP streamable-HTTP transport requires the client to accept bothapplication/jsonandtext/event-stream. Send onlyapplication/jsonand the server refuses with a406. See the MCP transport spec. -
Default urllib User-Agent →
403. At authoring, Python's defaultPython-urllib/3.9UA was refused by Cloudflare with error1010. Any explicitUser-Agentheader got a200. Set your own UA on every request. -
The reply can be an SSE
data:frame. Atools/callresponse often arrives as adata:line, not plain JSON. The tool's payload is the JSON string ofresult.content[0].text; a second text block holds a one-line human summary. Parse both shapes — Snippet B does this in five lines. -
Upstream venue refusal — retry once, then move venue. A refused call sets
result.isErrorand returnserror_code: UPSTREAM_RATE_LIMITwith aretry_after_secondsand asuggestionfield naming other venues. Retry once, respect the delay, then stop. -
A spent quota is not a venue problem. A
TIER_LIMIT_REACHEDrefusal carrieslimitandresets_atwith no quota block. Do not retry — readresets_atand back off until the window rolls.
None of these are documented in the average "hello, MCP" post. All of them will bite a hand-rolled client on day one. Verify your own responses at algovault.com/verify.
FAQ
Why not just stick with the two-indicator rule? Because it leaves every other decision — regime, cross-venue check, funding context, audit trail, freshness — to you or your agent, silently. Snippet A is the exposure; the table is the receipt.
Which venue should my agent request? Pass a specific exchange (like HL) when you want a venue-scoped call. Omit it and the composite runs across all live venues — see the AlgoVault docs for the current list.
How fresh is the call? The timestamp plus your requested timeframe (1m through 1d) tell you everything. There is no expiry field; the call is computed on demand for the timeframe you asked for.
What does a free run cost? Nothing. The free tier is 200 calls per month, keyless. _algovault.quota.remaining on every answered call tells you where you stand.
What's Next?
- the track record — the standalone PFE measurement, Merkle-anchored on Base L2
- the docs — quickstart, tool schema, venue list
- the GitHub repo — the MCP server itself
- The decision-versus-data answer: which MCP server gives your agent crypto trade calls, not data?
- Deciding whether to hand-roll at all: build vs buy a trading model
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)