This guide is written from a developer point of view. Its goal is not only to list the current contracts, but to answer the practical question:
How do I build an indicator strategy that is clear, backtestable, and ready to become a saved trading strategy?
RoonQuant currently supports two Python authoring models:
If you are starting a new strategy, the default recommendation is:
IndicatorStrategy.ScriptStrategy only if you need bar-by-bar state, dynamic position management, or execution control.The most common source of confusion is mixing up signal logic, risk defaults, and runtime execution.
Think of IndicatorStrategy as:
dfbuy / sell signalsoutputThis is the best fit for:
Think of ScriptStrategy as:
ctx.positionctx.buy(), ctx.sell(), and ctx.close_position()This is the best fit for:
For IndicatorStrategy, you usually have three layers:
df['buy'] and df['sell'].# @strategy stopLossPct ..., takeProfitPct, entryPct, and related defaults.Do not mix these into one thing.
In particular:
buy / sell decide when the strategy wants to enter or exit# @strategy decides how the engine should size and protect positions by default| Use Case | Recommended Mode |
|----------|------------------|
| Build indicators, overlays, and signal markers | IndicatorStrategy |
| Research entry and exit rules on a dataframe | IndicatorStrategy |
| Add fixed stop-loss, take-profit, or entry sizing defaults | IndicatorStrategy |
| Need runtime position state and bar-by-bar control | ScriptStrategy |
| Need dynamic exits based on current open position | ScriptStrategy |
| Need partial close, scale-in/out, or bot-like logic | ScriptStrategy |
Rule of thumb:
IndicatorStrategy.ScriptStrategy.This is the recommended workflow for most new strategy development.
At the top of the script, define name, description, tunable params, and strategy defaults.
```python my_indicator_name = "Trend Pullback Strategy" my_indicator_description = "Buy pullbacks in an uptrend and exit on weakness."
```
Use # @param for values the user may tune often.
Format:
```python
```
Best practice:
params.get(...)string is accepted as the same type family as strUse # @strategy for strategy defaults such as:
stopLossPct: stop-loss ratio, for example 0.03 = 3%takeProfitPct: take-profit ratio, for example 0.06 = 6%entryPct: fraction of capital to allocate on entrytrailingEnabledtrailingStopPcttrailingActivationPcttradeDirection: long, short, or bothImportant:
leverage here.Indicator code runs in a sandbox. pd, np, and a params dictionary are already available.
Recommended baseline:
python
df = df.copy()
Expected columns usually include:
openhighlowclosevolumeA time column may exist, but do not rely on a fixed type.
Avoid:
eval, exec, open, or __import__buy / sell signalsThe backtest engine reads boolean columns:
df['buy']df['sell']They should:
fillna(False)Recommended pattern:
```python raw_buy = (ema_fast > ema_slow) & (ema_fast.shift(1) <= ema_slow.shift(1)) raw_sell = (ema_fast < ema_slow) & (ema_fast.shift(1) >= ema_slow.shift(1))
df['buy'] = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool) df['sell'] = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool) ```
This keeps your signals from firing on every bar of the same regime.
This is where stop-loss, take-profit, and position management usually become confusing.
There are two valid exit styles in IndicatorStrategy.
Your indicator logic itself decides when to exit by setting df['sell'].
Examples:
Use this style when the exit is part of the strategy idea itself.
You let the strategy engine apply fixed defaults declared with # @strategy, such as:
stopLossPcttakeProfitPctentryPctUse this style when the signal logic should stay simple, and you want the engine to handle fixed protective rules.
Pick one primary owner for exits whenever possible.
For example:
buy / sell# @strategyYou can combine them, but document it clearly so other developers know whether an exit is signal-driven, engine-driven, or both.
output objectYour script must assign a final output dictionary:
python
output = {
"name": "My Strategy",
"plots": [],
"signals": []
}
Supported keys:
nameplotssignalscalculatedVars as optional metadataEach plot item should contain:
namedata with length exactly len(df)coloroverlaytypeEach signal item should contain:
type: buy or selltextcolordata: list with None on bars without a markerIndicator backtests are signal-driven:
df['buy'] and df['sell']This matters because:
shift(-1) in signal logic introduces look-ahead biasPractical nuance:
next_bar_open or same_bar_closeThis section is the practical answer to the most common implementation question.
If you want fixed risk defaults, write them as # @strategy lines:
```python
```
Meaning:
stopLossPct 0.03: use a 3% stop-loss defaulttakeProfitPct 0.06: use a 6% take-profit defaultentryPct 0.25: allocate 25% of capital on entrytradeDirection long: long-only by defaultThis is the correct choice when you want:
If your "stop-loss" is actually part of the indicator model, write it as a sell signal.
Example: exit a long when close falls below an ATR-style stop line.
```python atr = (df['high'] - df['low']).rolling(14).mean() stop_line = df['close'].rolling(20).max() - atr * 2.0
raw_sell = df['close'] < stop_line.shift(1) df['sell'] = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool) ```
In this style:
For indicator strategies, position management is intentionally simple:
entryPct for default entry sizingtradeDirection to limit long, short, or bothIf you need:
then the strategy has outgrown IndicatorStrategy and should move to ScriptStrategy.
This example shows a complete developer-oriented pattern: metadata, defaults, indicator calculation, signal generation, and chart output.
```python my_indicator_name = "EMA Pullback Strategy" my_indicator_description = "Buy pullbacks above the slow EMA and exit on trend failure."
df = df.copy()
fast_len = int(params.get('fast_len', 20)) slow_len = int(params.get('slow_len', 50)) rsi_len = int(params.get('rsi_len', 14)) rsi_floor = float(params.get('rsi_floor', 50.0))
ema_fast = df['close'].ewm(span=fast_len, adjust=False).mean() ema_slow = df['close'].ewm(span=slow_len, adjust=False).mean()
delta = df['close'].diff() gain = delta.clip(lower=0).ewm(alpha=1 / rsi_len, adjust=False).mean() loss = (-delta.clip(upper=0)).ewm(alpha=1 / rsi_len, adjust=False).mean() rs = gain / loss.replace(0, np.nan) rsi = 100 - (100 / (1 + rs))
trend_up = ema_fast > ema_slow pullback_done = df['close'] > ema_fast rsi_ok = rsi > rsi_floor
raw_buy = trend_up & pullback_done & rsi_ok & (~trend_up.shift(1).fillna(False)) raw_sell = (ema_fast < ema_slow) | (rsi < 45)
buy = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool) sell = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool)
df['buy'] = buy df['sell'] = sell
buy_marks = [df['low'].iloc[i] * 0.995 if buy.iloc[i] else None for i in range(len(df))] sell_marks = [df['high'].iloc[i] * 1.005 if sell.iloc[i] else None for i in range(len(df))]
output = { "name": my_indicator_name, "plots": [ { "name": "EMA Fast", "data": ema_fast.fillna(0).tolist(), "color": "#1890ff", "overlay": True }, { "name": "EMA Slow", "data": ema_slow.fillna(0).tolist(), "color": "#faad14", "overlay": True }, { "name": "RSI", "data": rsi.fillna(0).tolist(), "color": "#722ed1", "overlay": False } ], "signals": [ { "type": "buy", "text": "B", "data": buy_marks, "color": "#00E676" }, { "type": "sell", "text": "S", "data": sell_marks, "color": "#FF5252" } ] } ```
What this example teaches:
# @strategyThis version is closer to how developers actually use RoonQuant today:
# @param# @strategytradeDirection explicitly so the saved strategy and backtest panel stay aligned```python my_indicator_name = "Breakout Retest With Direction Control" my_indicator_description = "Breakout-and-retest logic with platform-friendly params and default risk settings."
df = df.copy()
breakout_len = int(params.get('breakout_len', 20)) retest_buffer = float(params.get('retest_buffer', 0.002)) volume_mult = float(params.get('volume_mult', 1.2)) ema_filter_len = int(params.get('ema_filter_len', 50))
ema_filter = df['close'].ewm(span=ema_filter_len, adjust=False).mean() range_high = df['high'].rolling(breakout_len).max().shift(1) range_low = df['low'].rolling(breakout_len).min().shift(1) volume_avg = df['volume'].rolling(breakout_len).mean()
long_breakout = df['close'] > range_high long_retest_ok = df['low'] <= range_high * (1 + retest_buffer) long_volume_ok = df['volume'] >= volume_avg * volume_mult long_trend_ok = df['close'] > ema_filter
short_breakout = df['close'] < range_low short_retest_ok = df['high'] >= range_low * (1 - retest_buffer) short_volume_ok = df['volume'] >= volume_avg * volume_mult short_trend_ok = df['close'] < ema_filter
raw_buy = long_breakout & long_retest_ok & long_volume_ok & long_trend_ok raw_sell = short_breakout & short_retest_ok & short_volume_ok & short_trend_ok
buy = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool) sell = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool)
df['buy'] = buy df['sell'] = sell
buy_marks = [df['low'].iloc[i] * 0.995 if buy.iloc[i] else None for i in range(len(df))] sell_marks = [df['high'].iloc[i] * 1.005 if sell.iloc[i] else None for i in range(len(df))]
output = { "name": my_indicator_name, "plots": [ { "name": "EMA Filter", "data": ema_filter.fillna(0).tolist(), "color": "#1890ff", "overlay": True }, { "name": "Range High", "data": range_high.fillna(0).tolist(), "color": "#52c41a", "overlay": True }, { "name": "Range Low", "data": range_low.fillna(0).tolist(), "color": "#f5222d", "overlay": True } ], "signals": [ { "type": "buy", "text": "L", "data": buy_marks, "color": "#00E676" }, { "type": "sell", "text": "S", "data": sell_marks, "color": "#FF5252" } ] } ```
Why this example maps cleanly to the UI:
# @param values can be tuned by AI or by manual parameter editing workflows# @strategy defaults line up with saved strategy defaults and backtest-side risk settingstradeDirection both makes it obvious that the code is designed for long and short signalsMove to ScriptStrategy when the strategy needs runtime state rather than pure dataframe signals.
Typical triggers:
The safest product-facing contract is:
def on_init(ctx): ...def on_bar(ctx, bar): ...Why this matters:
on_baron_init and on_baron_init only initializes state or writes a log linebar typically exposes:
bar.openbar.highbar.lowbar.closebar.volumebar.timestampctx currently exposes:
ctx.param(name, default=None)ctx.bars(n=1)ctx.positionctx.balancectx.equityctx.log(message)ctx.buy(price=None, amount=None)ctx.sell(price=None, amount=None)ctx.close_position()Notes:
ctx does not expose the full trading config object directlyctx.param(...) for script-level defaults that belong in source codectx.position supports both numeric checks and field access patterns such as:
```python if not ctx.position: ...
if ctx.position > 0: ...
if ctx.position["side"] == "long": ... ```
```python def on_init(ctx): ctx.log("strategy initialized")
def on_bar(ctx, bar): stop_loss_pct = ctx.param("stop_loss_pct", 0.03) take_profit_pct = ctx.param("take_profit_pct", 0.06) order_amount = ctx.param("order_amount", 1)
bars = ctx.bars(30)
if len(bars) < 20:
return
closes = [b.close for b in bars]
ma_fast = sum(closes[-10:]) / 10
ma_slow = sum(closes[-20:]) / 20
if not ctx.position and ma_fast > ma_slow:
ctx.buy(price=bar.close, amount=order_amount)
return
if not ctx.position:
return
if ctx.position["side"] != "long":
return
entry_price = ctx.position["entry_price"]
if bar.close <= entry_price * (1 - stop_loss_pct):
ctx.close_position()
return
if bar.close >= entry_price * (1 + take_profit_pct):
ctx.close_position()
return
if ma_fast < ma_slow:
ctx.close_position()
```
Use this style when stop-loss and take-profit truly belong to runtime position management instead of pure indicator output.
Important sizing note:
entryPctamount in ctx.buy() / ctx.sell() as runtime order intent, not as the only source of truth for backtest sizingMost ScriptStrategy workflows run on closed bars:
on_bar(ctx, bar) after a bar is confirmedThere is also a bot-style runtime mode in the current system:
on_bar with synthetic tick-like bars built from the latest priceThis example is closer to how a platform-facing live strategy is usually written:
ctx.param(...) for script defaultsctx.position before deciding whether to open, reverse, reduce, or fully closectx.buy() / ctx.sell() for directional intentctx.close_position() when you want an explicit full exit```python def on_init(ctx): ctx.log("live strategy initialized")
def on_bar(ctx, bar): fast_len = int(ctx.param("fast_len", 10)) slow_len = int(ctx.param("slow_len", 30)) risk_pct = float(ctx.param("risk_pct", 0.25)) stop_loss_pct = float(ctx.param("stop_loss_pct", 0.02)) take_profit_pct = float(ctx.param("take_profit_pct", 0.05)) allow_short = bool(ctx.param("allow_short", True))
bars = ctx.bars(slow_len + 5)
if len(bars) < slow_len:
return
closes = [b.close for b in bars]
fast_ma = sum(closes[-fast_len:]) / fast_len
slow_ma = sum(closes[-slow_len:]) / slow_len
price = bar.close
if not ctx.position:
if fast_ma > slow_ma:
ctx.buy(price=price, amount=risk_pct)
return
if allow_short and fast_ma < slow_ma:
ctx.sell(price=price, amount=risk_pct)
return
return
if ctx.position["side"] == "long":
entry_price = float(ctx.position["entry_price"])
if price <= entry_price * (1 - stop_loss_pct):
ctx.close_position()
return
if price >= entry_price * (1 + take_profit_pct):
ctx.close_position()
return
if allow_short and fast_ma < slow_ma:
ctx.sell(price=price, amount=risk_pct)
return
if ctx.position["side"] == "short":
entry_price = float(ctx.position["entry_price"])
if price >= entry_price * (1 + stop_loss_pct):
ctx.close_position()
return
if price <= entry_price * (1 - take_profit_pct):
ctx.close_position()
return
if fast_ma > slow_ma:
ctx.buy(price=price, amount=risk_pct)
return
```
What this example demonstrates:
ctx.param(...) keeps script defaults visible and editable in sourcectx.position is the switch that separates flat / long / short behaviorctx.buy() and ctx.sell() express directional intent, not just "open long" or "open short" in isolationctx.close_position() is the clearest choice when your rule means "exit everything now"Backtest vs live differences you should remember:
amount is best treated as runtime order intent; saved-strategy backtests still size mainly from normalized trading config such as entryPctctx.sell() while long, or ctx.buy() while short, may behave like a close-plus-reverse style intent depending on runtime state and product configurationctx.close_position() over relying on implicit interpretationSaved strategies are resolved by the backend into a normalized snapshot for backtesting and execution. Common fields include:
strategy_typestrategy_modestrategy_codeindicator_configtrading_configCurrent run types include:
indicatorstrategy_indicatorstrategy_scriptCurrent limitations:
ScriptStrategy does not support cross_sectional live execution modeshift(1) for confirmationshift(-1) in signal logicRolling and EWM calculations create leading NaNs. Clean them before signal generation.
Every plot['data'] and signal['data'] list must match len(df) exactly.
For IndicatorStrategy, core calculations should be pandas-native whenever possible.
For ScriptStrategy, avoid hidden state outside ctx, avoid randomness, and make order intent explicit.
# @param and # @strategy for indicator defaultsctx.param() for script defaultscolumn "strategy_mode" does not existYour database schema is older than the running code. Apply the required migration on qd_strategies_trading.
Strategy script must define on_bar(ctx, bar)Your ScriptStrategy code is missing the required handler.
Missing required functions: on_init, on_barThe current UI verifier expects both functions to exist in the source text.
Strategy code is empty and cannot be backtestedThe saved strategy does not contain valid code for the selected mode.
All chart output arrays must align exactly with the dataframe length.
Check these first:
buy / sell signals edge-triggered?# @strategy defaults aligned with the strategy idea?If strategy creation, verification, backtest, or execution fails, check backend logs first. Common issue classes:
This is the most practical product workflow for most teams.
Start in the Indicator IDE when you are still shaping the idea:
df.# @param.# @strategy.plots and signals.At this stage, the goal is not to perfect live execution. The goal is to make the logic visible, testable, and easy to iterate.
Once the indicator behaves correctly:
Recommended mindset:
# @param and # @strategy describe tunable defaultsOnce the signal model is stable:
Why this matters:
Stay with IndicatorStrategy if:
Promote to ScriptStrategy if:
Before enabling live trading:
Live trading should be treated as a separate validation stage, not as a continuation of editor-only experimentation.
This section highlights the mistakes that most often cause misleading backtests, confusing strategy behavior, or product/runtime mismatches.
# @param but never reading itWrong:
```python
df = df.copy() fast_len = 20 ema_fast = df['close'].ewm(span=fast_len, adjust=False).mean() ```
Correct:
```python
df = df.copy() fast_len = int(params.get('fast_len', 20)) ema_fast = df['close'].ewm(span=fast_len, adjust=False).mean() ```
Why:
params.get(...), the declaration becomes cosmetic and the quality checker may warnbuy / sell fire on every barWrong:
python
df['buy'] = df['close'] > ema_fast
df['sell'] = df['close'] < ema_fast
Correct:
```python raw_buy = df['close'] > ema_fast raw_sell = df['close'] < ema_fast
df['buy'] = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool) df['sell'] = (raw_sell.fillna(False) & (~raw_sell.shift(1).fillna(False))).astype(bool) ```
Why:
Wrong:
```python
```
Correct:
```python
```
Then set leverage in the product panel or saved-strategy trading configuration.
Why:
shift(-1) and accidentally introducing look-ahead biasWrong:
python
df['buy'] = (df['close'].shift(-1) > ema_fast).fillna(False)
Correct:
python
raw_buy = df['close'] > ema_fast
df['buy'] = (raw_buy.fillna(False) & (~raw_buy.shift(1).fillna(False))).astype(bool)
Why:
shift(-1) reaches into future datactx.buy(..., amount=...) as absolute backtest sizeWrong mental model:
python
ctx.buy(price=bar.close, amount=1.0)
"This guarantees the backtest always uses exactly 100% of capital."
Correct mental model:
python
position_pct = float(ctx.param("risk_pct", 0.25))
ctx.buy(price=bar.close, amount=position_pct)
And then verify the saved-strategy backtest using the normalized trading config.
Why:
entryPctamount is best treated as runtime order intent, not as the only source of truth for historical sizingctx.sell() or ctx.buy() when you really mean "fully flatten now"Risky:
python
if stop_hit:
ctx.sell(price=bar.close, amount=0.25)
Clearer:
python
if stop_hit:
ctx.close_position()
Why:
ctx.buy() / ctx.sell() express directional intent and may be interpreted through current position statectx.close_position() is the least ambiguous choiceRisky:
```python
df['sell'] = some_other_exit_condition ```
Better:
```python
df['sell'] = reverse_signal ```
Why:
Use this section as a fast "what is supported right now?" reference when writing strategy code.
# @strategy supported keys| Key | Meaning | Typical Example | Notes |
|-----|---------|-----------------|-------|
| stopLossPct | Default stop-loss ratio | # @strategy stopLossPct 0.02 | Engine-managed default risk setting |
| takeProfitPct | Default take-profit ratio | # @strategy takeProfitPct 0.05 | Engine parser is more permissive than the toy examples |
| entryPct | Default capital allocation ratio | # @strategy entryPct 0.25 | Common source of backtest sizing |
| trailingEnabled | Enable trailing stop logic | # @strategy trailingEnabled true | Boolean |
| trailingStopPct | Trailing stop ratio | # @strategy trailingStopPct 0.015 | Used with trailing enabled |
| trailingActivationPct | Profit threshold before trailing activates | # @strategy trailingActivationPct 0.03 | Used with trailing enabled |
| tradeDirection | Direction filter | # @strategy tradeDirection both | long, short, or both |
Important:
leverage in # @strategy# @param quick format| Part | Example | Meaning |
|------|---------|---------|
| Name | fast_len | Parameter key |
| Type | int / float / bool / str / string | Supported types |
| Default | 20 | Default value shown to the system |
| Description | Fast EMA length | Human-readable hint |
Example:
```python
```
And then read them with:
python
fast_len = int(params.get('fast_len', 20))
allow_short = bool(params.get('allow_short', True))
ctx methods and fields for ScriptStrategy| Item | Type | Meaning |
|------|------|---------|
| ctx.param(name, default) | method | Read or initialize script-level defaults |
| ctx.bars(n=1) | method | Get recent bars up to the current runtime index |
| ctx.log(message) | method | Write strategy log messages |
| ctx.buy(price=None, amount=None) | method | Express buy / long-side intent |
| ctx.sell(price=None, amount=None) | method | Express sell / short-side intent |
| ctx.close_position() | method | Explicitly flatten current position |
| ctx.position | field | Current position object |
| ctx.balance | field | Runtime balance snapshot |
| ctx.equity | field | Runtime equity snapshot |
ctx.position common fields:
| Field | Meaning |
|-------|---------|
| side | long, short, or empty when flat |
| size | Current position size |
| entry_price | Average entry price |
| direction | 1, -1, or 0 |
| amount | Runtime amount mirror |
bar fields for ScriptStrategy| Field | Meaning |
|-------|---------|
| bar.open | Open price |
| bar.high | High price |
| bar.low | Low price |
| bar.close | Close price |
| bar.volume | Volume |
| bar.timestamp | Time value from runtime feed |
output structure for IndicatorStrategyTop-level structure:
python
output = {
"name": my_indicator_name,
"plots": [],
"signals": [],
"calculatedVars": {}
}
Supported top-level keys:
| Key | Required | Meaning |
|-----|----------|---------|
| name | recommended | Display name |
| plots | recommended | Chart series output |
| signals | recommended | Buy/sell marker output |
| calculatedVars | optional | Extra metadata or computed values |
Each plot item commonly contains:
| Key | Meaning |
|-----|---------|
| name | Plot label |
| data | List aligned to len(df) |
| color | Display color |
| overlay | Whether to draw on price chart |
| type | Optional rendering hint |
Each signal item commonly contains:
| Key | Meaning |
|-----|---------|
| type | buy or sell |
| text | Marker label |
| color | Marker color |
| data | List aligned to len(df), using None where no marker exists |
df['buy'] and df['sell'] should be boolean and length-alignedshift(-1) in signal logicctx.close_position() when the rule clearly means "exit everything now"amount as runtime order intent, then verify sizing with saved-strategy backtestsIndicatorStrategy.# @param and # @strategy metadata.ScriptStrategy only when you truly need runtime position logic.