QuantDinger Indicator Development Guide¶
Applies to: the current QuantDinger chart-indicator contract Audience: first-time indicator authors, developers migrating Pine/TDX formulas, and reviewers of AI-generated code
A QuantDinger indicator is a Python chart program that runs in the Indicator IDE. It reads the current K-line DataFrame, calculates aligned series, and returns plots, markers, and sparse chart layers through output.
The most important boundary is: an indicator is for chart analysis, not trade execution.
An indicator cannot place orders, backtest, run live trading, read an account, manage positions, enable leverage, or execute stop-loss/take-profit rules. To trade an idea, convert its visual signals into a Strategy API V2 strategy, then verify, backtest, and deploy that strategy separately.
1. Build a minimal indicator first¶
Paste this into the Indicator IDE and run it:
my_indicator_name = "Close Line"
my_indicator_description = "Displays the close price as a chart overlay."
df = df.copy()
close_line = [
None if pd.isna(value) else float(value)
for value in df["close"]
]
output = {
"name": my_indicator_name,
"plots": [
{
"name": "Close",
"data": close_line,
"color": "#3B82F6",
"type": "line",
"overlay": True,
}
],
"signals": [],
"layers": [],
}
This is the complete minimal contract:
- Declare display metadata.
- Create a working copy with
df = df.copy(). - calculate data aligned one-to-one with the K-line rows.
- Set the
outputdictionary.
For a new indicator, first draw one line, then add parameters, then event markers, and only then add advanced layers.
2. Indicator, strategy, and conversion boundaries¶
| Artifact | Owns | Does not own |
|---|---|---|
| Chart Indicator | plots, panes, lamp rows, visual markers, zones, labels | backtest, live trading, orders, positions, leverage, execution risk |
| Strategy API V2 | subscriptions, signals, order intents, positions, backtest, live execution, protections | Indicator IDE output rendering |
| Indicator-to-Strategy | translating visual signal meaning into executable code | mixing order behavior into the source indicator |
output["signals"] contains chart markers only. A sell-oriented “Death” marker may mean a long-exit warning or weakening conditions. It does not automatically open a short or reverse a position.
For a long-only conversion, the usual mapping is:
- explicit bullish entry event →
open_long - explicit bearish exit event →
close_long - generate
open_shortonly when the user explicitly requests shorting and provides a distinct bearish entry rule
Do not create execution columns such as open_long, close_long, open_short, close_short, add_long, or reduce_long in an indicator. Do not use legacy # @strategy annotations.
3. Runtime and input data¶
The runtime provides:
df: a pandas DataFrame for the current chart, ordered oldest to newest, one row per bar.params: a dict produced by merging declared defaults with values from the parameter panel.pd: preloaded pandas.np: preloaded numpy.open,high,low,close, andvolume: some runtime entries also expose convenience Series. Prefer explicitdf["close"]access for clarity and portability.
Standard OHLCV access:
open_price = df["open"]
high = df["high"]
low = df["low"]
close = df["close"]
volume = df["volume"]
Important rules:
- Do not assume a
timecolumn exists; time may already be the DataFrame index. - Do not rename or remove required OHLCV columns.
- Check before using optional fields, for example
if "turnover" in df.columns:. - Run
df = df.copy()before mutations. - Prefer vectorized
rolling,ewm,shift, andwhereoperations for core series.
4. Recommended file structure and metadata¶
# @param period int 20 Calculation period
my_indicator_name = "Example Indicator"
my_indicator_description = "Explains what is drawn and how events are marked."
df = df.copy()
period = int(params.get("period", 20))
# Helper functions
# Series calculation
# Marker construction
output = {
"name": my_indicator_name,
"plots": [],
"signals": [],
"layers": [],
}
Every indicator should declare:
my_indicator_name = "Dual EMA Viewer"
my_indicator_description = "Chart-only EMA crossover indicator with visual event markers."
Keep the name short and stable. The description should say:
- what is calculated;
- whether it appears on the price chart or a separate pane;
- what each event marker means;
- which parameters matter.
Do not promise returns or imply live validation.
Project source rules require English identifiers, metadata, comments, parameter descriptions, and default display labels. Localize display text only when a user explicitly requests a target language.
5. Declaring parameters¶
Syntax:
# @param <name> <int|float|bool|str> <default> <description>
Example:
# @param fast_len int 12 Fast EMA period
# @param slow_len int 26 Slow EMA period
# @param band_pct float 1.5 Channel width percent
# @param show_marks bool true Show crossover markers
# @param source str close Price source
A declaration does not create a Python variable. Read every value explicitly:
fast_len = int(params.get("fast_len", 12))
slow_len = int(params.get("slow_len", 26))
band_pct = float(params.get("band_pct", 1.5))
show_marks = bool(params.get("show_marks", True))
source = str(params.get("source", "close"))
Contract rules:
- Declare one parameter per line.
- Use valid Python identifiers for names.
- Use only
int,float,bool,str, orstring. - The declared default must match the
params.getfallback after type conversion. - Write booleans as
true/falsein declarations andTrue/Falsein Python. - A string default cannot contain spaces because it is parsed as one token.
Declare search candidates at the end of the description:
# @param period int 20 Lookback period range=5:60:5
# @param multiplier float 2.0 Band multiplier values=1.5,2.0,2.5,3.0
range=start:end:step produces an inclusive arithmetic sequence. values=a,b,c declares an explicit list. One parameter expands to at most 1,024 values. Range markers are removed from the human-facing description.
Indicator parameters control calculations and display only. Do not declare account, symbol, timeframe, position, leverage, stop-loss, or take-profit settings.
6. The output contract¶
The program must finish by setting a dict named output:
output = {
"name": my_indicator_name,
"plots": plots,
"signals": signals,
"layers": layers,
}
Optional:
output["calculatedVars"] = {}
Validation rules:
outputmust be a dict.- At least the
plotsorsignalskey must exist. - Every
plot["data"]must have lengthlen(df). - Every
signal["data"]must have lengthlen(df). - Do not emit NaN, positive infinity, or negative infinity; use
Nonefor missing points. - Layers do not need per-bar arrays, but indices, times, and prices must be meaningful for the current data.
Explicit empty lists are recommended:
output = {
"name": my_indicator_name,
"plots": [],
"signals": [],
"layers": [],
}
7. plots: price overlays and pane series¶
Each plot normally contains:
| Field | Type | Meaning |
|---|---|---|
name |
str | legend and series name |
data |
list | values/None, length len(df) |
color |
str | preferably #RRGGBB |
overlay |
bool | True on price chart, False in a pane |
type |
optional str | commonly line; other current renderer styles may be used |
plots = [
{
"name": "EMA Fast",
"data": fast_values,
"color": "#22C55E",
"type": "line",
"overlay": True,
},
{
"name": "RSI",
"data": rsi_values,
"color": "#8B5CF6",
"type": "line",
"overlay": False,
},
]
Moving averages, Bollinger bands, and price channels normally overlay the main chart. RSI, MACD, and lamp rows normally use separate panes.
Use one missing-value conversion helper:
def to_plot_list(series):
return [
None if pd.isna(value) else float(value)
for value in series
]
Do not fill warm-up gaps in price overlays with zero. It draws misleading lines from zero to the actual market price.
8. signals: sparse visual events¶
signals = [
{
"type": "buy",
"text": "Long Entry",
"color": "#22C55E",
"data": entry_marks,
},
{
"type": "sell",
"text": "Long Exit",
"color": "#EF4444",
"data": exit_marks,
},
]
Rules:
typeis commonlybuyorsell. It controls marker orientation, not the signal name.textis the stable name. OptionaltextDatacan provide a per-bar label.- Only a finite numeric
data[i]activates a signal on bar i. textandtextDatanever activate a signal by themselves.- Empty positions must contain real
Nonevalues. - Mark one-bar events by default instead of repeating a persistent state.
Convert a state into its rising edge:
def edge(condition):
current = condition.fillna(False).astype(bool)
previous = current.shift(1, fill_value=False).astype(bool)
return current & ~previous
Build marker-price lists:
entry_event = edge(ema_fast > ema_slow)
exit_event = edge(ema_fast < ema_slow)
entry_marks = [
float(df["low"].iloc[i] * 0.995)
if bool(entry_event.iloc[i])
else None
for i in range(len(df))
]
exit_marks = [
float(df["high"].iloc[i] * 1.005)
if bool(exit_event.iloc[i])
else None
for i in range(len(df))
]
For “show on the next bar after confirmation”:
confirmed_entry = edge(raw_entry).shift(
1,
fill_value=False,
).astype(bool)
This moves a completed event later; it does not read future data.
9. layers: zones, lines, and labels¶
Prefer plots and signals for normal indicators. Add layers only for supply/demand zones, support/resistance, channels, invalidation levels, or structure labels that materially improve readability.
Zone:
{
"type": "zone",
"startIndex": 120,
"endIndex": 180,
"top": 105.2,
"bottom": 101.8,
"text": "Demand",
"fillColor": "#22C55E",
"borderColor": "#22C55E",
"opacity": 0.12,
}
Horizontal line:
{
"type": "line",
"startIndex": 100,
"endIndex": len(df) - 1,
"price": 98.5,
"text": "Support",
"color": "#F59E0B",
"dashed": True,
}
For a sloped line, replace price with startPrice and endPrice. Label:
{
"type": "label",
"index": len(df) - 1,
"price": float(df["close"].iloc[-1]),
"text": "Trend Weakens",
"color": "#EF4444",
"textColor": "#FFFFFF",
}
Indices are the most stable representation for the current df. Matching startTime, endTime, and time values are also supported.
Layers remain visual objects. They do not represent real orders, positions, or hosted stops.
10. pandas and numpy type traps¶
The most common failure is treating a numpy ndarray as a pandas Series.
Incorrect:
values = np.where(close > close.shift(1), close, 0)
average = values.rolling(10).mean()
np.where may return ndarray, which has no rolling, shift, ewm, fillna, or iloc.
Prefer pandas-native operations:
values = close.where(close > close.shift(1), 0)
average = values.rolling(10).mean()
When wrapping an ndarray is unavoidable:
array = np.where(close > close.shift(1), close, 0)
values = pd.Series(array, index=df.index)
Always pass index=df.index. Otherwise the new RangeIndex can silently misalign with a DatetimeIndex.
| numpy form | Preferred pandas form |
|---|---|
np.where(cond, a, b) |
a.where(cond, b) |
np.maximum(s, 0) |
s.clip(lower=0) |
np.minimum(s, k) |
s.clip(upper=k) |
np.abs(s) |
s.abs() |
11. Avoid look-ahead and repainting¶
An indicator may use only the current and earlier bars. Do not use:
shift(-1)orshift(-N);iloc[i + 1]inside row loops;bars_ago(-N);rolling(..., center=True);- the final full-dataset row to rewrite past signals;
- future highs, lows, or confirmations to mark an earlier bar.
A valid crossover uses the current and previous state:
cross_up = (
(ema_fast > ema_slow)
& (ema_fast.shift(1) <= ema_slow.shift(1))
)
If a signal requires the current close to confirm, its converted strategy should execute later. Do not move the signal backward merely to improve the chart or backtest.
12. Sandbox and safety rules¶
Allowed computational modules include numpy, pandas, math, json, datetime, time, collections, functools, itertools, statistics, decimal, fractions, and copy. Since pd and np are preloaded, imports are normally unnecessary.
Do not use:
- network, file, database, or subprocess access;
eval,exec,compile, oropen;- reflection, dynamic imports, dunder escapes, or sandbox bypasses;
- pandas/numpy file read, write, or serialization methods;
- modules such as
os,sys,requests,socket,subprocess,threading,sqlite3,pathlib,pickle,ctypes, oroperator.
Validation has a timeout. Avoid unbounded loops, explosive recursion, and unnecessarily expensive row-by-row algorithms.
13. Complete tutorial: dual EMA viewer¶
# @param fast_len int 12 Fast EMA period range=5:30:1
# @param slow_len int 26 Slow EMA period range=10:80:2
# @param confirm_next_bar bool false Show markers one bar after confirmation
# @param show_marks bool true Show crossover markers
my_indicator_name = "Dual EMA Viewer"
my_indicator_description = "Displays two EMAs and marks confirmed crossover events."
df = df.copy()
fast_len = int(params.get("fast_len", 12))
slow_len = int(params.get("slow_len", 26))
confirm_next_bar = bool(params.get("confirm_next_bar", False))
show_marks = bool(params.get("show_marks", True))
close = df["close"]
high = df["high"]
low = df["low"]
def edge(condition):
current = condition.fillna(False).astype(bool)
previous = current.shift(1, fill_value=False).astype(bool)
return current & ~previous
def to_plot_list(series):
return [
None if pd.isna(value) else float(value)
for value in series
]
ema_fast = close.ewm(span=fast_len, adjust=False).mean()
ema_slow = close.ewm(span=slow_len, adjust=False).mean()
golden = edge(ema_fast > ema_slow)
death = edge(ema_fast < ema_slow)
if confirm_next_bar:
golden = golden.shift(1, fill_value=False).astype(bool)
death = death.shift(1, fill_value=False).astype(bool)
golden_marks = [
float(low.iloc[i] * 0.995)
if show_marks and bool(golden.iloc[i])
else None
for i in range(len(df))
]
death_marks = [
float(high.iloc[i] * 1.005)
if show_marks and bool(death.iloc[i])
else None
for i in range(len(df))
]
output = {
"name": my_indicator_name,
"plots": [
{
"name": "EMA Fast",
"data": to_plot_list(ema_fast),
"color": "#22C55E",
"type": "line",
"overlay": True,
},
{
"name": "EMA Slow",
"data": to_plot_list(ema_slow),
"color": "#3B82F6",
"type": "line",
"overlay": True,
},
],
"signals": [
{
"type": "buy",
"text": "Long Entry",
"color": "#22C55E",
"data": golden_marks,
},
{
"type": "sell",
"text": "Long Exit",
"color": "#EF4444",
"data": death_marks,
},
],
"layers": [],
}
How it works:
- Parameter declarations drive the panel and candidate ranges.
params.getreads exactly matching defaults.- EMAs are persistent state, so they belong in plots.
- Crossovers are one-time events, so
edgeconverts them into signals. - Empty marker slots contain
None. - The bearish marker is explicitly named “Long Exit,” preventing an accidental short-entry interpretation during conversion.
14. Validation, debugging, and common errors¶
Use this workflow after each meaningful change:
- Save a version.
- Run/preview the indicator.
- Execute code validation.
- Inspect warm-up bars, extreme markets, and short datasets.
- Change parameters and confirm that results respond.
- Confirm that signals appear only on event bars.
| Hint or error | Cause | Fix |
|---|---|---|
EMPTY_CODE |
no source | provide complete indicator code |
MISSING_OUTPUT |
no output assignment | add the output dict |
MissingOutput |
no output after execution | check branches and scope |
InvalidOutputType |
output is not a dict | return a dict |
InvalidOutputStructure |
neither plots nor signals exists | provide at least one key |
LengthMismatch |
data length differs from bars | make every data list len(df) |
MISSING_DF_COPY |
missing working copy | add df = df.copy() |
PARAM_DEFAULT_MISMATCH |
declaration and fallback differ | align both defaults |
DECLARED_PARAMS_NOT_READ_VIA_PARAMS_GET |
declared but not read | call params.get |
EXECUTION_COLUMNS_IGNORED_FOR_INDICATOR |
trade columns detected | remove them and convert to V2 |
STRATEGY_ANNOTATIONS_IGNORED_FOR_INDICATOR |
legacy strategy annotations | remove them |
NDARRAY_PANDAS_METHOD_MISUSE |
ndarray used as Series | use pandas or wrap with its index |
FUTURE_DATA_LEAK |
future access detected | use current and past data only |
15. Semantic checklist before strategy conversion¶
Answer these questions before converting:
- Which marker is the long entry?
- Which marker is the long exit?
- Is shorting genuinely required? A bearish long exit is not automatically a short entry.
- Is reversal required? If so, are closing and reverse entry separate actions?
- On which timeframe and completed bar is the signal confirmed?
- Are repeated entries, scale-in, or scale-out allowed?
- How should sizing, stop-loss, take-profit, and trailing protection work?
- Which instrument and market type will the strategy own?
The converted strategy should remove chart-only colors, label offsets, plots, layers, and marker arrays. Preserve the signal algebra, then declare instruments, frequency, sizing, and risk explicitly with Strategy API V2.
Always verify and backtest generated strategy code again. Marketplace publication requires at least one successful backtest record.
16. Pre-publication checklist¶
- [ ] Name and description exist and make no return claims.
- [ ] Comments, identifiers, metadata, and default labels are English.
- [ ]
df = df.copy()is present. - [ ] Every parameter is read through
params.getwith a matching default. - [ ] No order, position, leverage, or execution-risk logic is present.
- [ ]
outputis a dict. - [ ] Every plot/signal data list has length
len(df). - [ ] Missing values are
None; no NaN or infinity is emitted. - [ ] Signals are sparse events; persistent states use plots or a few layers.
- [ ] No future data is accessed.
- [ ] Numpy results are converted to indexed Series before pandas-only methods.
- [ ] Short datasets, warm-up periods, and unusual parameters do not crash.
- [ ] A version is saved and preview/validation passes.
