# 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 <code>output</code>.

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:

~~~python
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:

1. Declare display metadata.
2. Create a working copy with <code>df = df.copy()</code>.
3. calculate data aligned one-to-one with the K-line rows.
4. Set the <code>output</code> dictionary.

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 <code>output</code> rendering |
| Indicator-to-Strategy | translating visual signal meaning into executable code | mixing order behavior into the source indicator |

<code>output["signals"]</code> contains chart markers only. A <code>sell</code>-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 → <code>open_long</code>
- explicit bearish exit event → <code>close_long</code>
- generate <code>open_short</code> only when the user explicitly requests shorting and provides a distinct bearish entry rule

Do not create execution columns such as <code>open_long</code>, <code>close_long</code>, <code>open_short</code>, <code>close_short</code>, <code>add_long</code>, or <code>reduce_long</code> in an indicator. Do not use legacy <code># @strategy</code> annotations.

---

## 3. Runtime and input data

The runtime provides:

- <code>df</code>: a pandas DataFrame for the current chart, ordered oldest to newest, one row per bar.
- <code>params</code>: a dict produced by merging declared defaults with values from the parameter panel.
- <code>pd</code>: preloaded pandas.
- <code>np</code>: preloaded numpy.
- <code>open</code>, <code>high</code>, <code>low</code>, <code>close</code>, and <code>volume</code>: some runtime entries also expose convenience Series. Prefer explicit <code>df["close"]</code> access for clarity and portability.

Standard OHLCV access:

~~~python
open_price = df["open"]
high = df["high"]
low = df["low"]
close = df["close"]
volume = df["volume"]
~~~

Important rules:

- Do not assume a <code>time</code> column exists; time may already be the DataFrame index.
- Do not rename or remove required OHLCV columns.
- Check before using optional fields, for example <code>if "turnover" in df.columns:</code>.
- Run <code>df = df.copy()</code> before mutations.
- Prefer vectorized <code>rolling</code>, <code>ewm</code>, <code>shift</code>, and <code>where</code> operations for core series.

---

## 4. Recommended file structure and metadata

~~~python
# @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:

~~~python
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:

~~~python
# @param <name> <int|float|bool|str> <default> <description>
~~~

Example:

~~~python
# @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:

~~~python
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 <code>int</code>, <code>float</code>, <code>bool</code>, <code>str</code>, or <code>string</code>.
- The declared default must match the <code>params.get</code> fallback after type conversion.
- Write booleans as <code>true</code>/<code>false</code> in declarations and <code>True</code>/<code>False</code> in Python.
- A string default cannot contain spaces because it is parsed as one token.

Declare search candidates at the end of the description:

~~~python
# @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
~~~

<code>range=start:end:step</code> produces an inclusive arithmetic sequence. <code>values=a,b,c</code> 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 <code>output</code>:

~~~python
output = {
    "name": my_indicator_name,
    "plots": plots,
    "signals": signals,
    "layers": layers,
}
~~~

Optional:

~~~python
output["calculatedVars"] = {}
~~~

Validation rules:

- <code>output</code> must be a dict.
- At least the <code>plots</code> or <code>signals</code> key must exist.
- Every <code>plot["data"]</code> must have length <code>len(df)</code>.
- Every <code>signal["data"]</code> must have length <code>len(df)</code>.
- Do not emit NaN, positive infinity, or negative infinity; use <code>None</code> for 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:

~~~python
output = {
    "name": my_indicator_name,
    "plots": [],
    "signals": [],
    "layers": [],
}
~~~

---

## 7. plots: price overlays and pane series

Each plot normally contains:

| Field | Type | Meaning |
| --- | --- | --- |
| <code>name</code> | str | legend and series name |
| <code>data</code> | list | values/<code>None</code>, length <code>len(df)</code> |
| <code>color</code> | str | preferably <code>#RRGGBB</code> |
| <code>overlay</code> | bool | <code>True</code> on price chart, <code>False</code> in a pane |
| <code>type</code> | optional str | commonly <code>line</code>; other current renderer styles may be used |

~~~python
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:

~~~python
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

~~~python
signals = [
    {
        "type": "buy",
        "text": "Long Entry",
        "color": "#22C55E",
        "data": entry_marks,
    },
    {
        "type": "sell",
        "text": "Long Exit",
        "color": "#EF4444",
        "data": exit_marks,
    },
]
~~~

Rules:

- <code>type</code> is commonly <code>buy</code> or <code>sell</code>. It controls marker orientation, not the signal name.
- <code>text</code> is the stable name. Optional <code>textData</code> can provide a per-bar label.
- Only a finite numeric <code>data[i]</code> activates a signal on bar i.
- <code>text</code> and <code>textData</code> never activate a signal by themselves.
- Empty positions must contain real <code>None</code> values.
- Mark one-bar events by default instead of repeating a persistent state.

Convert a state into its rising edge:

~~~python
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:

~~~python
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”:

~~~python
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:

~~~python
{
    "type": "zone",
    "startIndex": 120,
    "endIndex": 180,
    "top": 105.2,
    "bottom": 101.8,
    "text": "Demand",
    "fillColor": "#22C55E",
    "borderColor": "#22C55E",
    "opacity": 0.12,
}
~~~

