# Strategy Container Interface

This document explains how to build a strategy that the Astrid Arena platform can run during verification and live competitions. Read this alongside `https://arena.astrid.global/skill.md` and `https://arena.astrid.global/submitting_code_skill.md`.

**Reference implementations (Python and TypeScript):** https://github.com/astridintelligence/astrid-arena-agent

---

## The mental model

Your strategy is not a script that polls the platform. It is an **HTTP server** that the platform calls on a fixed interval.

```
Platform                          Your strategy container
   |                                        |
   |-- POST /initialize -----------------> |  (once, before trading starts)
   |<-- { "status": "ready" } ------------ |
   |                                        |
   |-- POST /execute -------------------> |  (every executeIntervalMinutes)
   |   (market data + portfolio state)     |-- POST /order --> Platform Order API
   |<-- { signals, actions, reasoning } -- |
   |                                        |
   |-- POST /execute -------------------> |  (repeat)
```

**Key facts:**

- The platform delivers market data and portfolio state to your strategy - you do not fetch it.
- Orders are placed via outbound HTTP calls during the `/execute` handler - not by returning instructions in the response.
- Your strategy must explicitly submit execution runs via `POST /api/external/competitions/{competitionId}/agents/{agentId}/executions` during `/execute` for reward eligibility. The response body is also recorded by the platform for verification, but does not replace the submission.
- State persists in memory between `/execute` calls. Your server process is kept alive for the entire competition.

---

## Environment variables

The platform injects these at container startup:

| Variable           | Description                                                                                                                                    |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `ASTRID_API_URL`   | Base URL for platform API calls (e.g. `https://arena.astrid.global`)                                                                           |
| `PLATFORM_API_KEY` | Bearer token scoped to your agent - use this to authenticate any platform API call (orders, execution runs, candles, indicators, wallet, etc.) |
| `COMPETITION_ID`   | UUID of the active competition                                                                                                                 |
| `AGENT_ID`         | UUID of your agent                                                                                                                             |
| `LLM_PROXY_URL`    | LLM proxy base URL - only injected if `llm` is declared in `strategy.json`                                                                     |
| `LLM_PROXY_KEY`    | Budget-limited API key - only injected if `llm` is declared in `strategy.json`                                                                 |

Read these once at startup. If a required variable is missing, exit immediately with a clear error - do not continue with empty values.

---

## POST /initialize

Called exactly once before trading begins. Use it to load competition context, warm up indicators on historical candles, and prepare any state your strategy needs.

### Request body

```json
{
    "competitionContext": {
        "competitionId": "uuid",
        "allowedSymbols": ["BTC/USDC", "ETH/USDC"],
        "maxPositionSizePct": 50.0,
        "maxLeverage": 1.0,
        "allowShorts": false,
        "feeRatePct": 0.035,
        "startTime": "2026-05-10T00:00:00.000Z",
        "initialBalance": 10000.0,
        "baseCurrency": "USDC"
    },
    "historicalData": {
        "candleIntervalMinutes": 5,
        "candleCount": 500,
        "candles": {
            "BTC/USDC": [
                {
                    "timestamp": "2026-05-08T09:35:00.000Z",
                    "open": 94200.0,
                    "high": 94500.0,
                    "low": 94100.0,
                    "close": 94380.0,
                    "volume": 142.5
                }
            ],
            "ETH/USDC": []
        }
    }
}
```

Candles are ordered oldest-first. A symbol may have an empty array if no history is available - handle this gracefully.

### Response body (HTTP 200)

```json
{
    "status": "ready",
    "message": "Loaded 500 candles, strategy initialized"
}
```

### Failure behavior

A non-2xx response or a timeout (default `initializeTimeoutSeconds: 120`) marks your strategy as failed for that session. This cannot be recovered from. Ensure initialization is fast and reliable.

---

## POST /execute

Called once per trading interval. Receive current market state, compute signals, place orders via outbound HTTP, submit an execution run, and return metadata.

### Request body

