# BotPit — Build-a-Bot Spec

> BotPit is a paper-trading tournament platform. Users build trading bots that
> compete on equal starting capital ($100,000 USD), equal fee schedules, and
> equal time windows. Winners become copyable with real capital on partner
> exchanges. Paper money, real leaderboard, real prizes.

This document is the complete contract for submitting a bot to BotPit. If
you are an AI coding assistant (Cursor, Claude Code, ChatGPT, Copilot, v0,
etc.), everything below is sufficient to produce a working bot in the
user's language of choice.

## TL;DR

1. User creates an agent at https://www.botpit.io/dashboard and gets an agent ID.
2. User generates a TradingView-style token (`aatv_<hex>`) from the agent
   admin page — used as a bearer credential for signal submission.
3. User builds a bot that POSTs trading signals to one endpoint:
   ```
   POST https://www.botpit.io/api/v1/tv/signals
   ```
4. BotPit simulates fills against live Binance futures mark prices, tracks
   equity curve, ranks against other bots on the leaderboard.

## Two auth paths — pick the one that fits your client

BotPit issues two distinct credential sets per agent. Use whichever one
fits your bot's runtime:

| Path | Credential | Signing | Best for |
|---|---|---|---|
| **HMAC code-bot** | `aa_pub_<base64url>` + `aa_sec_<base64url>` keypair | HMAC-SHA256 over `{timestamp}.{rawBody}` | Code bots in any language — the secret never leaves your runtime, requests are signed per-call, no token-in-URL leakage. **This is the default credential issued at agent creation.** |
| **TradingView token** | `aatv_<64 hex>` bearer token | None — token presented as-is | TradingView Pine alerts, MDX AlgoMaster / BotMaster, and any client that can't compute HMAC at fire time. Less secure but more compatible. |

Both paths route to the same matching engine and the same leaderboard.
You can use both on the same agent (e.g. HMAC for your Python bot's
state polling and a TV token in TradingView's webhook UI for an
indicator-driven entry signal) — credentials are independent.

## Endpoints

| Method | URL | HMAC? | TV token? | Purpose |
|---|---|---|---|---|
| POST | `https://www.botpit.io/api/v1/signals` | ✅ required | ❌ | Submit a trading signal — HMAC code-bot path. |
| POST | `https://www.botpit.io/api/v1/tv/signals` | ❌ | ✅ required | Submit a trading signal — TradingView path. |
| GET  | `https://www.botpit.io/api/v1/tv/state` | ✅ | ✅ | Read open positions, recent fills, recent signals (with reject reasons), current equity. |
| GET  | `https://www.botpit.io/api/v1/tv/tournament` | ✅ | ✅ | Read live tournament config — allowed pairs, leverage cap, fees, scoring formula, dates. |

The two GET endpoints accept either credential — code-bots authenticated
via HMAC don't need to also generate a TV token just to read state.

## HMAC code-bot path — `POST /api/v1/signals`

```
POST https://www.botpit.io/api/v1/signals
Content-Type: application/json
Agent-Arena-Key: aa_pub_<your-public-key>
Agent-Arena-Signature: t=<unix_ms>,v1=<hex>

{"nonce":1745234567,"pair":"BTC-USDT","side":"long","order_type":"market","size":{"mode":"pct_equity","value":10},"leverage":5}
```

**Signature recipe:**

1. Read your raw request body as a UTF-8 string `body` (byte-exact —
   no pretty-printing differences).
2. Take the current Unix time in **milliseconds** as `t`.
3. Compute `v1 = HMAC-SHA256(secret, f"{t}.{body}").hex()`.
4. Send `Agent-Arena-Signature: t={t},v1={v1}`.

**Replay window:** `t` must be within **300 seconds** of server time.
If your bot is on a sleeping laptop with bad clock sync you'll see
`SIGNATURE_TIMESTAMP_SKEW`.

**Body schema (HMAC path):**

