Ten minutes from an empty file to an indicator drawing on your chart. Python runs in a process beside the trading engine, on the same data and the same order path the terminal itself uses — so what you write is not a plugin bolted on the side, it is the terminal.
Four steps to a live indicator. Everything below this section is reference — you do not need it yet.
class X(Indicator) is an indicator wherever the file sits. Folders are convention and persistence: files under indicators/, strategies/, monitors/, apps/ and plugins/ are re-registered every time the runtime starts.strategies/, subclass Strategy, and decorate a method with @on.bar(symbol, interval) to run once per closed bar. It stays paper until a human explicitly grants it live.A module is a class. Where the file lives decides what kind of module it is, so an indicator belongs in indicators/ and a strategy in strategies/.
| Folder | Kind |
|---|---|
indicators/name.py | Indicator |
strategies/name.py | Strategy |
monitors/name.py | Monitor |
name.py | Analysis — a one-off script |
self.timesmay run PAST the newest candle. Values dated onto the chart’s projected future bars render in the empty area to the right of price, so a next-close forecast draws where it belongs instead of stopping at the last close. A value dated at a time the chart has no bar for — past or future — is simply not drawn.async def or await.The base class decides what a module is allowed to do. All four share the lifecycle hooks.
| Base | Purpose | Entry point |
|---|---|---|
Indicator | Returns named series the chart draws. | compute(self, ctx) → dict |
Strategy | May place orders, behind the human deploy-confirm. | @on.* handlers |
Monitor | Produces verdicts and notifications. Never orders. | @on.schedule |
App | Drives a custom panel. | ui.panel(...) + ctx.ui.push |
on_start(self, ctx) · on_stop(self, ctx)self persists for the life of the deployment — the instance is reused, not rebuilt per event.compute(self, ctx) → dict | None{plot_name: values} and sets self.times to matching epoch-millisecond timestamps, one per value — which may extend past the newest candle to forecast forward. Return None to draw nothing. Use float('nan') for a gap — never 0.What wakes a module. Declare them as decorators on the methods that handle them.
| Decorator | Delivered | Payload |
|---|---|---|
| @on.bar(symbol, interval) | yes | One closed bar: symbol, interval, time_ms, OHLC, volume |
| @on.tick(symbol) | yes | One tick: symbol, ltp, bid, ask, timestamp_ms |
| @on.schedule(cron=, every=, tz=) | yes | No payload — a wall-clock wake |
| @on.signal(name, **filters) | yes | Whatever ctx.emit sent |
| @on.news(symbol) | yes | One live news article, as a plain dict |
| @on.order(**filters) | yes | The whole order lifecycle, fills included |
| @on.fill(**filters) | yes | Executions only |
| @on.position(**filters) | no | No publisher — refused at registration |
Declared inputs appear in the chart’s settings dialog and arrive as ctx.params. Read them with a default — a plain run passes an empty dict.
| Type | Signature |
|---|---|
Int | Int("length", default=20, minimum=2, maximum=500, step=1, label="") |
Float | Float("mult", default=2.0, minimum=0.1, maximum=10.0, step=0.1) |
Bool | Bool("use_close", default=True) |
Str | Str("note", default="") |
Enum | Enum("source", choices=["close", "hlc3"], default="close") |
Symbol | Symbol("hedge", default="") |
A default outside its own bounds raises at import, where you can see it, rather than at run time when nobody is looking.
An indicator declares its shape; While25 renders it. Every value here is a default the user can override in the settings dialog, exactly as for a built-in.
Plot(name, color="", width=1, style="solid", plot_type="line", color_down="", opacity=1.0, overlay=None, visible=True)name is both the key compute() returns and the label in the legend.width 1–4, opacity0–1 — outside those it raises, naming the field.Fill(upper, lower, color="", opacity=0.08)Level(value, color="", width=1, style="dashed")Band(top, bottom, color="", opacity=0.06)Served by the engine, which owns the candle cache and the tick buffer. Symbols are always canonical keys — EQ:US:AAPL, FUT:US:MNQ:202609, CRYPTO:GLOBAL:BTC-USD — never a bare ticker.
ctx.data.candles(symbol, interval="1D", *, start="", end="", limit=0, source="live") → list[dict]source picks which store they come from — the three are different questions, not fallbacks for one another.EQ:US:AAPL. Not a bare ticker.1m 3m 5m 15m 30m 1h 4h 1D 1W 1M. Anything else raises UNKNOWN_INTERVAL.live reads the in-memory series a chart or an @on.bar subscription is filling. It never fetches, so it raises NO_SERIES when nothing is watching that instrument yet. This is the series an indicator must use — it is the one drawn underneath it. historical is a real vendor pull, cached. intradayis today’s bars straight from the vendor. An unrecognised value is refused rather than falling back.historical only; live ignores it and intraday is always today. Empty lets the engine choose the window.0 means no cap.ctx.data.historical(symbol, interval="1D", *, start="", end="", limit=0) → list[dict]source="historical". The engine clamps the window it actually requests to what the vendor serves in one call, so a very wide start returns what is available rather than failing.ctx.data.intraday(symbol, interval="5m", *, limit=0) → list[dict]source="intraday". Today only, and 1m 5m 15m 30m 1h only — anything else raises UNSUPPORTED_INTERVAL.ctx.data.history(...) → list[dict]candles(...) with source="live". The name promised a fetch the call never performed. Existing scripts keep working; new code should say which source it means.@on.bar delivers, so live bars line up with rows you already hold.NO_SERIES — it does not return an empty list. The engine reads its live bar series and deliberately does not fetch, because a vendor round-trip here would stall every other module.ctx.data.quote(symbol) → dict | NoneNone when the engine has no data for that symbol. A price of 0is a real price, and a strategy that cannot tell “no data” from “worthless” will act on the difference.ctx.data.quotes(symbols) → list[dict]quote(). Symbols with no data are omitted, not padded — so the result may be shorter than the input and you cannot index into it by position.ctx.data.news(symbols, *, start="", end="", source="", limit=0) → list[dict]symbol, time_ms, title, url always; author, excerpt, body, source, sentiment, sentiment_score only when the vendor returned them — an absent field is an absent key, never 0 (a score of 0.0 is a real, neutral reading; test with "sentiment_score" in row, not truthiness). in_market_hours is True/False when the engine could decide against the exchange calendar, None when it could not. The list itself carries two honesty attributes: rows.truncated — the engine hit its page cap while older matching articles may exist — and rows.errors — per-symbol FAILED/PARTIAL codes for vendor fetches that fell over mid-query, so an empty result for a listed key is a vendor failure, never a quiet news day.@on.news(symbol) runs once per live article, receiving the same dict shape — with symbol spelled as the module declared it, and in_market_hoursalways None (live pushes are not session-tagged). Two modules watching one symbol share one upstream subscription; the first to stop cannot cut the second’s feed, and neither can the user closing the terminal’s news panel. A refused subscription — the connection’s live-news limit, or a bare symbol the instrument index does not know — logs an error to the console instead of failing silently. Both surfaces need a news provider on the engine — news() raises NO_PROVIDER without one (loudly, because an empty list would read as a quiet news day, which is a different fact), while a subscription is accepted and simply never fires.Never cached. A stale position or margin figure feeding a sizing decision is a money-path bug, and the round-trip costs microseconds.
ctx.portfolio.positions() → list[dict]ctx.portfolio.position(symbol) → dict | Nonesymbol or instrument_key. None when flat.ctx.portfolio.funds() → dictctx.portfolio.snapshot() → dictpositions. One round-trip instead of two.Strategies only — and enforced, not advisory: in any other kind (Indicator, App, Monitor, analysis) every order call raises OrderRightsError before a byte leaves the process, and the engine independently refuses a live grant for a non-Strategy. Every call routes through the same OrderInterface the terminal itself uses, so risk rules, pre-trade checks and the OMS apply identically — there is deliberately no separate path for Python.
ctx.orders.place(instrument_key, side, quantity, *, order_type="market", price=0.0, trigger_price=0.0, product="delivery", validity="day", tag="", idempotency_key="", take_profit=0.0, stop_loss=0.0, trail_amount=0.0, group="", group_id="", parent_order_id="", linked_order_id="", symbol="", disclosed_quantity=0, is_amo=False, slice=False) → dict"BUY" or "SELL", case-insensitive. Anything else raises ValueError locally, before the engine sees it.> 0; otherwise ValueError.market limit sl stop_loss stop_loss_market trailing_stop moo moc loo loclimit types.delivery intraday margin normalday ioc gtc fok opg clsbasket automatically.BAD_GROUP. The engine’s bracket path carries an absolute distance only, and a percentage cannot be converted without an entry price — which a market entry does not have. Use trail_amount.simple basket oco oto. Left empty it is inferred: basket when either protective leg is set, otherwise simple.oco.oto.@on.fill(tag=…) filters on it.True here — a refusal raises rather than returning, so there is nothing to check.cancel and modifytake — not the broker’s.ValueError is raised locally for a bad side or a non-positive quantity, before anything reaches the engine.ctx.orders.buy(instrument_key, quantity, **kw) · ctx.orders.sell(instrument_key, quantity, **kw)place() with the side filled in. Every keyword above still applies. Note quantity is positional.ctx.orders.bracket(instrument_key, side, quantity, *, take_profit, stop_loss, **kw)place() with both protective legs requiredrather than optional.ctx.orders.cancel(order_id) → dictorder_id.ctx.orders.modify(order_id, *, quantity=0, price=0.0, trigger_price=0.0) → dict0 is left unchanged.paper. It is trueunless a human has armed that module through the terminal’s deploy-confirm — so a module’s orders go to the paper book even when the terminal itself is live. This is fail-closed and nothing a script writes can change it.Series and drawings on the chart the user is looking at.
ctx.chart.plot(name, values, times=None, *, overlay=True, instrument="", interval="")color argument; style is declared.ctx.chart.draw(name, tool_type, anchors, *, config=None, style=None, label="", instrument="", interval="")anchors is a list of (time_ms, price) pairs — the same units history() returns, so a level anchored to a bar you just read needs no conversion.ctx.chart.erase(name="")ctx.chart.tools() → list[dict]tool_typevocabulary — verify anchors and config with tools() rather than guessing: lines horizontal_line vertical_line trend_line ray extended_line horizontal_ray; channels parallel_channel regression_channel pitchfork; shapes & zones rectangle ellipse triangle arrow polyline price_zone; ranges price_range date_price_range ruler; text & markers text_label anchored_text price_label callout comment signpost sticker arrow_marker; fibonacci fib_retracement fib_extension trend_fib_extension; positions long_position short_position risk_reward; patterns xabcd_pattern abcd_pattern elliott_wave elliott_abc; volume & VWAP anchored_vwap volume_profile_range volume_profile_anchored.ctx.chart.hline(name, price, ...) · trendline(name, t1, p1, t2, p2, ...) · box(name, t1, p1, t2, p2, ...) · label(name, time_ms, price, text, ...)draw() for the common shapes.plot() follows. A level that walks with price is one drawing that moves, and re-running a module replaces its drawings instead of duplicating them.Publish a view to the Wiz panel. rows is a list of dicts or a pandas DataFrame. Name the encoding — While25 infers axes from column types when you leave them blank, which is fine for a table and a coin toss for anything else.
ctx.viz.show(kind, rows, *, title="", x="", y="", series="", value="", y2="", measure="")| Kind | Helper | Encoding it needs |
|---|---|---|
table | viz.table(rows, title) | — |
metric | viz.metric(rows, title) | one row, one number |
line | viz.line(rows, title, x=, y=) | x, y |
area | viz.area(rows, title, x=, y=) | x, y |
bar | viz.bar(...) | x, y |
grouped_bar | viz.grouped_bar(...) | x, y, series |
stacked_bar | viz.stacked_bar(...) | x, y, series |
pie | viz.pie(...) | x, value |
donut | viz.donut(...) | x, value |
scatter | viz.scatter(...) | x, y |
bubble | viz.bubble(...) | x, y, value (size) |
histogram | viz.histogram(rows, title, x=) | x only — it bins one column |
box | viz.box(...) | x, y |
heatmap | viz.heatmap(...) | x, y, value (colour) |
treemap | viz.treemap(...) | x, value (area) |
waterfall | viz.waterfall(...) | x, y, measure |
combo | viz.combo(...) | x, y (line), y2 (bars) |
value and a combo has no second axis without y2, so those are rejected with a message naming the field — rather than rendering a picture that is quietly wrong.An App owns a CUSTOM PANEL — a durable slot the engine keeps (ten per machine). Register it once from the Code editor and the tab lives under VIEW → Custom panels, surviving restarts and reloads; Start, Stop, Restart and Remove live on the panel itself — the panel is the application's source of truth, removing it stops the app, and registered is not running. The module declares its layout once, at import, and afterwards only pushes values into named slots — While25 owns every pixel.
ui.panel(panel_id, label="")ui.layout(*widgets)ui.row(*children)ui.metric(slot, label="") · ui.chart(slot, label="", live=False) · ui.table(slot, label="") · ui.text(slot, label="")slotyou push into. A chart slot fed a Wiz-shaped spec renders the terminal’s interactive chart surface — hover, tooltips, legend. Declare live=True only when the chart redraws at data rate: that buys a lightweight SVG fast path showing axis min/max and the x extent instead of full interactivity. The default is right for anything a person reads.ctx.ui.push(slot, value)metric, chart, table and text show what a module computed. None of them accept input or call back into Python. The interaction model is a deliberate separate design pass — freezing a half-considered callback contract that third parties must live with would be worse than waiting.Your modules, your data, and your packages live in one directory. A module runs with it as the working directory, so every path below is relative — the same script then runs unchanged against a cloud volume.
open("data/name.csv")data/. A name that already exists is suffixed, never replaced — silently overwriting a file a running script is reading would change its results with nothing to notice.Packagesnumpy and pandas ship with the terminal. Anything else — scipy, scikit-learn, xgboost — you install from the same drawer, or by listing it in requirements.txt. They go to lib/, which sits on sys.path ahead of the bundled copies, so a version you pin wins rather than your install quietly doing nothing.Everything else on ctx.
ctx.instrument · ctx.intervalctx.params → dictctx.log(message, level="info")level is info or error.ctx.emit(name, payload=None)@on.signal(name). Fire-and-forget: no queue, no replay, and a signal with no subscriber is a no-op.ctx.indicator(cls, *, instrument="", interval="", **params) → dictctx.alive() → boolEvery ctx.data, ctx.portfolio and ctx.orders call is a round-trip to the engine, and a refusal raises — it never comes back as a result object with ok=False. There is nothing to check on the happy path.
While25Error(RuntimeError).code is the machine-readable string in the table below; .message is the prose. Branch on the code — message wording changes, codes do not. It subclasses RuntimeError, so an existing except RuntimeError still catches it.| Code | Raised by | Cause and what to do |
|---|---|---|
| NO_SERIES | ctx.data.candles (source="live") | Nothing is watching that symbol at that interval, so there is no live bar series to read. source="live" deliberately does NOT fetch — it exists to return exactly the bars a chart is drawing. Open the instrument on a chart, subscribe it with @on.bar, or ask for source="historical". |
| UNKNOWN_INTERVAL | ctx.data.candles | The interval string is not one the engine recognises. Use "1m", "5m", "15m", "1h", "1D" and friends — retrying will not help. |
| NO_DATA | ctx.data.candles (fetched) | The vendor was asked and returned nothing for that instrument and window. Distinct from NO_SERIES: the fetch happened, the data does not exist. |
| BAD_RANGE | ctx.data.candles (source="historical") | start is after end. Both are ISO dates; a future end is clamped to today rather than refused, so this only fires on a genuinely inverted range. |
| UNSUPPORTED_INTERVAL | ctx.data.candles (source="intraday") | Intraday serves 1m, 5m, 15m, 30m and 1h only. Ask for source="historical" for anything coarser. |
| NOT_READY | any ctx call | The engine is still assembling that subsystem — market data, the portfolio, or the order path — usually within seconds of start-up. Transient: retry on the next event. |
| NOT_READY | ctx.orders (bracket) | The bracket path is unavailable, so the entry was REFUSED rather than placed naked. This is deliberate: a protected order silently becoming unprotected is worse than no order. |
| BAD_ORDER_TYPE | ctx.orders.place | order_type is not one of the accepted values. Your input is wrong; retrying will not help. |
| BAD_PRODUCT | ctx.orders.place | product is not one of the accepted values. |
| BAD_VALIDITY | ctx.orders.place | validity is not one of the accepted values. |
| BAD_GROUP | ctx.orders.place | The group type is unknown, or the group is missing a field its type requires — an OCO without its linked order, a bracket with neither leg priced. |
| SIDECAR_DOWN | any ctx call | The Python process is not running, or the engine could not reach it. Restart the module. |
These come from the runtime rather than from an engine refusal, so they carry no code.
| Exception | When |
|---|---|
| ValueError | Raised LOCALLY by ctx.orders before the engine is contacted: a side that is not BUY/SELL, or a quantity that is not positive. |
| TimeoutError | The engine did not answer within the call timeout. The script is not wedged — it was waiting, which is why this is not a hang. |
| RuntimeError | The terminal is not reachable at all: the pipe to the engine is gone. |
numpy and pandas are available in the runtime. Get the desktop app to run it — Python runs beside the engine, which a browser cannot host.