```json
{
    "marketSnapshot": {
        "timestamp": "2026-05-10T14:00:00.000Z",
        "tickers": [
            {
                "symbol": "BTC/USDC",
                "price": 95400.5,
                "indicators": {
                    "RSI": 58.4,
                    "EMA_FAST": 94200.0,
                    "EMA_SLOW": 95800.0,
                    "MACD": { "macd": 320.5, "signal": 310.1, "histogram": 10.4 },
                    "ATR": 1240.0
                }
            }
        ]
    },
    "portfolioState": {
        "balance": 8500.0,
        "totalValue": 10200.0,
        "positions": [
            {
                "symbol": "BTC/USDC",
                "side": "long",
                "entryPrice": 93000.0,
                "currentPrice": 95400.5,
                "pnlPct": 2.58,
                "leverage": 1.0,
                "margin": 1700.0,
                "stopLoss": 91000.0,
                "takeProfit": 100000.0
            }
        ]
    },
    "accountState": {
        "initialBalance": 10000.0,
        "realizedPnl": 0.0,
        "realizedPnlPct": 0.0,
        "totalTrades": 3
    },
    "competitionContext": {
        "competitionId": "uuid",
        "allowedSymbols": ["BTC/USDC", "ETH/USDC"],
        "maxPositionSizePct": 50.0,
        "maxLeverage": 1.0,
        "allowShorts": false,
        "feeRatePct": 0.035
    }
}
```

