DEV Community

AlgoVault.com
AlgoVault.com

Posted on

How to turn crypto market data into an agent-ready trade call (2026)

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).

Cover

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})
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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).

API response

{
  "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."
  }
}
Enter fullscreen mode Exit fullscreen mode

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: …
Enter fullscreen mode Exit fullscreen mode

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.

Agent loop

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 Accept header → 406. The MCP streamable-HTTP transport requires the client to accept both application/json and text/event-stream. Send only application/json and the server refuses with a 406. See the MCP transport spec.
  • Default urllib User-Agent → 403. At authoring, Python's default Python-urllib/3.9 UA was refused by Cloudflare with error 1010. Any explicit User-Agent header got a 200. Set your own UA on every request.
  • The reply can be an SSE data: frame. A tools/call response often arrives as a data: line, not plain JSON. The tool's payload is the JSON string of result.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.isError and returns error_code: UPSTREAM_RATE_LIMIT with a retry_after_seconds and a suggestion field naming other venues. Retry once, respect the delay, then stop.
  • A spent quota is not a venue problem. A TIER_LIMIT_REACHED refusal carries limit and resets_at with no quota block. Do not retry — read resets_at and 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?

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)