While25 - Programmable Trading Platform
homeomsrmschartspythonaicompareblogsupportGet While25
While25

A programmable trading platform.

Get While25Book a call

Order managementRisk managementChartsAI assistantCompare

Python SDKAPIDownload

AboutBlogCareersSupportContact

Interactive BrokersAlpacaZerodhaUpstoxAll brokers

TermsPrivacyLicenceRiskAIAcceptable useNotices

While25 is software. It is not a broker, dealer, exchange or investment adviser, and nothing on this site is a recommendation to buy or sell any instrument. Orders you place route to your own brokerage account; While25 never holds funds or securities.

Trading carries risk of loss, including losses greater than your deposit on leveraged products. Backtests and simulations do not predict future returns. Broker and exchange names are trademarks of their owners. We plan to add Coinbase, Binance and Robinhood; you cannot connect them yet.

© 2026 While25

while25
Python SDK

Write the platform, don’t just use it

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 platform itself uses — so what you write is not a plugin bolted on the side, it is the platform.

Start here
  • Quickstart
  • Writing a module
Declaring
  • Module types
  • Subscriptions
  • Inputs
  • Plots & style
Calling
  • Market data
  • Portfolio
  • Orders
  • Chart
  • Visualization
  • Panels & UI
  • Workspace
  • Context
  • Errors

Quickstart

Four steps to a live indicator. Everything below this section is reference — you do not need it yet.
1
Open the Code panel
In While25, add the Code panel. It holds your workspace: a file tree on the left, an editor on the right. You install nothing, and nothing leaves your machine.
2
Create indicators/momentum.py
The class declarationdecides the kind — 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.
3
Paste this
Twelve lines: a rolling return, drawn in its own pane under price.
indicators/momentum.py
import while25
from while25 import Indicator, Plot
from while25.inputs import Int

class Momentum(Indicator):
    id      = "momentum"
    label   = "Momentum"
    overlay = False                      # its own pane, below price
    inputs  = [Int("length", default=14, minimum=2)]
    plots   = [Plot("mom", color="#e0a34a")]

    def compute(self, ctx):
        n    = int(while25.params.get("length", 14))
        rows = while25.data.candles(while25.instrument, while25.interval, limit=n + 200)
        if len(rows) <= n:
            return None

        close = [float(r["close"]) for r in rows]
        # Warm-up bars have no value yet — NaN renders as a gap, 0 would be a lie.
        out   = [float("nan")] * n + [
            (close[i] / close[i - n] - 1) * 100 for i in range(n, len(close))
        ]

        self.times = [int(r["time"]) for r in rows]   # one timestamp per value
        return {"mom": out}
4
Register it, then add it to a chart
Hit Register. It now appears in the chart’s indicator picker like any built-in, and the AI can see it by name.
A strategy is the same shape
Put the file in 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.
strategies/breakout.py
import while25
from while25 import Strategy, on

class Breakout(Strategy):
    id = "breakout"

    @on.bar("EQ:US:AAPL", interval="5m")
    def bar(self, ctx, bar):
        if bar["close"] > bar["open"] * 1.01:
            while25.orders.buy(while25.instrument, 1, stop_loss=bar["low"])

Writing a module

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/.
indicators/zscore.py
# indicators/zscore.py
import while25
from while25 import Indicator, Plot, Level
from while25.inputs import Int

class ZScore(Indicator):
    id      = "zscore"
    label   = "Z-Score"
    overlay = False                       # its own pane, below price
    inputs  = [Int("length", default=20, minimum=2, maximum=500)]
    plots   = [Plot("z", color="#e0a34a")]
    levels  = [Level(2.0, style="dashed"), Level(-2.0, style="dashed")]

    def compute(self, ctx):
        n    = int(while25.params.get("length", 20))
        rows = while25.data.candles(while25.instrument, while25.interval, limit=n + 300)
        if len(rows) < n:
            return None

        close, out = [float(r["close"]) for r in rows], []
        for i in range(len(close)):
            if i < n - 1:
                out.append(float("nan"))          # warm-up renders as a gap
                continue
            w    = close[i - n + 1:i + 1]
            mean = sum(w) / n
            sd   = (sum((x - mean) ** 2 for x in w) / n) ** 0.5
            out.append(0.0 if sd == 0 else (close[i] - mean) / sd)

        self.times = [int(r["time"]) for r in rows]   # one per value
        return {"z": out}
