skills/spot-canvas/sn/new-strategy

new-strategy

SKILL.md

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(...) or no_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_high includes the current candle's high, so snap.close > snap.donchian_high is never true. For breakout detection, compare snap.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 float in 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)'
Weekly Installs
1
Repository
spot-canvas/sn
First Seen
Feb 26, 2026
Installed on
amp1
pi1
openclaw1
opencode1
cursor1
kimi-cli1