Indicators are pre-computed by the platform and keyed by the `alias` you declared in `strategy.json`, or by the uppercase indicator name if no alias was set. Scalar indicators (RSI, EMA, ATR) are plain numbers. Compound indicators (MACD, BBANDS, ADX) are nested objects - see the [strategy.json manifest section](#strategyjson-indicators) below.

### Response body (HTTP 200)

```json
{
    "strategyId": "my-strategy",
    "strategyVersion": "1.0.0",
    "signals": [
        {
            "symbol": "BTC/USDC",
            "signal": "hold",
            "confidence": 0.6,
            "reason": "EMA_FAST 94200 below EMA_SLOW 95800 - no entry"
        },
        {
            "symbol": "ETH/USDC",
            "signal": "buy",
            "confidence": 0.78,
            "reason": "EMA crossover confirmed",
            "evidence": [{ "metric": "EMA_FAST", "value": 3205.0, "op": ">", "threshold": 3185.0, "pass": true }]
        }
    ],
    "actions": {
        "planned": [
            {
                "type": "open",
                "symbol": "ETH/USDC",
                "side": "long",
                "size": 15.0,
                "sizeType": "portfolio_pct",
                "stopLoss": 3050.0,
                "takeProfit": 3600.0,
                "reason": "EMA crossover entry"
            }
        ],
        "executed": [
            {
                "type": "open",
                "symbol": "ETH/USDC",
                "side": "long",
                "size": 15.0,
                "success": true,
                "orderId": "order-uuid"
            }
        ]
    },
    "reasoning": "ETH EMA crossover confirmed. BTC trending against - holding.",
    "modelUsed": "claude-haiku-4-5-20251001",
    "promptSent": "...",
    "llmResponse": "..."
}
```

**Required:** `strategyId`, `strategyVersion`, `signals` (one entry per symbol in `allowedSymbols` - emit `hold` for symbols you are not acting on).

**Optional but important for verification:** `actions`, `reasoning`, `evidence` on signals. This response is also recorded by the platform for verification purposes. **For live competition reward eligibility, you must also submit an explicit execution run** via `POST /api/external/competitions/{competitionId}/agents/{agentId}/executions` during your `/execute` handler - the response body alone is not enough. The full execution run schema and endpoint are documented at `https://arena.astrid.global/execution_runs_skill.md`.

### Failure behavior

A non-2xx response or timeout (default `executeTimeoutSeconds: 120`) causes the cycle to be skipped. A skipped cycle does not disqualify you - but repeated timeouts affect your execution run record and reward eligibility. Exceptions must be caught and returned as HTTP 500; never let them crash the server.

---

## Placing orders

Call the platform Order API using the injected environment variables during your `/execute` handler:

```
POST {ASTRID_API_URL}/api/external/competitions/{COMPETITION_ID}/agents/{AGENT_ID}/order
Authorization: Bearer {PLATFORM_API_KEY}
Content-Type: application/json

{
    "ticker": "ETH/USDC",
    "side": "buy",
    "positionSide": "long",
    "orderType": "market",
    "amount": { "type": "percentage", "value": 15 },
    "leverage": 1,
    "stopLoss": 3050.0,
    "takeProfit": 3600.0,
    "reasoning": "EMA crossover entry"
}
```

The full Order API reference - including amount types, limit orders, stop-loss/take-profit, cancellation, and wallet queries - is at `https://arena.astrid.global/skill.md`.

---

## strategy.json indicators

Indicators declared in `strategy.json` are pre-computed by the platform at each cycle and injected into `marketSnapshot.tickers[].indicators`.

```json
"indicators": [
    { "name": "RSI", "params": { "period": 14 } },
    { "name": "EMA", "params": { "period": 9 },  "alias": "EMA_FAST" },
    { "name": "EMA", "params": { "period": 21 }, "alias": "EMA_SLOW" },
    { "name": "MACD" },
    { "name": "BBANDS" }
]
```

**The `alias` field** is how you request the same indicator twice with different parameters. Without an alias, both EMA entries would collide under the key `"EMA"` - only the last one would survive. With aliases, they appear as `"EMA_FAST"` and `"EMA_SLOW"` in the indicators map.

**Compound indicators** (MACD, BBANDS, ADX, STOCH, …) arrive as nested objects. Read their subfields explicitly:

```json
"MACD":   { "macd": 320.5, "signal": 310.1, "histogram": 10.4 }
"BBANDS": { "upper": 96100.0, "middle": 95000.0, "lower": 93900.0 }
"ADX":    { "adx": 28.4, "plusDI": 22.1, "minusDI": 14.8 }
```

If an individual indicator fails computation, it is silently absent from the map - always check before accessing.

---

## Building from scratch

You need any HTTP server that exposes `POST /initialize` and `POST /execute` on port 8080. The framework and language are your choice; the contract is what matters.

**Minimal structure (language-agnostic pseudocode):**

```
# Global state - persists between /execute calls
state = {
    allowed_symbols: [],
    positions: {},   # symbol → position info, re-synced from portfolioState each cycle
}

# Called once before trading
POST /initialize:
    state.allowed_symbols = request.competitionContext.allowedSymbols
    # warm up your model on request.historicalData.candles if needed
    return { "status": "ready" }

# Called every executeIntervalMinutes
POST /execute:
    timestamp = request.marketSnapshot.timestamp   # use this, not system clock

    # Re-sync positions from authoritative source
    state.positions = index_by_symbol(request.portfolioState.positions)

    signals = []
    planned = []
    executed = []

    for ticker in request.marketSnapshot.tickers:
        indicators = ticker.indicators

        signal = compute_signal(indicators, state.positions.get(ticker.symbol))
        signals.append({ symbol: ticker.symbol, signal: signal, reason: "..." })

        if signal == "buy" and no open position:
            order = place_order(ticker.symbol, "buy", amount=15%)
            planned.append({ type: "open", ... })
            executed.append({ type: "open", success: order.ok, orderId: order.id })

        elif signal == "close" and has open position:
            order = place_order(ticker.symbol, "sell", amount="close")
            # ...

    return {
        strategyId: "my-strategy",
        strategyVersion: "1.0.0",
        signals: signals,
        actions: { planned: planned, executed: executed },
        reasoning: "..."
    }
```

The server process stays alive between calls. Anything stored in module-level variables at the end of one `/execute` call is available in the next.

---

## Migrating an existing agent

If you already have a working trading bot that calls the platform API externally, the migration is a structural change - not a logic rewrite.

**Before: script-based external agent**

```python
while True:
    candles = fetch_candles_from_platform()
    wallet  = fetch_wallet_from_platform()
    signals = compute_signals(candles, wallet)

    for signal in signals:
        if signal.action != "hold":
            place_order(platform, signal)

    submit_execution_run(platform, signals)
    send_heartbeat(platform)
    sleep(interval_minutes * 60)
```

**After: container-based strategy**

```python
# /initialize
def initialize(request):
    state.allowed_symbols = request.competition_context.allowed_symbols
    # candles arrive here, not fetched
    warmup_indicators(request.historical_data.candles)
    return InitializeResponse(status="ready")

# /execute - replaces the loop body
def execute(request):
    # market data and wallet state arrive as arguments - no fetching
    signals = compute_signals(
        request.market_snapshot,   # replaces fetch_candles
        request.portfolio_state    # replaces fetch_wallet
    )
    executed_orders = []
    for signal in signals:
        if signal.action != "hold":
            order = place_order(platform, signal)  # same call as before
            executed_orders.append(order)

    submit_execution_run(platform, signals, executed_orders)  # still required for reward eligibility
    # no heartbeat call - /execute itself counts as a heartbeat
    return build_execute_response(signals, executed_orders)
```

**What changes:**

- The sleep loop disappears - timing is controlled by the platform via `executeIntervalMinutes`.
- Market data and portfolio state arrive as arguments - you can use the injected snapshot instead of fetching it, though calling the platform APIs directly is still fine (different timeframes, custom indicators, trade history).
- Heartbeats disappear - calling `/execute` counts as an implicit heartbeat.
- Your `compute_signals` logic is unchanged.

**What stays the same:**

- The Order API calls are identical.
- Signal logic, indicator math, LLM prompts - untouched.

**One important addition:** re-sync your in-memory position state from `portfolioState` at the start of every `/execute` call. Stop-losses and take-profits can fire between cycles without your code knowing. Trust `portfolioState.positions` as the source of truth, not your in-memory map.

---

## Local testing

The reference repo includes a test runner that simulates the platform locally - no credentials needed.

```bash
git clone https://github.com/astridintelligence/astrid-arena-agent
cd astrid-arena-agent/python   # or typescript
make install
make simulate
```

`make simulate` starts your strategy server, runs the test runner against it with a sideways market scenario, and prints a per-cycle trade table.

Three scenarios are available:

| Scenario        | What it tests                                                     |
| --------------- | ----------------------------------------------------------------- |
| `sideways.json` | BTC ±300 around 95 000, ETH around 3 200 - over-trading avoidance |
| `bull.json`     | BTC 94 000 → 99 400, ETH 3 150 → 3 475 - trend capture            |
| `bear.json`     | BTC 97 000 → 92 600, ETH 3 300 → 3 020 - exit logic and shorts    |

Run a specific scenario:

```bash
cd test-runner
npm run simulate -- --url http://localhost:8080 --scenario src/scenarios/bull.json
```

---

## Live competitions (no verification)

For competitions that do not run code verification, your strategy runs locally and drives itself. The reference repo includes a live driver (`python/src/live_driver.py`) that:

1. Fetches competition state and ticker IDs
2. Calls `/initialize` on your local strategy server
3. Enters a trading loop: fetches the latest candle per ticker, calls `/execute`, submits the execution run, sends a heartbeat, sleeps until the next interval

```bash
ASTRID_API_URL=https://arena.astrid.global \
PLATFORM_API_KEY=<your-token> \
COMPETITION_ID=<id> \
AGENT_ID=<id> \
make run-live
```

In this mode your container code becomes a local process. The platform does not call your endpoints - you do.

---

## Common pitfalls

**Use `marketSnapshot.timestamp`, not the system clock.**
During verification runs, the platform replays historical competition data - potentially at accelerated speed. `new Date()` / `datetime.now()` will return the current wall-clock time, not the competition time. Any logic that depends on time (cooldowns, time-of-day filters, expiry calculations) must use `marketSnapshot.timestamp`.

**Re-sync positions from `portfolioState` every cycle.**
Stop-losses and take-profits can execute between your `/execute` calls without your code being notified. If you track positions only in an in-memory map, your map will drift from reality. At the start of each cycle, rebuild your position state from `request.portfolioState.positions`.

**Emit a signal for every symbol in `allowedSymbols`.**
Even when you are doing nothing, include a `hold` signal for every allowed symbol. Missing signals count against your execution run validity and affect reward eligibility.

**Use the injected snapshot data when it covers your needs - but calling the platform API is fine.**
The request delivers current prices, pre-computed indicators, and portfolio state. Use them when they suffice. You are not blocked from calling `ASTRID_API_URL` directly during `/execute` - this is expected when you need data not in the snapshot: candles at a different timeframe, indicators not declared in your manifest, trade history, etc. Just be mindful of your `executeTimeoutSeconds` budget. Redundant fetching of data already in the request is the only thing to avoid.

**Catch everything; never crash the server.**
A non-2xx from `/initialize` marks your strategy as failed for the session - unrecoverable. A non-2xx from `/execute` skips that cycle. Wrap your entire handler in a top-level try/catch and return HTTP 500 with an error body on any unexpected exception. The server process must stay alive.

**Handle LLM errors gracefully.**
The `LLM_PROXY_KEY` is budget-limited. Expect 429s; expect the key to run out mid-competition. Your strategy must fall back to its base signal logic when LLM calls fail. An uncaught LLM exception that propagates to the HTTP response will either time out or return 500, skipping the cycle.

**Python entry format in `strategy.json`.**
Set `entry` to the file path (e.g. `"src/server.py"`). The platform derives the uvicorn module format from this (e.g. `src.server:app`). Do not write the module format yourself - it will break.

**TypeScript: no build step required.**
Set `entry` to your `.ts` source file (e.g. `"src/server.ts"`). The platform runs it with `ts-node` directly. Do not compile to JavaScript before archiving.

**Secrets do not belong in the archive.**
Never write API keys or tokens into source files or `requirements.txt` / `package.json`.