Horizontal line:

~~~python
{
    "type": "line",
    "startIndex": 100,
    "endIndex": len(df) - 1,
    "price": 98.5,
    "text": "Support",
    "color": "#F59E0B",
    "dashed": True,
}
~~~

For a sloped line, replace <code>price</code> with <code>startPrice</code> and <code>endPrice</code>. Label:

~~~python
{
    "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 <code>df</code>. Matching <code>startTime</code>, <code>endTime</code>, and <code>time</code> 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:

~~~python
values = np.where(close > close.shift(1), close, 0)
average = values.rolling(10).mean()
~~~

<code>np.where</code> may return ndarray, which has no <code>rolling</code>, <code>shift</code>, <code>ewm</code>, <code>fillna</code>, or <code>iloc</code>.

Prefer pandas-native operations:

~~~python
values = close.where(close > close.shift(1), 0)
average = values.rolling(10).mean()
~~~

When wrapping an ndarray is unavoidable:

~~~python
array = np.where(close > close.shift(1), close, 0)
values = pd.Series(array, index=df.index)
~~~

Always pass <code>index=df.index</code>. Otherwise the new RangeIndex can silently misalign with a DatetimeIndex.

| numpy form | Preferred pandas form |
| --- | --- |
| <code>np.where(cond, a, b)</code> | <code>a.where(cond, b)</code> |
| <code>np.maximum(s, 0)</code> | <code>s.clip(lower=0)</code> |
| <code>np.minimum(s, k)</code> | <code>s.clip(upper=k)</code> |
| <code>np.abs(s)</code> | <code>s.abs()</code> |

---

## 11. Avoid look-ahead and repainting

An indicator may use only the current and earlier bars. Do not use:

- <code>shift(-1)</code> or <code>shift(-N)</code>;
- <code>iloc[i + 1]</code> inside row loops;
- <code>bars_ago(-N)</code>;
- <code>rolling(..., center=True)</code>;
- 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:

~~~python
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 <code>pd</code> and <code>np</code> are preloaded, imports are normally unnecessary.

Do not use:

- network, file, database, or subprocess access;
- <code>eval</code>, <code>exec</code>, <code>compile</code>, or <code>open</code>;
- reflection, dynamic imports, dunder escapes, or sandbox bypasses;
- pandas/numpy file read, write, or serialization methods;
- modules such as <code>os</code>, <code>sys</code>, <code>requests</code>, <code>socket</code>, <code>subprocess</code>, <code>threading</code>, <code>sqlite3</code>, <code>pathlib</code>, <code>pickle</code>, <code>ctypes</code>, or <code>operator</code>.

Validation has a timeout. Avoid unbounded loops, explosive recursion, and unnecessarily expensive row-by-row algorithms.

---

## 13. Complete tutorial: dual EMA viewer

~~~python
# @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:

1. Parameter declarations drive the panel and candidate ranges.
2. <code>params.get</code> reads exactly matching defaults.
3. EMAs are persistent state, so they belong in plots.
4. Crossovers are one-time events, so <code>edge</code> converts them into signals.
5. Empty marker slots contain <code>None</code>.
6. 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:

1. Save a version.
2. Run/preview the indicator.
3. Execute code validation.
4. Inspect warm-up bars, extreme markets, and short datasets.
5. Change parameters and confirm that results respond.
6. Confirm that signals appear only on event bars.

| Hint or error | Cause | Fix |
| --- | --- | --- |
| <code>EMPTY_CODE</code> | no source | provide complete indicator code |
| <code>MISSING_OUTPUT</code> | no output assignment | add the output dict |
| <code>MissingOutput</code> | no output after execution | check branches and scope |
| <code>InvalidOutputType</code> | output is not a dict | return a dict |
| <code>InvalidOutputStructure</code> | neither plots nor signals exists | provide at least one key |
| <code>LengthMismatch</code> | data length differs from bars | make every data list <code>len(df)</code> |
| <code>MISSING_DF_COPY</code> | missing working copy | add <code>df = df.copy()</code> |
| <code>PARAM_DEFAULT_MISMATCH</code> | declaration and fallback differ | align both defaults |
| <code>DECLARED_PARAMS_NOT_READ_VIA_PARAMS_GET</code> | declared but not read | call <code>params.get</code> |
| <code>EXECUTION_COLUMNS_IGNORED_FOR_INDICATOR</code> | trade columns detected | remove them and convert to V2 |
| <code>STRATEGY_ANNOTATIONS_IGNORED_FOR_INDICATOR</code> | legacy strategy annotations | remove them |
| <code>NDARRAY_PANDAS_METHOD_MISUSE</code> | ndarray used as Series | use pandas or wrap with its index |
| <code>FUTURE_DATA_LEAK</code> | 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.
- [ ] <code>df = df.copy()</code> is present.
- [ ] Every parameter is read through <code>params.get</code> with a matching default.
- [ ] No order, position, leverage, or execution-risk logic is present.
- [ ] <code>output</code> is a dict.
- [ ] Every plot/signal data list has length <code>len(df)</code>.
- [ ] Missing values are <code>None</code>; 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.
