Strategy API V2 Development Guide¶
Applies to: the current executable QuantDinger strategy contract Audience: first-time strategy authors, indicator-conversion users, and developers targeting both backtest and live execution
QuantDinger has one current executable Python strategy contract: Strategy API V2. The same source compiles into a strategy manifest used by backtest and live runtimes for instruments, subscriptions, events, order intents, portfolio accounting, and protection rules.
The source owns its market, instruments, frequency, schedules, and trading logic. Run forms provide dates, initial capital, costs, source-permitted leverage, and user parameters; they do not override source-controlled markets, symbols, or timeframes.
Chart indicators are separate artifacts. Their plots, signals, and layers cannot place orders. Convert an indicator into Strategy API V2 before backtesting or deploying it.
1. Quick start: a minimal executable strategy¶
"""SPY 20-Day Moving Average
Trades a long-only SPY regime from completed daily bars.
"""
# @param period int 20 Moving-average period range=5:100:5
# @param target_pct float 0.95 Target portfolio weight range=0.1:1.0:0.05
def initialize(context):
g.symbol = "USStock:SPY"
context.set_universe([g.symbol])
context.subscribe(
frequency="1d",
fields=["open", "high", "low", "close", "volume"],
)
context.set_warmup(120)
context.set_benchmark("USStock:SPY")
def handle_data(context, data):
period = int(context.params.get("period", 20))
target_pct = float(context.params.get("target_pct", 0.95))
bars = get_history(
period + 1,
"1d",
"close",
g.symbol,
)
if len(bars) < period:
return
price = float(bars["close"].iloc[-1])
average = float(bars["close"].tail(period).mean())
position = get_position(g.symbol)
desired = target_pct if price > average else 0.0
if desired > 0 and position.amount <= 0:
order_target_percent(
g.symbol,
desired,
reason="ma_long_entry",
stop_loss_pct=0.05,
)
elif desired == 0 and position.amount > 0:
order_target_percent(
g.symbol,
0.0,
reason="ma_long_exit",
)
Workflow:
- Create a script in the Strategy IDE and paste the source.
- Save the source.
- Verify it and inspect the compiled manifest.
- Choose dates, capital, commission, slippage, and parameters.
- Inspect executions, closed trades, the order ledger, equity, and holdings.
- Create a deployment only after the backtest behaves as intended. New deployments start stopped.
2. Compiler requirements and authoring standard¶
Hard compiler requirements:
- Source is non-empty and executes in the safe sandbox.
initialize(context)exists.initializedeclares a static universe, index, or named pool throughcontext.set_universe(...).- If no subscription is declared, the compiler adds a default daily subscription; this guide still recommends an explicit
context.subscribe. - The source exposes
handle_data,on_rebalance, or at least one registered schedule callback. - Leveraged strategies satisfy the Crypto-swap-only policy.
The project authoring standard additionally requires:
- Start with a triple-quoted docstring. Its first line is the strategy name; following lines describe universe, signals, schedule, and risk.
- Use English identifiers and source comments.
- Use stable, auditable parameter and reason names.
- Avoid look-ahead, implicit reversals, unbounded scaling, and uncapped exposure.
initialize runs during compilation/manifest discovery. Use it for declarations and initial g state. Do not request market data, inspect real positions, or place orders there.
3. The source-owned manifest¶
Compilation discovers:
- API version and source hash;
- CTA or portfolio classification;
- static or dynamic universe;
- subscribed instruments, frequency, and fields;
- schedules;
- benchmark;
- lifecycle handlers;
- factor and fundamental dependencies;
- warm-up bars;
- leverage permission and maximum;
- custom metadata.
Verification endpoint:
POST /api/strategies/verify
Content-Type: application/json
{"code": "...complete Strategy API V2 source..."}
A valid response contains valid: true and the manifest. Verify the final saved source before deployment, not only an earlier draft.
4. Canonical instruments¶
| Market | Example |
|---|---|
| China A-share | CNStock:600519.SH |
| US equity | USStock:MSFT |
| Hong Kong equity | HKStock:00700.HK |
| Crypto spot | Crypto:BTC/USDT@spot |
| Venue-specific Crypto spot | Crypto:BTC/USDT@okx:spot |
| Crypto perpetual | Crypto:BTC/USDT@swap |
| Venue-specific perpetual | Crypto:BTC/USDT@okx:swap |
The parser also normalizes selected aliases, such as 600519.XSHG to CNStock:600519.SH and BTCUSDT to BTC/USDT.
Production strategies should use the full market prefix. Crypto defaults to spot when no market type is present. Only swap instruments can permit contract leverage.
5. Static and dynamic universes¶
Static single instrument:
context.set_universe(["USStock:SPY"])
Static basket:
context.set_universe([
"USStock:AAPL",
"USStock:MSFT",
"USStock:NVDA",
])
Index universe:
context.set_universe(index="INDEX:SP500")
members = get_index_stocks("INDEX:SP500")
Named platform pool:
context.set_universe(pool="sp500")
members = get_universe_stocks()
Dynamic universes resolve point-in-time constituents. Do not copy today's pool members into source and then use them for a historical backtest.
A dynamic universe, more than one static instrument, or on_rebalance normally classifies the manifest as portfolio. One static instrument normally classifies it as CTA.
6. Subscriptions, warm-up, and benchmark¶
context.subscribe(
frequency="1d",
fields=["open", "high", "low", "close", "volume"],
)
context.set_warmup(260)
context.set_benchmark("USStock:SPY")
Rules:
- Frequency belongs in source, for example
1m,5m,1h,4h,1d, or1w. - Aliases such as
daily,day, anddnormalize to1d. - Omitting symbols subscribes the current universe.
set_warmupasks the data service for history before the requested backtest start. It does not remove the need forlen(bars)guards.- A benchmark is for comparison; it is not traded automatically.
- The
get_historyfrequency argument is API-compatible metadata. The current runtime reads subscribed frames, so request the same frequency the source subscribes.
7. Lifecycle and schedules¶
Supported handlers:
def initialize(context):
pass
def before_trading_start(context, data):
pass
def handle_data(context, data):
pass
def on_rebalance(context, panel):
pass
def after_trading_end(context, data):
pass
Schedule registration:
def initialize(context):
context.set_universe(["USStock:SPY"])
context.subscribe(frequency="5m")
run_daily(rebalance, time="09:35")
run_weekly(weekly_review, weekday=1, time="09:40")
run_monthly(monthly_rebalance, monthday=1, time="09:45")
Rules:
weekdayis 1–7, with Monday as 1.- A monthday past the end of a month resolves to that month's last day.
- On daily or lower-frequency bars, a specific intraday time does not create a nonexistent bar.
- Prefer
callback(context, data); the runtime also adapts callbacks that accept only context. - A portfolio strategy with no registered schedules invokes
on_rebalance. - The current engine invokes
before_trading_startandafter_trading_endfor every event timestamp. Do not assume they run only once per calendar day in an intraday strategy.
8. The critical timing model¶
Backtests expose only point-in-time-visible data:
- At a new bar, orders queued after the previous close execute first, using the current open.
before_trading_startand due schedule callbacks see data only through the previous bar; their orders can be processed at the current open.- The current completed bar becomes visible and
handle_dataruns. - Orders emitted by
handle_datawait for the next bar open. after_trading_endalso sees the current bar; its new orders wait for the next bar.
This implements “confirm on close, fill at next open” without future leakage. Never use negative shifts or future rows to move execution earlier.
Live sessions process each closed bar once and preserve g state. Receiving the same bar twice should not duplicate strategy work.
9. context, data, and g¶
Common context fields:
| Field | Meaning |
|---|---|
context.params |
run parameters |
context.current_dt |
current event timestamp |
context.previous_trading_date |
previous event timestamp |
context.portfolio.starting_cash |
initial capital |
context.portfolio.available_cash |
available cash |
context.portfolio.total_value |
current equity |
context.portfolio.positions |
current position map |
context.data |
data view |
Use data.current(symbol, field) for a current visible value, data.history(symbols, count, fields) for history, and data[symbol] for its current visible DataFrame.
Persist state across callbacks on g:
def initialize(context):
g.last_signal = ""
g.rebalance_count = 0
Do not store strategy state in files, databases, or external module services. g is the per-run user state namespace.
10. Parameters¶
# @param fast_period int 20 Fast moving-average period range=2:100:1
# @param slow_period int 50 Slow moving-average period range=3:250:1
# @param target_pct float 0.95 Target weight values=0.5,0.75,0.95
# @param enabled bool true Enable entries
Read values through context:
fast_period = int(context.params.get("fast_period", 20))
slow_period = int(context.params.get("slow_period", 50))
target_pct = float(context.params.get("target_pct", 0.95))
enabled = bool(context.params.get("enabled", True))
Declared defaults and code fallbacks must agree. The parameter panel supplies context.params; the fallback remains the final default when a value is absent.
Symbols, market, timeframe, and leverage permission are source contract fields. Do not disguise them as ordinary run-form overrides.
11. History, factors, and fundamentals¶
Single-instrument history:
bars = get_history(
60,
"1d",
["open", "high", "low", "close", "volume"],
"USStock:SPY",
)
One instrument returns a DataFrame. Multiple instruments return a dict of canonical instrument keys to DataFrames:
frames = data.history(
["USStock:AAPL", "USStock:MSFT"],
count=30,
fields=["close", "volume"],
)
Technical indicators and factors:
rsi_value = factor("rsi", g.symbol, period=14)
macd = indicator("MACD", g.symbol, fastperiod=12, slowperiod=26, signalperiod=9)
scores = get_factors(symbols, ["momentum_20", "volatility_20"])
Fundamentals:
fundamentals = get_fundamentals(
["PE", "PB", "ROE", "MARKET_CAP"],
symbols,
)
Other public aliases include REVENUE_GROWTH, DEBT_TO_EQUITY, and FREE_CASH_FLOW. Use only real point-in-time fields supported by the platform; do not invent fields or read future reports.
Pass a symbol to factor/indicator in a multi-asset strategy. The symbol may be omitted only when the data portal has exactly one instrument.
12. Positions and order APIs¶
Read positions:
position = get_position(g.symbol)
all_positions = get_positions()
Common Position fields:
symbolamountavg_costlast_pricemarket_value
Order functions:
| Function | Meaning |
|---|---|
order(symbol, amount) |
add/subtract a quantity |
order_value(symbol, value) |
add/subtract quote-currency value |
order_target(symbol, amount) |
set a target quantity |
order_target_value(symbol, value) |
set a target quote value |
order_target_percent(symbol, percent) |
set a target share of portfolio equity |
Target APIs are usually best for repeatable rebalancing. Give every order a stable reason:
order_target_percent(
g.symbol,
0.5,
reason="breakout_long_entry",
)
Write spot and all non-Crypto markets as long-only under the current product policy. A long exit and a short entry are independent; do not turn a zero target into a negative position automatically.
The engine accounts for commission, slippage, lot size, liquidity caps, price limits, and suspensions. Deferred and rejected requests appear in the order audit ledger. “No fill” does not necessarily mean “no signal.”
13. Stop, take-profit, trailing, and time protection¶
Attach protection to an entry:
order_target_percent(
g.symbol,
0.8,
reason="breakout_long_entry",
stop_loss_pct=0.03,
take_profit_pct=0.08,
trailing_stop_pct=0.025,
trailing_activation_pct=0.02,
time_limit_seconds=86400 * 10,
)
Or set defaults for later entries:
set_default_protection(
stop_loss_pct=0.03,
take_profit_pct=0.08,
)
Percentage fields are ratios: 0.03 means 3%. Values are clamped to safe ranges, and negatives become zero.
Backtest behavior:
- A gap through a protection threshold fills at the available bar open.
- An intrabar touch fills at the trigger price.
- If several protections trigger in one bar, conservative mode prioritizes stop-loss, trailing stop, time limit, then take-profit.
Live execution checks the same protection semantics on an independent price clock instead of waiting for the next strategy bar. Protection state can be persisted and restored after a session restart.
14. Leverage and shorting¶
Only a static universe consisting entirely of Crypto swap instruments may declare:
def initialize(context):
g.symbol = "Crypto:BTC/USDT@okx:swap"
context.set_universe([g.symbol])
context.subscribe(frequency="1h")
context.allow_leverage(max_leverage=5)
Rules:
- Do not call
allow_leveragefor Crypto spot, equities, index/pool universes, or non-Crypto markets. - Dynamic universes cannot enable contract leverage.
- Backtest/deployment leverage cannot exceed the source maximum.
- A run form cannot force leverage on when the source has not permitted it.
- The runtime applies the selected leverage; do not multiply order sizing by leverage again.
- Shorting belongs only in swap strategies and requires independent short-entry, short-exit, and risk rules.
15. Complete CTA tutorial: dual EMA trend¶
"""Dual EMA Long Trend
Trades a long-only daily SPY trend with a protected entry and next-open fills.
"""
# @param fast_period int 20 Fast EMA period range=5:80:5
# @param slow_period int 50 Slow EMA period range=20:250:10
# @param target_pct float 0.95 Target portfolio weight range=0.1:1.0:0.05
# @param stop_loss_pct float 0.05 Entry stop-loss ratio range=0.01:0.15:0.01
def initialize(context):
g.symbol = "USStock:SPY"
context.set_universe([g.symbol])
context.subscribe(frequency="1d")
context.set_warmup(300)
context.set_benchmark("USStock:SPY")
def handle_data(context, data):
fast_period = int(context.params.get("fast_period", 20))
slow_period = int(context.params.get("slow_period", 50))
target_pct = float(context.params.get("target_pct", 0.95))
stop_loss_pct = float(context.params.get("stop_loss_pct", 0.05))
if fast_period >= slow_period:
log.warning("fast_period must be smaller than slow_period")
return
bars = get_history(
slow_period + 2,
"1d",
"close",
g.symbol,
)
if len(bars) < slow_period + 1:
return
close = bars["close"]
fast_now = float(close.ewm(span=fast_period, adjust=False).mean().iloc[-1])
slow_now = float(close.ewm(span=slow_period, adjust=False).mean().iloc[-1])
position = get_position(g.symbol)
if fast_now > slow_now and position.amount <= 0:
order_target_percent(
g.symbol,
target_pct,
reason="dual_ema_long_entry",
stop_loss_pct=stop_loss_pct,
)
elif fast_now < slow_now and position.amount > 0:
order_target_percent(
g.symbol,
0.0,
reason="dual_ema_long_exit",
)
Why it is structured this way:
- Universe, frequency, and benchmark live in source.
- Warm-up covers the slow EMA, while runtime length is still checked.
- Invalid fast/slow combinations stop the current event.
- Entry and exit are exclusive; the bearish condition exits a long but does not short.
- A completed daily bar emits an order for the next open.
- Protection is attached to the entry; the exit targets zero.
16. Portfolio tutorial: weekly factor rebalance¶
"""S&P 500 Momentum Basket
Selects the strongest five point-in-time pool members and rebalances weekly.
"""
# @param holdings int 5 Number of holdings range=3:20:1
# @param max_weight float 0.18 Maximum weight per holding range=0.05:0.3:0.01
def initialize(context):
context.set_universe(pool="sp500")
context.subscribe(frequency="1d")
context.set_warmup(80)
context.set_benchmark("USStock:SPY")
run_weekly(rebalance, weekday=1, time="09:35")
def rebalance(context, data):
holdings = int(context.params.get("holdings", 5))
max_weight = float(context.params.get("max_weight", 0.18))
symbols = get_universe_stocks()
if len(symbols) < holdings:
return
scores = get_factors(symbols, "momentum_20")
if scores.empty or "momentum_20" not in scores.columns:
return
ranked = scores["momentum_20"].dropna().sort_values(ascending=False)
selected = list(ranked.head(holdings).index)
if not selected:
return
target_weight = min(max_weight, 0.95 / len(selected))
current = get_positions()
for symbol in current:
if symbol not in selected:
order_target_percent(symbol, 0.0, reason="weekly_remove")
for symbol in selected:
order_target_percent(symbol, target_weight, reason="weekly_select")
This strategy class must use point-in-time universe and factor data. Evaluate coverage, survivorship bias, turnover, trading costs, lot sizes, and unfilled orders in addition to headline return.
17. Backtests, results, and diagnosis¶
Core backtest request:
{
"code": "...",
"startDate": "2024-01-01",
"endDate": "2025-12-31",
"initialCapital": 100000,
"commission": 0.0005,
"slippage": 0.0005,
"leverageEnabled": false,
"leverage": 1,
"params": {},
"persist": true
}
You may supply sourceId or strategyId to load saved source. The request cannot override source markets, instruments, or frequency.
Inspect:
resultStatus:no_signals,open_position_only, orcompleted_trades.totalExecutions: fill count.totalTrades: closed round-trip count, not fill count.rawTrades/executions: opens, adds, reductions, and closes.closedTrades: completed round trips.orderLedger: fills, deferrals, rejections, and reasons.holdingSnapshots/rebalanceRecords: portfolio evolution.equityCurve, drawdown, win rate, Profit Factor, benchmark, and excess return.dataProvenance/executionAssumptions: data origin and fill model.
Zero executions can be valid: insufficient history, conditions never met, poor parameters, missing data, or rejected orders. Read logs and the order ledger before treating it as an engine failure.
18. Deployment and live boundaries¶
Core deployment fields include:
sourceIdnameinitialCapitalexecutionMode:signalorlive- optional
credentialId,params, leverage, position side, and notifications
A new deployment is stopped and must be started explicitly. Stop it before deletion.
Current live-account boundaries:
- Crypto live execution requires a supported exchange credential.
- USStock live execution requires Alpaca or IBKR.
- Mixed-market live deployment is unsupported.
- Other markets cannot be forced through a mismatched credential.
Use signal mode first to validate notifications, signal frequency, and state restoration. A successful backtest does not prove that credentials, balances, venue rules, minimum order sizes, and network health are ready for live trading.
19. Sandbox and common failures¶
Strategy source runs in a safe execution environment. File, network, database, process, dynamic execution, reflection, and unsafe imports are prohibited. Do not use eval, exec, compile, open, dunder bypasses, or external state.
| Error | Meaning | Fix |
|---|---|---|
strategyV2.codeRequired |
empty source | submit complete source |
strategyV2.initializeRequired |
initialize missing | add it |
strategyV2.initializeFailed:... |
initialization failed | keep initialize declarative |
strategyV2.universeRequired |
universe missing | call set_universe |
strategyV2.handlerRequired |
no handler/schedule | add a handler or schedule |
strategyV2.leverageCryptoSwapOnly |
invalid leverage market | use static Crypto swaps only |
strategyV2.leverageNotAllowed |
run requests unpermitted leverage | permit it legally or disable it |
strategyV2.leverageExceedsStrategyLimit |
requested leverage too high | lower the request |
strategyV2.dataUnavailable:... |
instrument data unavailable | check canonical symbol and range |
strategyV2.runtimeFailed:... |
handler raised | inspect the named handler and cause |
20. Pre-publication checklist¶
- [ ] The file has an English docstring covering name, universe, signals, schedule, and risk.
- [ ]
initializeonly declares universe, subscription, warm-up, benchmark, schedules, leverage permission, and initialg. - [ ] Instruments are canonical and Crypto explicitly distinguishes spot/swap.
- [ ] The source owns instruments and frequency; no run-form override is assumed.
- [ ] Parameter defaults and code fallbacks agree.
- [ ] Every history window checks actual length.
- [ ] No future rows, negative shifts, or centered rolling.
- [ ] Long exits and short entries are independent.
- [ ] Exposure is capped; grid, DCA, martingale, and scaling layers have hard limits.
- [ ] Every order has an auditable reason.
- [ ] Risk percentages use decimal ratios.
- [ ] Leverage is declared only for Crypto swaps and is not multiplied twice.
- [ ] The manifest verifies successfully.
- [ ] The order ledger is reviewed, not only the equity curve.
- [ ] Robustness is tested across periods and cost assumptions.
- [ ] At least one successful backtest exists before publication.
- [ ] Credentials, market, balance, lot size, and notifications are checked before live use.