FolderKind
indicators/name.pyIndicator
strategies/name.pyStrategy
monitors/name.pyMonitor
name.pyAnalysis — a one-off script
You can predict past the last bar
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.
The package is the API
Inside a running module every call is package-level: while25.candles, while25.buy, while25.subscribe, while25.ui.push, while25.log. There is one module per process, so nothing needs passing around. The ctxa handler receives is that same object — use it when you want the explicit style, or bind a fake with while25.use(fake) in a test. Shorten the name yourself if you like: import while25 as w25.
Everything is synchronous
Each module owns its own process, so a blocking call costs nothing anyone else can see. There is no event loop to yield to — never write async def or await.

Module types

The base class decides what a module may do. All four share the lifecycle hooks.
BasePurposeEntry point
IndicatorReturns named series the chart draws.compute(self, ctx) → dict
StrategyMay place orders, behind the human deploy-confirm.@on.* handlers
MonitorProduces verdicts and notifications. Never orders.@on.schedule
AppDrives a custom panel.on_start(self, ctx) or run(self, ctx)
PYon_start(self, ctx) · on_stop(self, ctx)
Called once when the module starts and once when it stops. State you set on self persists for the life of the deployment — the runtime reuses the instance rather than rebuilding it per event.
PYrun(self, ctx)
The escape hatch: called once on start, after on_start, for work that has a beginning and an end rather than a handler — a backtest, a report, a one-pass scan. The module is already RUNNING while it executes, so a long job does not leave the panel reading “starting”; raising inside it puts the module in error with the traceback.
PYcompute(self, ctx) → dict | None
Indicators only. Returns {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.

Subscriptions

What wakes a module. Declare them as decorators on the methods that handle them.
Declaring subscriptions
import while25
from while25 import Strategy, on

class Breakout(Strategy):
    id = "breakout"

    @on.tick("EQ:US:AAPL")
    def price(self, ctx, tick):
        ...

    @on.fill(tag="breakout")
    def filled(self, ctx, event):
        ...
DecoratorDeliveredPayload
@on.bar(symbol, interval)yesOne closed bar: symbol, interval, time_ms, OHLC, volume
@on.tick(symbol)yesOne tick: symbol, ltp, bid, ask, timestamp_ms
@on.schedule(cron=, every=, tz=)yesNo payload — a wall-clock wake
@on.signal(name, **filters)yesWhatever while25.emit sent
@on.news(symbol)yesOne live news article, as a plain dict
@on.order(**filters)yesThe whole order lifecycle, fills included
@on.fill(**filters)yesExecutions only
@on.position(**filters)noNo publisher — refused at registration
A handler that raises halts the module
Routing stops, on purpose. See Errors for what each failure raises and how to handle it.

Inputs

Declared inputs appear in the chart’s settings dialog and arrive as while25.params. Read them with a default — a plain run passes an empty dict.
TypeSignature
IntInt("length", default=20, minimum=2, maximum=500, step=1, label="")
FloatFloat("mult", default=2.0, minimum=0.1, maximum=10.0, step=0.1)
BoolBool("use_close", default=True)
StrStr("note", default="")
EnumEnum("source", choices=["close", "hlc3"], default="close")
SymbolSymbol("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.

Plots & style

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.
PYPlot(name, color="", width=1, style="solid", plot_type="line", color_down="", opacity=1.0, overlay=None, visible=True)
One line. name is both the key compute() returns and the label in the legend.
style: solid · dotted · dashed · large-dashed · sparse-dotted
plot_type: line · histogram · area · stepline
Colours are CSS strings. width 1–4, opacity0–1 — outside those it raises, naming the field.
PYFill(upper, lower, color="", opacity=0.08)
A translucent band between two named plots — a Bollinger envelope.
PYLevel(value, color="", width=1, style="dashed")
A horizontal reference line — RSI’s 30 and 70.
PYBand(top, bottom, color="", opacity=0.06)
Shading between two levels — an overbought zone.

Market data

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.
PYwhile25.data.candles(symbol, interval="1D", *, start="", end="", limit=0, source="live") → list[dict]
Closed bars for one instrument, oldest first. source picks which store they come from — the three are different questions, not fallbacks for one another.
Parameters
symbolstrrequired
Canonical key, e.g. EQ:US:AAPL. Not a bare ticker.
intervalstroptionaldefault "1D"
One of 1m 3m 5m 15m 30m 1h 4h 1D 1W 1M. Anything else raises UNKNOWN_INTERVAL.
sourcestroptionaldefault "live"
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. The SDK refuses an unrecognised value rather than falling back.
startstroptionaldefault ""
ISO date, inclusive. Used by historical only; live ignores it and intraday is always today. Empty lets the engine choose the window.
endstroptionaldefault ""
ISO date, inclusive. Empty means up to the newest bar; the SDK clamps a future date to today.
limitintoptionaldefault 0
Cap on rows, counted from the newest end — asking for 200 gives the most recent 200. 0 means no cap.
PYwhile25.data.historical(symbol, interval="1D", *, start="", end="", limit=0) → list[dict]
Shorthand for 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.
PYwhile25.data.intraday(symbol, interval="5m", *, limit=0) → list[dict]
Shorthand for source="intraday". Today only, and 1m 5m 15m 30m 1h only — anything else raises UNSUPPORTED_INTERVAL.
PYwhile25.data.history(...) → list[dict]
Deprecated alias for 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.
Response attributes
timeintrequired
The bar’s open time in epoch milliseconds — the same base @on.bar delivers, so live bars line up with rows you already hold.
openfloatrequired
Opening price.
highfloatrequired
Highest traded price in the bar.
lowfloatrequired
Lowest traded price in the bar.
closefloatrequired
Closing price.
volumefloatrequired
Traded volume.
Raises
NO_SERIESUNKNOWN_INTERVALNOT_READY
An instrument nothing is watching raises 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.
PYwhile25.data.quote(symbol) → dict | None
The latest quote for one instrument.
Parameters
symbolstrrequired
Canonical key.
Response attributes
symbolstrrequired
The instrument the quote is for.
ltpfloatrequired
Last traded price.
bidfloatrequired
Best bid.
askfloatrequired
Best ask.
openfloatrequired
Session open.
highfloatrequired
Session high.
lowfloatrequired
Session low.
prev_closefloatrequired
Previous session’s close.
volumefloatrequired
Session volume.
timeintrequired
Quote timestamp, epoch milliseconds.
Absent is None, never zero
The whole call returns None 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.
Raises
NOT_READYSIDECAR_DOWN
PYwhile25.data.quotes(symbols) → list[dict]
Several quotes in one round-trip.
Parameters
symbolslist[str]required
Canonical keys.
Returns
list[dict]
Same row shape as quote(). The SDK omits symbols with no data rather than padding them — so the result may be shorter than the input and you cannot index into it by position.
PYwhile25.data.news(symbols, *, start="", end="", source="", limit=0) → list[dict]
News articles, oldest first — answered by the engine’s news path, the same one the platform’s news panel reads, so a script can never see a different story than the screen.
Parameters
symbolsstr | list[str]required
One symbol or up to 10 — an 11th raises INVALID.
startstr | intoptional
ISO date or epoch ms; inclusive lower bound. The SDK reads dates and tz-less datetimes as UTC. A range in the PAST works like historical bars: the engine seeks back through the archive to reach it, and returns an empty list — not the nearest articles — when nothing falls inside.
endstr | intoptional
ISO date or epoch ms; a bare date is inclusive to end-of-day.
sourcestroptional
Exact outlet name; empty means all sources.
limitintoptional
Per symbol. 0 → default 50; hard cap 200.
Returns
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.
A live counterpart: @on.news(symbol)
A module method decorated @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 platform’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 look like a quiet news day, which is a different fact), while a subscription goes through and never fires.
Raises
INVALIDNO_PROVIDERNOT_READYFAILEDSIDECAR_DOWN

Portfolio

Never cached. A stale position or margin figure feeding a sizing decision is a money-path bug, and the round-trip costs microseconds.
PYwhile25.portfolio.positions() → list[dict]
Every open position.
Response attributes
symbolstrrequired
Display symbol.
instrument_keystrrequired
Canonical key.
quantityintrequired
Signed: negative when short.
average_pricefloatrequired
Always per-point. Some brokers report futures average cost as price × multiplier; that is de-normalised at the adapter, so nothing here may scale it again.
last_pricefloatrequired
Last traded price.
pnlfloatrequired
Unrealised P&L for this position.
productstrrequired
The product the broker holds the position under.
PYwhile25.portfolio.position(symbol) → dict | None
One position, matched on either symbol or instrument_key. None when flat.
PYwhile25.portfolio.funds() → dict
Cash and margin.
Response attributes
cashfloatrequired
Free cash.
margin_usedfloatrequired
Margin currently committed.
margin_availablefloatrequired
Margin still available.
PYwhile25.portfolio.snapshot() → dict
Everything at once: the three fund figures plus positions. One round-trip instead of two.
Raises
NOT_READYSIDECAR_DOWN

Orders

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 platform itself uses, so risk rules, pre-trade checks and the OMS apply identically — there is deliberately no separate path for Python.
PYwhile25.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
Place one order. Blocks until the engine answers.
Parameters
instrument_keystrrequired
Canonical key.
sidestrrequired
Exactly "BUY" or "SELL", case-insensitive. Anything else raises ValueError locally, before the engine sees it.
quantityintrequired
Must be > 0; otherwise ValueError.
order_typeenum (str)optionaldefault "market"
market limit sl stop_loss stop_loss_market trailing_stop moo moc loo loc
pricefloatoptionaldefault 0.0
Limit price. Required by limit types.
trigger_pricefloatoptionaldefault 0.0
Stop trigger.
productenum (str)optionaldefault "delivery"
delivery intraday margin normal
validityenum (str)optionaldefault "day"
day ioc gtc fok opg cls
take_profitfloatoptionaldefault 0.0
Setting either protective leg makes the group a basket automatically.
stop_lossfloatoptionaldefault 0.0
Protective leg. Either leg alone is valid.
trail_amountfloatoptionaldefault 0.0
Absolute trail distance.
trail_percentfloatoptionaldefault 0.0
Refused on a bracket with BAD_GROUP. The engine’s bracket path carries an absolute distance only, and a percentage needs an entry price to convert — and a market entry does not have one. Use trail_amount.
groupenum (str)optionaldefault inferred
simple basket oco oto. Left empty, the SDK infers it: basket when you set either protective leg, otherwise simple.
linked_order_idstroptionaldefault ""
Required by oco.
parent_order_idstroptionaldefault ""
Required by oto.
tagstroptionaldefault ""
Free label. @on.fill(tag=…) filters on it.
idempotency_keystroptionaldefault ""
Your own de-duplication key; the engine never places a repeat twice.
Response attributes
okboolrequired
Always True here — a refusal raises rather than returning, so there is nothing to check.
order_idstrrequired
The engine’s internal id. This is what cancel and modifytake — not the broker’s.
paperboolrequired
Trueunless a human has armed this module for live through the platform’s deploy-confirm.
broker_order_idsarray (str)required
The broker’s own ids. Several for a bracket — one per leg.
latency_msintrequired
Engine round-trip for this placement.
Raises
BAD_ORDER_TYPEBAD_PRODUCTBAD_VALIDITYBAD_GROUPNOT_READY
Plus any risk-gate rejection, which arrives the same way. The SDK raises ValueError locally for a bad side or a non-positive quantity, before anything reaches the engine.
PYwhile25.orders.buy(instrument_key, quantity, **kw) · while25.orders.sell(instrument_key, quantity, **kw)
place() with the side filled in. Every keyword above still applies. Note quantity is positional.
PYwhile25.orders.bracket(instrument_key, side, quantity, *, take_profit, stop_loss, **kw)
place() with both protective legs requiredrather than optional.
PYwhile25.orders.cancel(order_id) → dict
Cancel a working order by its engine order_id.
PYwhile25.orders.modify(order_id, *, quantity=0, price=0.0, trigger_price=0.0) → dict
Amend a working order. The engine leaves any field you set to 0 unchanged.
Placing a bracket
import while25
from while25 import While25Error

try:
    res = while25.orders.bracket("FUT:US:MNQ:202609", "BUY", 1,
                             take_profit=21_450.0,
                             stop_loss=21_380.0,
                             tag="breakout")
except While25Error as e:
    # A refusal RAISES — it does not come back as {"ok": False}. Risk-gate
    # rejections arrive here too.
    while25.log(f"refused [{e.code}] {e.message}", level="error")
else:
    if res["paper"]:
        while25.log("filled on the paper book")        # not armed for live
    else:
        while25.log(f"live order {res['order_id']}")
Live requires an explicit human grant
Every result carries paper. It is trueunless a human has armed that module through the platform’s deploy-confirm — so a module’s orders go to the paper book even when the platform itself is live. This is fail-closed and nothing a script writes can change it.
Prefer a bracket to a naked entry
A bracket is engine-owned, persisted, and restored at boot — it survives the script’s process dying. A stop managed in Python does not.

Chart

Series and drawings on the chart the user is looking at.
PYwhile25.chart.plot(name, values, times=None, *, overlay=True, instrument="", interval="")
Draw a series from a strategy or monitor. An indicator does not call this — it returns a dict instead. There is no color argument; you declare the style.
PYwhile25.chart.draw(name, tool_type, anchors, *, config=None, style=None, label="", instrument="", interval="")
A level, trendline, zone or label. 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.
PYwhile25.chart.erase(name="")
Remove one drawing, or every drawing this module made.
PYwhile25.chart.tools() → list[dict]
Every drawing tool the chart knows, with anchor requirements and config schema. A tool added to the platform later appears here on its own. The current 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.
PYwhile25.chart.hline(name, price, ...) · trendline(name, t1, p1, t2, p2, ...) · box(name, t1, p1, t2, p2, ...) · label(name, time_ms, price, text, ...)
Thin sugar over draw() for the common shapes.
The name you choose identifies the drawing
Drawing the same name again moves that drawing rather than adding a second one — the same rule 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.

Visualization

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.
PYwhile25.viz.show(kind, rows, *, title="", x="", y="", series="", value="", y2="", measure="")
Any renderer. Each also has a named helper.
KindHelperEncoding it needs
tableviz.table(rows, title)—
metricviz.metric(rows, title)one row, one number
lineviz.line(rows, title, x=, y=)x, y
areaviz.area(rows, title, x=, y=)x, y
barviz.bar(...)x, y
grouped_barviz.grouped_bar(...)x, y, series
stacked_barviz.stacked_bar(...)x, y, series
pieviz.pie(...)x, value
donutviz.donut(...)x, value
scatterviz.scatter(...)x, y
bubbleviz.bubble(...)x, y, value (size)
histogramviz.histogram(rows, title, x=)x only — it bins one column
boxviz.box(...)x, y
heatmapviz.heatmap(...)x, y, value (colour)
treemapviz.treemap(...)x, value (area)
waterfallviz.waterfall(...)x, y, measure
comboviz.combo(...)x, y (line), y2 (bars)
Visualizations
while25.viz.bar(trades, "P&L by symbol", x="symbol", y="pnl")
while25.viz.heatmap(grid, "Correlation", x="a", y="b", value="rho")
while25.viz.table(df, "Open positions")            # a DataFrame works directly
The SDK refuses a missing role rather than guessing
A treemap has no area without value and a combo has no second axis without y2, so the SDK rejects those with a message naming the field — rather than rendering a picture that is quietly wrong.

Panels & UI

An App owns a CUSTOM PANEL, a tab that names the app the way a chart tab names its instrument. Saving the file under apps/ is what registers it: the tab appears under VIEW → Custom panels and survives restarts, reloads and a synced layout on another machine.

Start, Stop, Restart and Remove live on the panel itself, because the panel is the application's source of truth. Close stops the app and closes the tab without touching the file, and deleting the file is what removes the panel for good.

The module declares its layout once, at import, and afterwards only pushes values into named slots. While25 owns every pixel.

apps/desk.py
# apps/desk.py
import while25
from while25 import App, on
from while25 import ui

ui.panel("desk", "Trading Desk")
ui.layout(
    ui.row(ui.metric("pnl", "Day P&L"), ui.metric("pos", "Positions")),
    ui.chart("equity", "Equity curve"),
    ui.table("fills", "Recent fills"),
)

class Desk(App):
    id = "desk"

    @on.schedule(every="5s")
    def refresh(self, ctx):
        p = while25.portfolio.snapshot()
        while25.ui.push("pnl", p.get("day_pnl"))
        while25.ui.push("pos", len(while25.portfolio.positions()))
PYui.panel(panel_id, label="")
Names the panel this file declares.
PYui.layout(*widgets)
The panel’s tree, declared once at import.
PYui.row(*children)
A horizontal group.
PYui.metric(slot, label="") · ui.chart(slot, label="", live=False) · ui.table(slot, label="") · ui.text(slot, label="")
The four built-in leaf widgets. Each owns a named slotyou push into. A chart slot fed a Wiz-shaped spec renders the platform’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.
PYui.view(slot, label="")
The platform’s own components, when you would rather not write markup (and the backtest view). Push a dict of blocks describing what to show — header, tiles, kv, table, badges, text, progress, chart, html, section, row, divider — and the While25 renders it in its own components. A chart block is the interactive Wiz surface by default (any of the 17 kinds; "live": true for the SVG fast path), and an html block is your own markup as one sandboxed section — so custom HTML and an interactive chart share one panel, side by side in a row.
PYui.html(slot, label="")
The app’s own page, and the default for a panel — a dashboard, a desk, a styled report, a custom grid. Push a markup string; your markup and CSS render as written, charts included (inline <svg>or CSS bars). Declare it as the layout’s only widget and the view becomes the whole panel (full height, no caption, no padding); put it alongside other widgets and it is one content-sized section among them, its height tracking its own content. label is optional here — pass one for a caption, omit it and your own <h1> leads.
PYthe frame you render into
Not a bare iframe. The platform’s theme tokens resolve inside — var(--color-amber), --color-profit, --color-loss, --color-text-dim, --color-surface-2 and more — and follow a theme switch. Bare markup arrives styled (headings, zebra tables with tabular numerals, code blocks, lists), with helper classes .card, .tile, .grid, .row, .num, .profit, .loss, .muted. Three faces by role — IBM Plex Mono for data, Inter for prose, JetBrains Mono for code — on a four-step scale. That brand theme is the default, not a rule: it sits in a lower cascade layer, so any CSS you write wins over it, and the platform never snaps or strips anything you write. Build on the tokens and classes and the page reads as part of the platform. Links open in the system browser (https only).
HTML and CSS — scripts never run
The fragment renders in a sandboxed frame with no script permission: <script>, on* handlers and javascript: URLs are inert, so an onclick would silently do nothing. Compute in Python and push new markup instead. CSS animations, transitions and :hover do work — that is the motion you have. The frame isolates styles both ways, and vh units resolve against the frame rather than the window.
PYwhile25.ui.push(slot, value)
Send a value to a declared slot. If you push to a slot you never declared, the SDK logs it and drops it rather than raising — a typo in a display label should not halt a module.
Display only, for now
metric, chart, table, text and html 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.

Workspace

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.
the workspace
<workspace>/
├── indicators/  strategies/  monitors/  apps/   your modules
├── data/        files you drop in
├── lib/         packages you install
└── requirements.txt
PYopen("data/name.csv")
Drop files onto the workspace drawer in the Code panel and they land in data/. When a name already exists, the platform adds a suffix and never replaces the file — silently overwriting a file a running script is reading would change its results with nothing to notice.
PYPackages
numpy and pandas ship with the platform. 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.
Opening a folder runs nothing
The Code panel opens any folder or file on your machine, the way an editor does. Nothing in it executes until you press Run — and a file you run from an opened folder runs once and the platform never registers it, so it cannot become a panel, a strategy or anything the terminal restores at boot. Save As into the workspace is what makes a file part of the terminal; the path alone decides.

Context

Everything else on while25. The ctx a handler receives is the same object, kept for the explicit style and for tests.
PYwhile25.instrument · while25.interval
What the chart asked for. An indicator should compute from these rather than hardcoding a symbol — one registration then serves every chart.
PYwhile25.params → dict
The user’s values for your declared inputs.
PYwhile25.log(message, level="info")
Writes to the panel console. level is info or error.
PYwhile25.emit(name, payload=None)
Fire a signal another module can receive with @on.signal(name). Fire-and-forget: no queue, no replay, and a signal with no subscriber is a no-op.
PYwhile25.indicator(cls, *, instrument="", interval="", **params) → dict
Run another indicator in this process and get its series back — no chart, no round trip. This is how a strategy uses an indicator’s numbers.
PYwhile25.alive() → bool
False once the platform asks the module to stop. Check it inside any long loop.

Errors

Every while25.data, while25.portfolio and while25.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.
Handling a refusal
import while25
from while25 import While25Error

try:
    rows = while25.data.candles("EQ:US:AAPL", "5m", limit=200)
except While25Error as e:
    if e.code == "NO_SERIES":
        while25.log("not being watched yet — open it on a chart")
        return
    raise                       # anything else is not ours to swallow
PYWhile25Error(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.
CodeRaised byCause and what to do
NO_SERIESwhile25.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_INTERVALwhile25.data.candlesThe interval string is not one the engine recognises. Use "1m", "5m", "15m", "1h", "1D" and friends — retrying will not help.
NO_DATAwhile25.data.candles (fetched)We asked the vendor and it returned nothing for that instrument and window. Distinct from NO_SERIES: the fetch happened, the data does not exist.
BAD_RANGEwhile25.data.candles (source="historical")start is after end. Both are ISO dates; the SDK clamps a future end to today rather than refusing it, so this only fires on an inverted range.
UNSUPPORTED_INTERVALwhile25.data.candles (source="intraday")Intraday serves 1m, 5m, 15m, 30m and 1h only. Ask for source="historical" for anything coarser.
NOT_READYany while25 callThe 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_READYwhile25.orders (bracket)The bracket path is unavailable, so the engine REFUSED the entry rather than placing it naked. This is deliberate: a protected order silently becoming unprotected is worse than no order.
BAD_ORDER_TYPEwhile25.orders.placeorder_type is not one of the accepted values. Your input is wrong; retrying will not help.
BAD_PRODUCTwhile25.orders.placeproduct is not one of the accepted values.
BAD_VALIDITYwhile25.orders.placevalidity is not one of the accepted values.
BAD_GROUPwhile25.orders.placeThe 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_DOWNany while25 callThe Python process is not running, or the engine could not reach it. Restart the module.

Raised without a code

These come from the runtime rather than from an engine refusal, so they carry no code.

ExceptionWhen
ValueErrorwhile25.orders raises this LOCALLY before it contacts the engine: a side that is not BUY/SELL, or a quantity that is not positive.
TimeoutErrorThe engine did not answer within the call timeout. The script is not wedged — it was waiting, which is why this is not a hang.
RuntimeErrorThe platform is not reachable at all: the pipe to the engine is gone.
A handler that raises halts the module
An uncaught exception stops routing for that module, deliberately: a strategy that raised part-way through an entry is holding a position it has lost track of, and the next event would run the same handler again. The process stays alive so the traceback is readable; restarting clears it.
The SDK itself has no dependencies, so you can write and unit-test a module on a plain Python install before it ever touches the platform. 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.