# Strategy Author

> Author a Python trading strategy compatible with the command-dash backtest runner and live signal scheduler. Use when the user wants to write a new strategy that can be backtested against historical bars and toggled "Go Live" to fire TradersPost webhooks on a cron.

- Skill: `traderspost/strategy-author` (Agent Skill)
- Install (CLI): `npx skillmds@latest add traderspost/strategy-author`
- Raw SKILL.md: https://api.skillmd.com/api/skills/traderspost/strategy-author/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: traderspost (https://skillmd.com/u/traderspost)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/traderspost/strategy-author

---


# strategy-author

Write a Python strategy file that the command-dash runner can backtest against historical OHLC and run live to fire TradersPost webhooks.

## When to use

Trigger this skill when the user wants to:
- Add a new strategy to `strategies/<name>.py`
- Adapt an existing trading rule (SMA crossover, RSI, breakout, mean reversion) into the runner's contract
- Wire a strategy to both a backtest UI and a live cron-driven signal

## The contract

A strategy file must export at least one of:

| Symbol            | Required for | Purpose |
|-------------------|--------------|---------|
| `NAME: str`       | nice-to-have | human label shown in the UI |
| `DESCRIPTION: str`| nice-to-have | first line of the file's docstring works too |
| `SYMBOL: str`     | backtest default | default ticker (e.g. `"SPY"`) — overridable per backtest |
| `signal_for_bar(bar, state) -> "buy" \| "sell" \| None` | lightweight backtest | called per OHLC bar |
| `backtest(bars, capital) -> dict` | full control | bypass the runner and return your own equity curve |
| `signal() -> dict \| None` | live mode | called on a cron; returns a TradersPost payload or None |

You don't need all four — pick the ones that match your workflow.

## Minimal example: SMA crossover

```python
"""20/50-day SMA crossover on SPY."""
NAME = "SMA Crossover"
DESCRIPTION = "20/50-day SMA crossover on SPY"
SYMBOL = "SPY"

FAST, SLOW = 20, 50


def _sma(prices, n):
    if len(prices) < n:
        return None
    return sum(prices[-n:]) / n


def signal_for_bar(bar, state):
    closes = state.setdefault("closes", [])
    closes.append(bar["close"])
    fast = _sma(closes, FAST)
    slow = _sma(closes, SLOW)
    prev_fast = _sma(closes[:-1], FAST) if len(closes) > FAST else None
    prev_slow = _sma(closes[:-1], SLOW) if len(closes) > SLOW else None
    if None in (fast, slow, prev_fast, prev_slow):
        return None
    if prev_fast <= prev_slow and fast > slow:
        return "buy"
    if prev_fast >= prev_slow and fast < slow:
        return "sell"
    return None


def signal():
    """Live mode — wire to recent OHLC if you want this to actually fire."""
    return None  # safe default: do nothing live
```

`bar` shape (passed by the lightweight runner):

```python
{ "time": 1715731200, "open": 234.0, "high": 235.5, "low": 233.0, "close": 234.8, "volume": 1234567 }
```

`state` is a dict that persists across bars within a single backtest run. Use it for indicator state.

## Full-control variant

If you need to manage your own position sizing, slippage, or non-trivial state, return everything yourself:

```python
def backtest(bars: list[dict], capital: float) -> dict:
    cash = capital
    position = 0
    equity_curve = []
    trades = []
    for bar in bars:
        # ... your logic ...
        equity_curve.append({"time": bar["time"], "value": cash + position * bar["close"]})
    return {
        "equityCurve": equity_curve,
        "trades": trades,
        # runner fills in stats, finalEquity, totalReturn if you omit them
    }
```

## Live mode

The live scheduler calls `signal()` on a cron and posts the return value to TradersPost. Pull fresh data inside the function:

```python
import os
import httpx


def signal():
    api_key = os.environ["MASSIVE_API_KEY"]
    url = (
        f"https://api.massive.com/v2/aggs/ticker/SPY/range/1/day/"
        f"{(_today() - _days(60)).isoformat()}/{_today().isoformat()}"
        f"?adjusted=true&sort=asc&limit=200&apiKey={api_key}"
    )
    bars = httpx.get(url, timeout=15).json().get("results", [])
    closes = [b["c"] for b in bars]
    if len(closes) < SLOW + 1:
        return None
    fast_now = sum(closes[-FAST:]) / FAST
    slow_now = sum(closes[-SLOW:]) / SLOW
    fast_prev = sum(closes[-FAST - 1:-1]) / FAST
    slow_prev = sum(closes[-SLOW - 1:-1]) / SLOW
    if fast_prev <= slow_prev and fast_now > slow_now:
        return {"action": "buy", "ticker": SYMBOL}
    if fast_prev >= slow_prev and fast_now < slow_now:
        return {"action": "sell", "ticker": SYMBOL}
    return None  # no signal this tick → no webhook
```

Returning `None` is the safe "do nothing" — the scheduler skips posting.

## Where files live

- `strategies/<name>.py` — your strategies (gitignored by default)
- `strategies/examples/<name>.py` — version-controlled examples ship with the repo
- Both directories are scanned by `GET /api/strategies` and listed in the Strategies panel.

## Common mistakes

- **Stateful module-level globals across runs** — if you write `position = 0` at module level and mutate it, two simultaneous backtests will fight. Keep state inside `state` (per-run) or per-call.
- **Returning `"buy"` when already long, repeatedly** — the lightweight runner ignores buy signals when `position > 0`, but real exchanges will queue extra orders. Add a `state["in_position"]` flag if you also use this strategy live.
- **Forgetting to skip warmup bars** — the SMA isn't defined until bar `SLOW + 1`; returning `None` early avoids spurious early trades.

## Reference

In command-dash:
- `strategies/examples/sma_crossover.py` — the working example
- `apps/api/app/backtest/runner.py` — the runner that calls `signal_for_bar` / `backtest`
- `apps/api/app/signals/scheduler.py` — the live cron that calls `signal()`

