QuantDinger Indicator Development Guide
August 1, 2026 · View on GitHub
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.
The TradingView/Pine compatibility target is: preserve the calculation and visual semantics of common single-symbol, single-timeframe indicators, not Pine syntax, every Pine API, or Pine's tick-by-tick execution model. Pine migrations must rewrite formulas in Python/pandas and return the result through the output contract in this guide.
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 | line, bar, or circle; see aliases below |
Public plot types:
| Input | Normalized result | Typical Pine equivalent |
|---|---|---|
line | connected line | plot(..., style=plot.style_line) |
bar, histogram, column | columns from a baseline | histogram/columns |
circle, dot, point, scatter | circles | circles/scatter |
lamp | lamp dots; three or more lamp series in one pane are arranged into rows | an approximation for state panels |
Style fields apply by figure type:
- Common:
color,opacity. - Line:
lineWidthandlineStyle/style, such assolidordashed. - Bar and circle:
size/radius,borderColor, andborderSize; bars also acceptbaseValue. - All
overlay=Falseplots from one indicator share one pane. They do not automatically create several panes as separate Pine scripts would.
For Pine-like series colors or dynamic point sizes, a data entry may be an object instead of a scalar:
dynamic_points = [
None,
{"value": 102.5, "color": "#22C55E", "size": 4},
{"value": 101.8, "color": "#EF4444", "size": 6},
]
Value aliases are value, y, and data; color aliases are color, fillColor, and backgroundColor; size aliases are size, radius, and r. New code should use the canonical value/color/size fields.
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.- The most stable format for both rendering and notification monitoring is a finite, non-zero marker price in
data[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.
Compatibility formats also accept True, or {"active": True, "price": ..., "text": ..., "color": ...}. When a boolean has no price, the renderer anchors it around the current candle's high or low. For consistent preview, saved signal monitoring, and future clients, new indicators should prefer the “price or None” form and use point objects only when per-point text or color is required.
renderMode controls how persistent conditions become markers:
| Value | Behavior |
|---|---|
events (explicit event mode) | every non-empty event point is rendered and can notify |
points / markers / raw | explicitly preserve every active point |
state / continuous / condition | render and notify only on the False → True edge |
When the mode is omitted, the runtime identifies high-density active series as persistent state and applies edge behavior. Public indicators should not depend on that heuristic; generate sparse events directly or set renderMode explicitly.
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. TradingView/Pine capability mapping
QuantDinger does not execute Pine source. Migration means preserving indicator meaning, numerical timing, and visual expression with Python/pandas. This table is the current contract boundary:
| TradingView/Pine capability | Current status | QuantDinger equivalent or limit |
|---|---|---|
| OHLCV, history references, rolling windows | Supported | df, shift, rolling, ewm |
input.int/float/bool/string | Core semantics supported | # @param; Pine group, inline, confirm, and dedicated color/source/timeframe/symbol controls are not available |
Common ta.* indicators | Calculation semantics are implementable | use pandas/numpy; the indicator runtime has no Pine-compatible ta namespace, so smoothing, warmup, and NaN behavior must be verified |
plot(), histogram, circles | Common subset supported | line/bar/circle plots, overlay/pane placement, and per-point colors/sizes |
plotshape(), plotchar() | Main event semantics supported | use signals; use textData or point objects for dynamic text |
hline(), line, box, label | Common static results supported | line/zone/label layers; each run returns a final object list, without Pine object IDs, mutation methods, or garbage-collection semantics |
alert(), alertcondition() | Partially supported | emit signals, then configure monitoring after saving; indicator code does not directly notify or trade |
| arrays, maps, user functions, state calculations | Python equivalents available | use list/dict, functions, and pandas Series; calculation is vectorized over the DataFrame, not Pine's per-bar var/varip model |
fill(), linefill, bgcolor(), barcolor() | No first-class equivalent yet | a fixed range may be approximated with a zone; this is not full fill, background, or candle-color compatibility |
area/step/cross/arrow, plotcandle()/plotbar() | Not supported yet | an unknown type currently falls back to line; do not rely on that fallback |
| table, polyline, dynamic drawing lifecycle | Not supported yet | do not emit undefined objects |
request.security(), request.*(), cross-symbol/MTF | Not supported yet | indicators receive only the current chart DataFrame and cannot fetch network or exchange data |
barstate.*, tick updates, rollback | Different execution model | the runtime recalculates from the received DataFrame; confirmed-bar events are the reproducible baseline |
| strategy.*, orders, positions, backtesting | Outside indicator scope | convert to Strategy API V2 |
The recommended acceptance criterion for “most features are consistent” is therefore: common single-symbol, single-timeframe indicators produce equivalent values and event bars from the same OHLCV, parameters, and confirmed candles. It does not mean Pine source runs directly, and it excludes multi-timeframe requests, tick state, and advanced drawing objects until those contracts are implemented.
For every Pine migration:
- Record the Pine version, inputs, source series, and indicator timeframe.
- Match exact SMA/EMA/RMA/WMA, standard-deviation, ATR, and crossover definitions; matching names are not enough.
- Match warmup,
napropagation, division-by-zero handling, and the first valid bar. - Separate persistent conditions from one-time events; a Pine condition series must not become a notification on every bar.
- Compare numerical tolerance, event bar indexes, and drawing anchors on a fixed OHLCV fixture.
- If the Pine script uses a “not supported yet” row, simplify it, document an approximation, or extend the platform contract first. Never label it fully compatible in documentation or code comments.
See TradingView's official Pine documentation for its capability categories: Visuals overview, Inputs, Other timeframes and data, and Bar states.
11. 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() |
12. 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.
13. 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.
14. 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.
15. 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 |
16. 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.
17. 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.