| Field | Type | Required? | Notes |
|---|---|---|---|
| `nonce` | int | yes | Strictly monotonic per (agent, public key). Server rejects `<= last_seen` with 409 `NONCE_DUPLICATE`. Use Unix ms or any monotonic counter. |
| `pair` | string | yes | `BTC-USDT` / `ETH-USDT` / `SOL-USDT` / `PAXG-USDT` |
| `side` | enum | yes | `long` / `short` / `close` |
| `order_type` | enum | yes | `market` (others reserved for future) |
| `size.mode` | enum | yes | `pct_equity` (others reserved) |
| `size.value` | number | yes | 0 < value ≤ 100 for pct_equity |
| `leverage` | number | optional | 1–20. Defaults to 1 if omitted. |
| `stop_price` / `tp` / `sl` / `reduce_only` / `client_metadata` | various | optional | Reserved; not enforced server-side in v0.1. |

**HMAC error codes (sync, in POST response):**

| HTTP | `reason_code` | Cause |
|---|---|---|
| 401 | `MISSING_KEY_HEADER` | `Agent-Arena-Key` header absent |
| 401 | `MISSING_SIGNATURE_HEADER` | `Agent-Arena-Signature` header absent |
| 401 | `MALFORMED_SIGNATURE` | Signature header didn't parse as `t=...,v1=...` |
| 401 | `SIGNATURE_INVALID` | HMAC didn't match (wrong secret, body changed, etc.) |
| 401 | `SIGNATURE_TIMESTAMP_SKEW` | `t` outside ±300s of server time |
| 401 | `KEY_REVOKED` | Public key has been revoked |
| 400 | `MALFORMED_PAYLOAD` | Body isn't valid JSON, or schema validation failed |
| 409 | `NONCE_DUPLICATE` | Nonce ≤ last accepted nonce for this key |
| 400 | `TOURNAMENT_NOT_LIVE` | Agent isn't in a live tournament |
| 400 | `PAIR_NOT_ALLOWED` | Pair not in tournament's allowlist |

**One-line bash test** (matches what the agent admin page shows):

```bash
PUBKEY="aa_pub_..."
SECRET="aa_sec_..."
T=$(date +%s%3N)
BODY='{"nonce":'$T',"pair":"BTC-USDT","side":"long","order_type":"market","size":{"mode":"pct_equity","value":10},"leverage":5}'
SIG=$(printf "%s.%s" "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST "https://www.botpit.io/api/v1/signals" \
  -H "Content-Type: application/json" \
  -H "Agent-Arena-Key: $PUBKEY" \
  -H "Agent-Arena-Signature: t=$T,v1=$SIG" \
  --data-binary "$BODY"
```

## TradingView path — `POST /api/v1/tv/signals`

For the rest of this section the auth shape is the `aatv_<token>` bearer
documented below. The signal vocabulary, body shape, and async error
codes are identical between paths — they map onto the same matching
engine.

**Auth:** `aatv_<64 hex chars>` token, presented in any of:

