new-strategy
Writing a New Starlark User Strategy
User strategies are Python-like Starlark scripts that plug directly into the SignalNGN signal engine. They run alongside built-in strategies, are backtestable against historical data, and publish signals to the same NATS stream as every other strategy.
Requirements: paid tier + an always-on tenant server.
Quick Start
# 1. Write your strategy to a .star file
cat > my_strategy.star << 'EOF'
def evaluate(snap, prev):
if snap.rsi_valid and snap.rsi < 30:
return signal("BUY", confidence=0.7, reason="RSI oversold")
return no_signal()
EOF
# 2. Validate it (no side effects)
sn strategy validate --name my_strategy --file my_strategy.star
# 3. Create it (persists to DB, inactive by default)
sn strategy create --name my_strategy --file my_strategy.star \
--description "Simple RSI oversold entry"
# 4. Backtest before going live
sn strategy backtest <id> \
--exchange coinbase --product BTC-USD \
--granularity ONE_HOUR \
--start 2024-01-01 --end 2025-01-01
# 5. Activate to start receiving live signals
sn strategy activate <id>
# 6. Wire it into a trading config
sn trading set coinbase BTC-USD --spot user:my_strategy --enable
sn trading reload
The evaluate() Function
Every strategy must define exactly one function with this signature:
def evaluate(snap, prev):
...
return signal(...) or no_signal()
snap— current candle's indicator snapshot (the candle that just closed)prev— previous candle's indicator snapshot- Must return
signal(...)orno_signal()— nothing else is valid - Exceptions are caught; the strategy returns no signal on error (pipeline never blocks)
signal() Builtin
signal(action, confidence=0.0, stop_loss=0.0, take_profit=0.0,
reason="", risk_reasoning="", position_pct=0.0)
| Parameter | Type | Description |
|---|---|---|
action |
string | Required. "BUY", "SELL", "SHORT", or "COVER" |
confidence |
float | 0.0–1.0. Clamped automatically. |
stop_loss |
float | Suggested stop-loss price. 0 = not set. |
take_profit |
float | Suggested take-profit price. 0 = not set. |
reason |
string | Human-readable explanation (appears in Telegram notifications). |
risk_reasoning |
string | Why those SL/TP levels were chosen. |
position_pct |
float | Fraction of capital to use (0.0–1.0). 0 = no recommendation. |
Action semantics:
| Action | Meaning |
|---|---|
BUY |
Open a long position (spot or futures-long) |
SELL |
Close a long position |
SHORT |
Open a short position (futures only) |
COVER |
Close a short position (futures only) |
# Minimal — just the action
return signal("BUY")
# Full — with all fields
return signal("BUY",
confidence=0.82,
stop_loss=snap.close - 2.0 * snap.atr,
take_profit=snap.close + 4.0 * snap.atr,
reason="MACD crossed above signal line",
risk_reasoning="SL at 2× ATR below entry, TP at 4× ATR (2:1 R:R)",
position_pct=0.20,
)
no_signal() Builtin
return no_signal()
Returns an empty signal (no trade). Equivalent to signal("") but clearer.
Snapshot Fields
Both snap and prev have identical fields. Always check _valid flags
before using an indicator — they are False during the warm-up period when
the engine doesn't yet have enough candles.
Price / Volume
| Field | Type | Description |
|---|---|---|
snap.close |
float | Candle close price |
snap.open |
float | Candle open price |
snap.high |
float | Candle high price |
snap.low |
float | Candle low price |
snap.volume |
float | Candle volume |
snap.avg_volume |
float | Rolling average volume |
snap.volume_valid |
bool | True when avg_volume is ready |
snap.timestamp |
int | Unix seconds of this candle |
RSI
| Field | Type | Description |
|---|---|---|
snap.rsi |
float | RSI (14-period) |
snap.rsi_valid |
bool | True when RSI is ready |
MACD
| Field | Type | Description |
|---|---|---|
snap.macd_line |
float | MACD line (EMA12 − EMA26) |
snap.macd_signal |
float | Signal line (EMA9 of MACD line) |
snap.macd_histogram |
float | MACD line − signal line |
snap.macd_valid |
bool | True when MACD is ready |
Moving Averages
| Field | Type | Description |
|---|---|---|
snap.sma50 |
float | Simple moving average (50) |
snap.sma50_valid |
bool | |
snap.sma200 |
float | Simple moving average (200) |
snap.sma200_valid |
bool | |
snap.ema12 |
float | Exponential moving average (12) |
snap.ema26 |
float | Exponential moving average (26) |
Bollinger Bands
| Field | Type | Description |
|---|---|---|
snap.bollinger_upper |
float | Upper band |
snap.bollinger_middle |
float | Middle band (SMA20) |
snap.bollinger_lower |
float | Lower band |
snap.bollinger_valid |
bool | True when bands are ready |
snap.bb_width |
float | (upper − lower) / middle |
snap.bb_percent_b |
float | (close − lower) / (upper − lower) |
Stochastic
| Field | Type | Description |
|---|---|---|
snap.stoch_k |
float | %K line |
snap.stoch_d |
float | %D line (signal) |
snap.stoch_valid |
bool | True when stochastic is ready |
Z-Score
| Field | Type | Description |
|---|---|---|
snap.zscore |
float | Z-score of close vs rolling mean |
snap.zscore_mean |
float | Rolling mean used for Z-score |
snap.zscore_stddev |
float | Rolling std dev used for Z-score |
snap.zscore_valid |
bool | True when Z-score is ready |
ATR
| Field | Type | Description |
|---|---|---|
snap.atr |
float | Average True Range |
snap.atr_valid |
bool | True when ATR is ready |
Supertrend
| Field | Type | Description |
|---|---|---|
snap.supertrend_value |
float | Supertrend indicator value |
snap.supertrend_bullish |
bool | True = bullish trend |
snap.supertrend_valid |
bool | True when Supertrend is ready |
Donchian Channel
| Field | Type | Description |
|---|---|---|
snap.donchian_high |
float | Donchian channel high (includes current candle) |
snap.donchian_low |
float | Donchian channel low (includes current candle) |
snap.donchian_mid |
float | Midpoint of channel |
snap.donchian_valid |
bool | True when Donchian is ready |
Donchian note:
snap.donchian_highincludes the current candle's high, sosnap.close > snap.donchian_highis never true. For breakout detection, comparesnap.close > prev.donchian_high.
math Module
A math module is always available. All functions accept int or float and return float.
math.abs(x) # absolute value
math.min(a, b) # minimum of two numbers
math.max(a, b) # maximum of two numbers
math.sqrt(x) # square root (error if x < 0)
math.pow(base, exp) # exponentiation
math.log(x) # natural logarithm (error if x ≤ 0)
math.exp(x) # e^x
math.ceil(x) # ceiling
math.floor(x) # floor
math.round(x) # round to nearest integer
User Parameters
Parameters are float64 values injected as Starlark globals. They let you tune
thresholds without changing strategy source code, and enable parameter sweeps
in backtests.
Define parameters at the top of the file as comments + constants:
# Parameters (injected via API --params):
# ENTRY - Z-score entry threshold (default 2.0)
# EXIT - Z-score exit threshold (default 0.5)
def evaluate(snap, prev):
if not snap.zscore_valid:
return no_signal()
if prev.zscore >= -ENTRY and snap.zscore < -ENTRY:
...
Pass them at creation time:
sn strategy create --name zscore_custom --file zscore.star \
--params '{"ENTRY": 2.0, "EXIT": 0.5, "MAX_POSITION": 0.25}'
Rules:
- Names must be valid identifiers (letters, digits, underscores; not starting with digit)
- All values are
floatin Starlark — use them directly in arithmetic - Starlark keywords (
if,def,for, etc.) are not allowed as parameter names - Parameters are injected at compile time; updating them requires
sn strategy update
Sandbox Limits
| Limit | Value |
|---|---|
| Max source size | 64 KB |
| Max execution steps per candle | 100,000 |
Imports (load()) |
Forbidden |
print() |
Silent (no-op) |
| File / network / OS access | Not available |
A typical strategy uses ~200 steps. The 100K limit catches infinite loops; it will never be hit by any reasonable strategy logic.
Strategy Patterns
RSI oversold/overbought
def evaluate(snap, prev):
if not snap.rsi_valid:
return no_signal()
if snap.rsi < 30 and prev.rsi >= 30: # crossed into oversold
sl = snap.close - 2.0 * snap.atr if snap.atr_valid else 0.0
tp = snap.close + 4.0 * snap.atr if snap.atr_valid else 0.0
return signal("BUY",
confidence=math.min(1.0, (30 - snap.rsi) / 10),
stop_loss=sl,
take_profit=tp,
reason="RSI crossed below 30 (%.1f)" % snap.rsi,
)
if snap.rsi > 70 and prev.rsi <= 70: # crossed into overbought
sl = snap.close + 2.0 * snap.atr if snap.atr_valid else 0.0
tp = snap.close - 4.0 * snap.atr if snap.atr_valid else 0.0
return signal("SELL",
confidence=math.min(1.0, (snap.rsi - 70) / 10),
stop_loss=sl,
take_profit=tp,
reason="RSI crossed above 70 (%.1f)" % snap.rsi,
)
return no_signal()
MACD crossover
def evaluate(snap, prev):
if not snap.macd_valid or not prev.macd_valid:
return no_signal()
# Bullish crossover: MACD line crosses above signal line
if prev.macd_line <= prev.macd_signal and snap.macd_line > snap.macd_signal:
return signal("BUY",
confidence=0.65,
reason="MACD crossed above signal line",
)
# Bearish crossover
if prev.macd_line >= prev.macd_signal and snap.macd_line < snap.macd_signal:
return signal("SELL",
confidence=0.65,
reason="MACD crossed below signal line",
)
return no_signal()
Supertrend trend-following
def evaluate(snap, prev):
if not snap.supertrend_valid or not prev.supertrend_valid:
return no_signal()
# Trend flipped to bullish
if not prev.supertrend_bullish and snap.supertrend_bullish:
sl = snap.supertrend_value # supertrend level is the natural stop
risk = snap.close - sl
tp = snap.close + 2.0 * risk if risk > 0 else 0.0
return signal("BUY",
confidence=0.70,
stop_loss=sl,
take_profit=tp,
reason="Supertrend flipped bullish",
risk_reasoning="SL at Supertrend level %.4f" % sl,
)
# Trend flipped to bearish
if prev.supertrend_bullish and not snap.supertrend_bullish:
sl = snap.supertrend_value
risk = sl - snap.close
tp = snap.close - 2.0 * risk if risk > 0 else 0.0
return signal("SELL",
confidence=0.70,
stop_loss=sl,
take_profit=tp,
reason="Supertrend flipped bearish",
)
return no_signal()
Z-score mean reversion with Kelly position sizing
ENTRY = 2.0
EXIT = 0.5
MAX_POSITION = 0.25
def evaluate(snap, prev):
if not snap.zscore_valid or not prev.zscore_valid:
return no_signal()
mean = snap.zscore_mean
stddev = snap.zscore_stddev
# BUY: Z-score crosses below -ENTRY (oversold)
if prev.zscore >= -ENTRY and snap.zscore < -ENTRY:
abs_z = math.abs(snap.zscore)
conf = math.min(1.0, (abs_z - ENTRY) / ENTRY)
sl = mean - (ENTRY + 1.0) * stddev
tp = mean - EXIT * stddev
reward = math.abs(tp - snap.close)
risk = math.abs(snap.close - sl)
pos = 0.0
if risk > 0 and (reward / risk) > 0:
kelly = ((abs_z - ENTRY) / ENTRY) / (reward / risk)
pos = math.min(math.max(kelly, 0.0), MAX_POSITION)
return signal("BUY", confidence=conf, stop_loss=sl, take_profit=tp,
position_pct=pos,
reason="Z-score %.2f crossed below -%.1f" % (snap.zscore, ENTRY))
# SELL: Z-score crosses above +ENTRY (overbought)
if prev.zscore <= ENTRY and snap.zscore > ENTRY:
abs_z = math.abs(snap.zscore)
conf = math.min(1.0, (abs_z - ENTRY) / ENTRY)
sl = mean + (ENTRY + 1.0) * stddev
tp = mean + EXIT * stddev
reward = math.abs(tp - snap.close)
risk = math.abs(snap.close - sl)
pos = 0.0
if risk > 0 and (reward / risk) > 0:
kelly = ((abs_z - ENTRY) / ENTRY) / (reward / risk)
pos = math.min(math.max(kelly, 0.0), MAX_POSITION)
return signal("SELL", confidence=conf, stop_loss=sl, take_profit=tp,
position_pct=pos,
reason="Z-score %.2f crossed above %.1f" % (snap.zscore, ENTRY))
return no_signal()
Donchian channel breakout
# Parameters: VOL_MULT=1.5, ATR_MULT=0.5, SL_ATR_MULT=1.5, RR_RATIO=2.5, MAX_POSITION=0.20
def evaluate(snap, prev):
if not snap.donchian_valid or not snap.atr_valid or not snap.volume_valid:
return no_signal()
if not prev.donchian_valid:
return no_signal()
# Upside breakout: compare close vs PREVIOUS Donchian high
# (snap.donchian_high already includes current candle's high)
if prev.close <= prev.donchian_high and snap.close > prev.donchian_high:
if snap.volume <= VOL_MULT * snap.avg_volume:
return no_signal()
penetration = snap.close - prev.donchian_high
if penetration <= ATR_MULT * snap.atr:
return no_signal()
risk = SL_ATR_MULT * snap.atr
sl = snap.close - risk
tp = snap.close + RR_RATIO * risk
return signal("BUY",
confidence=math.min(1.0, penetration / snap.atr),
stop_loss=sl, take_profit=tp,
position_pct=math.min(MAX_POSITION, 0.05 * RR_RATIO),
reason="Donchian breakout above %.4f" % prev.donchian_high,
)
# Downside breakdown
if prev.close >= prev.donchian_low and snap.close < prev.donchian_low:
if snap.volume <= VOL_MULT * snap.avg_volume:
return no_signal()
penetration = prev.donchian_low - snap.close
if penetration <= ATR_MULT * snap.atr:
return no_signal()
risk = SL_ATR_MULT * snap.atr
sl = snap.close + risk
tp = snap.close - RR_RATIO * risk
return signal("SELL",
confidence=math.min(1.0, penetration / snap.atr),
stop_loss=sl, take_profit=tp,
position_pct=math.min(MAX_POSITION, 0.05 * RR_RATIO),
reason="Donchian breakdown below %.4f" % prev.donchian_low,
)
return no_signal()
Starlark vs Python — Key Differences
Starlark is intentionally a restricted subset. Things that work differently:
| Feature | Python | Starlark |
|---|---|---|
| String formatting | f-strings or % |
% only — e.g. "val=%.2f" % x |
| Augmented assignment | x += 1 |
Supported ✓ |
while loops |
Supported | Supported (counts against step limit) |
import / load() |
import module |
Forbidden — no module loading |
| Classes | class Foo: |
Not available |
try / except |
Supported | Not available |
print() |
Stdout | Silent no-op |
| Mutable defaults | def f(x=[]) |
Not idiomatic; avoid |
| Global reassignment | x = 1; x = 2 |
Top-level globals are frozen after module init |
sn strategy Commands
# List all strategies (table)
sn strategy list
# List only active strategies
sn strategy list --active
# Get a strategy with full source
sn strategy get <id>
# Validate without persisting (safe to run anytime)
sn strategy validate --name my_strat --file ./my_strat.star
sn strategy validate --name my_strat --file ./my_strat.star --params '{"THRESHOLD": 2.0}'
# Create (inactive by default)
sn strategy create --name my_strat --file ./my_strat.star
sn strategy create --name my_strat --file ./my_strat.star \
--description "My strategy" --params '{"THRESHOLD": 2.0}'
# Update source (re-validates automatically; triggers engine reload if active)
sn strategy update <id> --file ./my_strat.star
# Activate / deactivate (triggers engine reload)
sn strategy activate <id>
sn strategy deactivate <id>
# Delete
sn strategy delete <id>
# Backtest
sn strategy backtest <id> \
--exchange coinbase --product BTC-USD \
--granularity ONE_HOUR \
--start 2024-01-01 --end 2025-01-01
# Backtest in futures-long mode with leverage
sn strategy backtest <id> \
--exchange coinbase --product ETH-USD \
--granularity THIRTY_MINUTES \
--mode futures-long --leverage 2
# Backtest with trend filter
sn strategy backtest <id> \
--exchange coinbase --product BTC-USD --granularity ONE_HOUR \
--trend-filter
Adding to Trading Config
After activation, wire the strategy into a product's trading config using the
user: prefix:
# Add to spot strategies for BTC-USD
sn trading set coinbase BTC-USD --spot user:my_strategy --enable
sn trading reload
# Add to multiple products
sn trading set coinbase ETH-USD --spot user:my_strategy --enable
sn trading set coinbase SOL-USD --spot user:my_strategy --enable
sn trading reload
# Replace all spot strategies with user strategy
sn trading set coinbase BTC-USD --spot user:my_strategy,macd_momentum
sn trading reload
Once active and in trading config, signals appear in the NATS stream:
signals.coinbase.BTC-USD.ONE_HOUR.user.my_strategy
Monitor them live:
sn signals --strategy "user.my_strategy"
sn signals --json --strategy "user.my_strategy" | jq 'select(.confidence >= 0.7)'
Validation Errors and Debugging
sn strategy validate returns detailed errors including line numbers and
Starlark stack traces.
Common mistakes:
| Error | Cause | Fix |
|---|---|---|
'evaluate' must take exactly 2 parameters |
Wrong function signature | Use def evaluate(snap, prev): |
signal: invalid action "HOLD" |
Bad action string | Use "BUY", "SELL", "SHORT", or "COVER" |
undefined: ENTRY |
Parameter used but not passed | Add --params '{"ENTRY": 2.0}' at creation |
snap.rsi: cannot assign to frozen struct |
Trying to modify snapshot | Snapshots are read-only |
exceeded 100000 steps |
Infinite or very slow loop | Simplify the logic; avoid nested loops |
load() is not allowed |
load("module") in source |
Remove — imports are forbidden |
source size N exceeds maximum 65536 bytes |
Source too large | Split into smaller strategy |
evaluation error: ... |
Runtime exception in dry-run | Fix the logic causing the error |
Testing locally before deploying — paste your strategy into the validate endpoint to iterate without creating DB entries:
sn strategy validate --name test --file draft.star
# returns: {"valid": true} or {"valid": false, "error": "..."}
Full Workflow Example
# 1. Write the strategy
cat > zscore_entry.star << 'EOF'
ENTRY = 2.0
EXIT = 0.5
def evaluate(snap, prev):
if not snap.zscore_valid or not prev.zscore_valid:
return no_signal()
if prev.zscore >= -ENTRY and snap.zscore < -ENTRY:
sl = snap.zscore_mean - (ENTRY + 1.0) * snap.zscore_stddev
tp = snap.zscore_mean - EXIT * snap.zscore_stddev
return signal("BUY",
confidence=0.70,
stop_loss=sl,
take_profit=tp,
reason="Z-score %.2f crossed below -%.1f" % (snap.zscore, ENTRY),
)
if prev.zscore <= ENTRY and snap.zscore > ENTRY:
sl = snap.zscore_mean + (ENTRY + 1.0) * snap.zscore_stddev
tp = snap.zscore_mean + EXIT * snap.zscore_stddev
return signal("SELL",
confidence=0.70,
stop_loss=sl,
take_profit=tp,
reason="Z-score %.2f crossed above %.1f" % (snap.zscore, ENTRY),
)
return no_signal()
EOF
# 2. Validate
sn strategy validate --name zscore_entry --file zscore_entry.star \
--params '{"ENTRY": 2.0, "EXIT": 0.5}'
# 3. Create
sn strategy create --name zscore_entry --file zscore_entry.star \
--description "Z-score mean reversion entry" \
--params '{"ENTRY": 2.0, "EXIT": 0.5}'
# → returns id, e.g. 7
# 4. Backtest
sn strategy backtest 7 \
--exchange coinbase --product BTC-USD \
--granularity ONE_HOUR \
--start 2024-01-01 --end 2025-01-01
# 5. Activate
sn strategy activate 7
# 6. Add to trading config
sn trading set coinbase BTC-USD --spot user:zscore_entry --enable
sn trading reload
# 7. Monitor live signals
sn signals --json --strategy "user.zscore_entry" | \
jq 'select(.confidence >= 0.6)'