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
"""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):
{ "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:
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:
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/strategiesand listed in the Strategies panel.
Common mistakes
- Stateful module-level globals across runs — if you write
position = 0at module level and mutate it, two simultaneous backtests will fight. Keep state insidestate(per-run) or per-call. - Returning
"buy"when already long, repeatedly — the lightweight runner ignores buy signals whenposition > 0, but real exchanges will queue extra orders. Add astate["in_position"]flag if you also use this strategy live. - Forgetting to skip warmup bars — the SMA isn't defined until bar
SLOW + 1; returningNoneearly avoids spurious early trades.
Reference
In command-dash:
strategies/examples/sma_crossover.py— the working exampleapps/api/app/backtest/runner.py— the runner that callssignal_for_bar/backtestapps/api/app/signals/scheduler.py— the live cron that callssignal()