- `Authorization: Bearer aatv_...` header (**recommended for code clients** — keeps the token out of access logs / referer headers)
- `x-botpit-token: aatv_...` header
- `?token=aatv_...` URL query string (required for TradingView's webhook UI; ends up in proxy/load-balancer logs — prefer the header forms when you control the client)
- POST signals only: `{ "token": "aatv_..." }` in the JSON body

## Two accepted body shapes

### Shape A — URL-query config + plain-text body (recommended for indicators)

Use this when your alert mechanism can't produce arbitrary JSON. Typical
for TradingView Pine indicators that fire via `alert()` — they can't
substitute template variables inside the alert message.

```
POST https://www.botpit.io/api/v1/tv/signals?token=aatv_<your-token>&pair=BTC-USDT&size=10&leverage=5
Content-Type: text/plain

Buy Entry
```

The body is the alert-event label. Normalisation (applied to both `event`
field and plain-text body) is: `lowercase → collapse runs of whitespace
and hyphens to a single underscore`, then look up in the vocabulary. So
`"Buy Entry"`, `"buy-entry"`, `"BUY_ENTRY"`, `"buy   entry"`, and
`"buy_entry"` all resolve identically. Underscores in the input are
preserved as-is; anything not matching the vocabulary returns
`ACTION_INVALID`.

### Shape B — JSON body (recommended for custom bots)

Use this when your bot emits structured data — Pine strategies with
template-substitutable Message fields, Python/Node scripts, etc.

```
POST https://www.botpit.io/api/v1/tv/signals
Content-Type: application/json

{
  "token": "aatv_<your-token>",
  "event": "buy_entry",
  "pair": "BTC-USDT",
  "size_pct_equity": 10,
  "leverage": 5,
  "nonce": 1745234567
}
```

Alternatively use `"action": "buy"` / `"sell"` / `"close"` instead of
`event`. Either field is accepted; `event` wins if both present.

## Event / action vocabulary

| Input | Side | What happens |
|---|---|---|
| `buy_entry`, `buy_reentry`, `buy`, `long` | `long` | Open new long. Rejected if you already have an open position on the pair (BotPit doesn't support adding). |
| `sell_entry`, `sell_reentry`, `sell`, `short` | `short` | Open new short. Same no-add rule. |
| `buy_exit`, `sell_exit`, `buy_tp`, `sell_tp`, `buy_sl`, `sell_sl`, `buy_take_profit`, `sell_take_profit`, `buy_stop_loss`, `sell_stop_loss`, `close`, `flat`, `exit` | `close` | Flatten whichever side is open. Rejected if already flat (`NO_POSITION_TO_CLOSE`). |

Case-insensitive. Hyphens, spaces, and underscores are interchangeable.

## Body field reference (JSON shape)

| Field | Type | Required? | Notes |
|---|---|---|---|
| `token` | string | yes (or in URL) | Agent's bearer token, `aatv_<hex>`. |
| `event` or `action` | string | yes | See vocabulary above. |
| `pair` | string | yes (or in URL) | One of: `BTC-USDT`, `ETH-USDT`, `SOL-USDT`, `PAXG-USDT`. Must match the tournament's allowed-pairs config. |
| `size_pct_equity` | number 0–100 | yes for entries | Percentage of current equity to deploy. Required for open; ignored for close. |
| `leverage` | number 1–20 | optional | Tournament cap is 20×. Defaults to 1. |
| `nonce` | number | optional | Unix timestamp (ms or s). Server generates one from receive time if omitted. |
| `timeframe` | string | optional | Bar interval label ("3m", "1h", etc). Server auto-infers from signal cadence if omitted — any TF works, including unusual ones like 12m or 17m. |

## Supported pairs

| Pair | Binance symbol |
|---|---|
| BTC-USDT | `BINANCE:BTCUSDT.P` |
| ETH-USDT | `BINANCE:ETHUSDT.P` |
| SOL-USDT | `BINANCE:SOLUSDT.P` |
| PAXG-USDT | `BINANCE:PAXGUSDT.P` (tokenised gold) |

Fills are matched against Binance **perpetual futures** mark prices. If
your bot generates signals from a non-Binance-perp chart (Coinbase spot,
Hyperliquid, etc.), your entry/exit prices won't reconcile with the
fill prices.

## Response

```
HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "status": "queued",
  "signal_id": "33629208-2582-4b2c-9645-a1da22ef1a28",
  "t_received": "2026-04-24T11:07:58.491Z",
  "tournament_id": "..."
}
```

## Error codes

There are two error surfaces — **synchronous** rejections (returned in the
4xx response to your POST) and **asynchronous** rejections (returned in the
`recent_signals` array of `/api/v1/tv/state`, when the matching engine
processes your signal a moment after you POST it).

### Synchronous (returned in the POST response)

All return 4xx + JSON `{ "status": "rejected", "reason_code": ... }`:

| HTTP | `reason_code` | Cause / fix |
|---|---|---|
| 400 | `INVALID_JSON` | Body isn't valid JSON and isn't a known plain-text event. |
| 401 | `TOKEN_MISSING_OR_MALFORMED` | Missing or wrong-format `token`. Must start with `aatv_`. |
| 401 | `TOKEN_UNKNOWN` | Token doesn't match any active agent. Rotate the token. |
| 400 | `ACTION_INVALID` | Event/action didn't resolve to a known side. Check vocabulary. |
| 400 | `SIZE_INVALID` | `size_pct_equity` out of range (must be 0 < x ≤ 100) for entry signals. |
| 400 | `SCHEMA_INVALID` | Body failed schema validation (type mismatch on pair, leverage, etc.). |
| 400 | `TOURNAMENT_NOT_LIVE` | Agent isn't entered in a live tournament. Check agent's league status. |
| 400 | `PAIR_NOT_ALLOWED` | `pair` isn't in this tournament's allowed list. |
| 500 | `INTERNAL_ERROR` | Engine-side problem — rare. Retry. |

Note on nonces: a colliding nonce does **not** return an error. The server
silently bumps it to `latest+1` and accepts the signal. You'll see the
final nonce in the `/api/v1/tv/state` response if you need to reconcile.

### Asynchronous (matching-engine rejects, visible via `/api/v1/tv/state`)

The POST returns `202 Accepted` and the matching engine processes the
signal a few seconds later. If the engine rejects it, the `recent_signals`
array in the state endpoint will show `status: "rejected"` with one of:

| `reason_code` | Cause |
|---|---|
| `NO_POSITION_TO_CLOSE` | Sent `close` (or `buy_exit` / `sell_tp` / etc.) when no position was open on that pair. Safe no-op — your bot's local state was wrong. |
| `POSITION_ADD_UNSUPPORTED` | Sent `buy_entry` while already long on the pair (or `sell_entry` while short). v0.1 doesn't support adding to positions — close before re-entering. |
| `POSITION_FLIP_UNSUPPORTED` | Sent the opposite-side entry while a position was open (e.g. `sell_entry` while long). Close first, then open the other side. |

A robust bot polls `/api/v1/tv/state` after each POST (or on a periodic
heartbeat) to reconcile what actually happened with what it thinks happened.

## Reading state — `GET /api/v1/tv/state`

```
GET https://www.botpit.io/api/v1/tv/state
Authorization: Bearer aatv_<your-token>
```

Returns:

```json
{
  "tournament": { "id": "...", "name": "...", "kind": "weekly_league", "ends_at": "..." },
  "equity": {
    "starting_usd": 100000,
    "current_usd": 102350.12,
    "peak_usd": 103120.00,
    "return_pct": 2.3501,
    "drawdown_pct": 0.7468,
    "realized_pnl_usd": 1820.50,
    "unrealized_pnl_usd": 529.62,
    "as_of": "2026-04-25T12:34:56.789Z"
  },
  "positions": [
    { "pair": "BTC-USDT", "side": "long", "size_units": 0.0418,
      "entry_price": 60123.45, "leverage": 5,
      "unrealized_pnl_usd": 529.62, "opened_at": "..." }
  ],
  "recent_fills": [
    { "pair": "BTC-USDT", "side": "long", "price": 60123.45,
      "size_units": 0.0418, "size_usd": 2513.12,
      "fee_usd": 0.50, "slippage_bps": 2, "filled_at": "..." }
  ],
  "recent_signals": [
    { "signal_id": "...", "nonce": 1745234567, "status": "filled",
      "reason_code": null, "reason": null,
      "t_received": "...", "t_processed": "..." }
  ]
}
```

This is the single best place to discover (a) **your true fill price**, which
includes the 2 bp slippage and is what client-side stops should reference;
(b) **why a signal was rejected**, including the async matching-engine codes;
and (c) **whether you actually have an open position**, so a bot recovering
from a restart can re-build its mental model from the truth.

## Reading config — `GET /api/v1/tv/tournament`

```
GET https://www.botpit.io/api/v1/tv/tournament
Authorization: Bearer aatv_<your-token>
```

Returns the live tournament's configuration:

```json
{
  "tournament": {
    "id": "...", "name": "Shrimp · wk 1", "kind": "weekly_league",
    "state": "LIVE",
    "starts_at": "...", "ends_at": "...",
    "league": { "name": "Shrimp", "tier": 1 }
  },
  "rules": {
    "starting_equity_usd": 100000,
    "leverage_cap": 20,
    "allowed_pairs": ["BTC-USDT", "ETH-USDT", "SOL-USDT", "PAXG-USDT"],
    "fee_bps_taker": 2,
    "fee_bps_maker": 0,
    "slippage_bps_market": 2,
    "dq_threshold_pct": 20
  },
  "scoring": {
    "formula": "return_pct - 2 * max_drawdown_pct",
    "drawdown_penalty_k": 2
  }
}
```

Special-event tournaments may restrict `allowed_pairs` (e.g. a BTC-only cup).
Fetching this endpoint at the start of each session is the right way to
discover what's legal — don't hardcode the pair list.

## Tournament mechanics your bot is competing in

- **Starting equity**: $100,000 USD (paper).
- **Fees**: 2 bps taker, 0 bps maker. Applied per fill at the notional
  side of the trade.
- **Slippage**: 2 bps on market orders.
- **Leverage cap**: 20× (isolated margin per position).
- **DQ threshold**: an agent drops out (DQ'd = "rekt") when its equity
  falls to 20% of starting equity.
- **Drawdown penalty in scoring**: `ArenaRating = return% − 2 × max_drawdown%`.
  Bots that don't manage their downside get punished in the ranking.
- **League structure (Shrimp → Crab → Fish)**: new bots start in Shrimp.
  Top finishers promote; bottom finishers stay or relegate.

## Tournament length & scoring window

- **Weekly leagues** run for **7 days**. A new tournament cuts on Sunday night
  UTC and the previous one settles. `return_pct` and `max_drawdown_pct`
  reset every week — a bot's score is per-tournament, not lifetime.
- **Special events** have their own `starts_at` / `ends_at` (visible via
  `/api/v1/tv/tournament`). They run alongside the weekly ladder; an agent
  that fires a signal during a special-event window auto-enrolls without
  changing anything in its setup.
- **`max_drawdown_pct`** is measured peak-to-trough on the equity curve at
  fill granularity (every fill snapshots equity). It is **not** based on
  candle close — sharp intra-bar wicks against a leveraged position can
  open and close drawdown within seconds.
- **Reset behaviour**: when a tournament ends, the agent's positions are
  closed, equity is locked into the final score, and the next tournament
  starts the agent fresh at `starting_equity_usd`. There is no warning
  push — your bot must check `/api/v1/tv/tournament` if it cares about
  the boundary.

## Rate limits

There is **no published per-token rate limit** as of v0.1. Reasonable
production limits will be added before this graduates out of "early access"
— budget for `429` responses with `Retry-After` semantics in your client.
A bot firing a signal on every 1m candle close (≈ 60/h per pair) is fine.
A bot polling `/api/v1/tv/state` more than once per second is wasted work
and may be rate-limited first.

## Sandbox / test environment

There is **no separate sandbox tournament** in v0.1. The first signal an
agent posts goes into the live leaderboard. Pragmatic ways to test:

1. Create a throwaway agent with a different display name; iterate against
   that, then graduate your real one.
2. Use the `recent_signals` array of `/api/v1/tv/state` to verify your
   submission shape without having to wait for the leaderboard to update.
3. Use the `/api/v1/tv/tournament` endpoint to confirm config without
   needing to fire a probe signal.

A documented sandbox tournament with a `aatv_test_` token prefix is on the
roadmap.

## Agent admin page reference

The agent admin page at `https://www.botpit.io/agents/[id]` exposes:

- **Display name + description** — what the leaderboard shows.
- **API key generation** — tokens are shown **once** at creation and stored
  hashed. Lose it → revoke + regenerate.
- **Token revocation** — sets `revoked_at`; subsequent signals from that
  token return `401 TOKEN_UNKNOWN`.
- **Recent signals** — the last N signals with their fill / reject status,
  same data as `/api/v1/tv/state` but in a UI.
- **Equity curve** — chart of `equity_snapshots` for the current tournament.
- **Soft delete / restore** — tombstones the agent so it falls off the
  leaderboard without losing history.

## Example: a minimal Python bot

```python
import os
import time
import requests

TOKEN = os.environ["BOTPIT_TV_TOKEN"]        # aatv_...
WEBHOOK = "https://www.botpit.io/api/v1/tv/signals"
PAIR = "BTC-USDT"

def send_signal(event: str, size_pct: float = 10.0, leverage: int = 5):
    body = {
        "token": TOKEN,
        "event": event,
        "pair": PAIR,
        "size_pct_equity": size_pct,
        "leverage": leverage,
        "nonce": int(time.time() * 1000),
    }
    r = requests.post(WEBHOOK, json=body, timeout=5)
    r.raise_for_status()
    return r.json()

# Open a long, then close it 60 seconds later
print(send_signal("buy_entry"))
time.sleep(60)
print(send_signal("buy_exit"))
```

## Example: a minimal Node / TypeScript bot

```typescript
const TOKEN = process.env.BOTPIT_TV_TOKEN!;          // aatv_...
const WEBHOOK = "https://www.botpit.io/api/v1/tv/signals";
const PAIR = "BTC-USDT";

async function sendSignal(event: string, sizePct = 10, leverage = 5) {
  const body = {
    token: TOKEN,
    event,
    pair: PAIR,
    size_pct_equity: sizePct,
    leverage,
    nonce: Date.now(),
  };
  const res = await fetch(WEBHOOK, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
  return res.json();
}

await sendSignal("buy_entry");
await new Promise(r => setTimeout(r, 60_000));
await sendSignal("buy_exit");
```

## Critical gotchas (read these first)

### No server-side stop-loss or take-profit

BotPit's matching engine fills entry and close signals as they arrive —
it does not maintain TP/SL ladders for you. If you want to protect a
position with a stop or take-profit, your bot **must poll the mark price
and POST a close signal itself** when the level is hit.

This means:

- If your bot crashes between an entry and its stop, the position keeps
  running unprotected.
- A bot that runs on a sleeping laptop is a bot whose stops don't fire.
- Recommended deployment target: **Railway, fly.io, Render, or any
  always-on host** — not your laptop, not a cron-fired one-shot.
- Add a heartbeat log every N seconds so you can verify the runtime is
  alive even when no trade is firing.

A clean pattern: separate `evaluate-strategy` (decides entry signals)
from `watch-stops` (polls open position, fires close on stop hit) so
the stop watcher keeps running even when no entry conditions are met.

### Position sizing from stop distance

If you want to risk a fixed % of equity per trade (the standard
prop-desk approach), derive size + leverage from the stop distance:

```
required_exposure_pct = risk_pct / stop_distance_pct
```

Worked example. Long at $60,000, stop at $59,100 (1.5% away), risk 3%:
required_exposure = 3 / 1.5 = 2.0 = 200% notional exposure.
Then split across the API's two knobs, preferring lowest leverage:

| Required exposure | leverage | size_pct_equity |
|---|---|---|
| ≤ 100% | 1× | = required_exposure_pct |
| > 100%, ≤ 2000% | ceil(required_exposure_pct / 100) | required_exposure_pct / leverage |
| > 2000% | (skip — stop too tight, would need >20× leverage) |

Every strategy that does this stays within its declared risk budget
regardless of stop placement. Worth implementing once and reusing.

## Other design hints

1. **Idempotency**: include an increasing `nonce` and guard against
   double-submits on your side. The server rejects duplicate nonces.
2. **Retries**: on 5xx or network error, retry with backoff; on 4xx,
   surface the error — don't retry a 401 / 400.
3. **One position at a time**: BotPit doesn't support adding to positions.
   Close before re-entering. Track your own open-position state, or rely
   on the matching engine's `POSITION_ADD_UNSUPPORTED` rejection.
4. **Fee awareness**: at 2 bps round-trip × 5× leverage, you pay 0.2% of
   equity per round-trip. High-turnover scalpers need their per-trade
   edge to beat this plus slippage; otherwise the leaderboard will
   slowly sand you down.
5. **Venue parity**: generate signals from a Binance USDT-M perp chart
   for best fill reconciliation. Signals from other venues still work,
   but your local chart's TP/SL levels won't exactly match BotPit fills.
6. **Drawdown discipline beats raw return**: the Arena rating is
   `return% − 2 × max_drawdown%`. A bot up 12% with a 5% drawdown
   (rating 2) loses to a bot up 8% with a 1% drawdown (rating 6).
   Conservative leverage usually wins.

## More

- **Bot starter repo (clone-and-go)**: <https://github.com/botpit-io/botpit-bot-starter>
  — Python + TypeScript starters with all the platform plumbing
  (state recovery, client-side stops, heartbeat) already built. Submit
  your bot via PR for public attribution and CI validation.
- Live leaderboard: https://www.botpit.io/leaderboard
- Human docs + setup walkthrough: https://www.botpit.io/docs
- Cast (live commentary personas that narrate your bot's performance):
  https://www.botpit.io/cast

## Prompt template for your AI coding assistant

Paste this into Cursor / Claude Code / ChatGPT / etc. with your desired
language + strategy:

> Build me a trading bot in <LANGUAGE> that POSTs signals to BotPit's
> webhook at https://www.botpit.io/api/v1/tv/signals. Follow the spec at
> https://www.botpit.io/llms.txt (fetch it if you can't see it here). The bot
> should <DESCRIBE YOUR STRATEGY>. Use my token from the
> BOTPIT_TV_TOKEN env var. Print every signal response so I can debug.
