Trading Dashboard

Sign in to manage strategies
Trading Dashboard
connecting...
Strategies
0
Running
0
Trades
0
Realized P&L
$0
Tiingo (hr)
Tiingo (mo)

New Strategy

Setup

Position

Broker

Backtest New Strategy

Setup

Position

Sentiment & Cache

Backtest Results

No backtest results yet. Click "Backtest" on a strategy or use "Backtest New Strategy".

Trade Log

TimeStrategySymbolAction PriceQtySentimentResult

Script Editor

Quick referenceta.ema/sma/rsi/macd/bollinger/atr/structure/squeeze/manipulation/regime ta.crossover/crossunder ta.highest/lowest · ctx.close_prices() ctx.window(n) ctx.timeframe(tf) ctx.state ctx.position · Signal.buy()/.sell()/.short()/.cover() .stop_atr() .target_atr() .trail_atr() .break_even() .timeout() .with_confidence() · strategies.<name>(ctx) to compose other saved scripts — full DSL reference →

Backtest with this script

Setup

Backtest History

Every backtest is cached by its configuration (symbol, script, sizing, etc.) and resumed forward automatically next time you run it. Click a row to reload its full results and charts on the Dashboard tab; delete a row to force a clean rerun from scratch next time.

UpdatedSymbolScriptGranularity FillsWin RateRealized P&L

Strategy Rankings

A background worker backtests the bundled default scripts, plus any script a user has submitted, against every symbol/timeframe with stored bar data — DB-only, no live API calls (see strategy_ranker.py). Each combo is measured over a few calendar windows sized to its granularity (5m/15m: 1 week/1 month/3 months; 30m-4h: 1 month/3 months/6 months; 6h+: 3 months/6 months/1 year), so results are comparable across timeframes without one giant backtest dominating the cost. Submit one of your own scripts for ranking from the Script Editor. "Global" shows defaults plus public submissions from anyone; "Mine" shows only your own submitted scripts (public or private), no defaults.

Note: ranking backtests always run with sentiment ignored (there's no correct lookback window to score it by once bars are windowed by calendar length — see strategy_ranker.py). Any script gated on ctx.sentiment_score (e.g. the bundled sentiment_gate) will never fire here and always shows 0 fills — that's expected, not a sign the script or the worker is broken.

ScriptOwnerSymbolGranularityWindow Win RateExpectancy (R)Annualized ReturnReturn % (window)Realized P&L FillsUpdated

DSL Reference

Everything a script can use. Every script is a Python module with a single run(ctx) entrypoint — this page documents ctx, the indicator library, and the types you build signals out of.

The script contract

A script is a Python module. It must define a single function, run(ctx), called once per bar close. Return a Signal to act, a Score to combine with other signals, or None to do nothing this bar. ta, stats, Signal, Score, Bar, and strategies are already in scope — no imports needed.

# minimal example: EMA crossover def run(ctx): fast = ta.ema(ctx.bars, 9) slow = ta.ema(ctx.bars, 21) if fast.crossed_above(slow): return Signal.buy().stop_atr(1.5).target_atr(3) if fast.crossed_below(slow): return Signal.sell() return None

ctx.state is a plain dict that persists across calls for this script — use it for anything you need to remember between bars. Indicator calls inside a single run() are cached per-arguments, so calling ta.ema(ctx.bars, 9) twice in the same bar is free.

Important: almost every ta.* indicator takes bars — a list[Bar], i.e. ctx.bars or ctx.window(n)not ctx.close_prices(). ctx.close_prices()/ctx.volumes() return a plain list[float] for your own math or for ta.crossover/ stats.* (which do take plain lists) — passing one to ta.ema/ ta.rsi/etc. instead of ctx.bars raises AttributeError: 'float' object has no attribute 'close'.

Context

Everything run(ctx) gets about the current bar, symbol, and history.

ctx.close_prices(n=None) → list[float]

Closes up to and including the current bar, oldest first — all of them by default, or just the trailing n. A plain list, not a Series — for your own math or ta.crossover/stats.*. Pass ctx.bars, not this, to ta.ema/ta.rsi/etc.

ctx.volumes(n=None) → list[float]

Same shape as close_prices(), for volume.

ctx.window(n) → list[Bar]

The last n bars (oldest first). Use when you need OHLC, not just close — and what to pass most ta.* functions instead of a bare price list.

ctx.last() → Bar

The current (most recently closed) bar.

ctx.timeframe(tf) → Timeframe

Resamples this context's bars up to a higher timeframe (e.g. "4h", "1d"). Not a Context — a small object with just .bars (list[Bar]) and .label; pass htf.bars to ta.* functions, there's no htf.close_prices(). See Multi-timeframe.

ctx.state → dict

Persists across calls to run() for this script. Empty dict on first call.

ctx.symbol → str

The symbol currently being evaluated, e.g. "AAPL".

ctx.position → Position | None

The current open position for this symbol under this script, or None if flat.

Bar

A single OHLCV candle.

openfloat
highfloat
lowfloat
closefloat
volumefloat
timestampfloat (Unix epoch seconds, not a datetime)

Position

A read-only snapshot of an open position.

FieldTypeDescription
.side"long" | "short"direction of the position
.qtyfloatposition size
.entry_pricefloataverage entry price
.current_pricefloatlatest close — what .pnl is computed against
.pnlfloatunrealized P&L: (current_price − entry_price) × qty for a long, reversed for a short
.directionstralias for .side
.stop read-onlyfloat | Nonecurrent engine-managed stop, if the entry signal set one
.target read-onlyfloat | Nonecurrent engine-managed profit target, if the entry signal set one

.stop / .target reflect whatever the entry signal's trade-management chain set — they're set by the engine, not by your script, once a position is open.

Multi-timeframe

ctx.timeframe("4h") resamples this context's bars up to that granularity and returns a Timeframe (.bars, .label — not a full Context). Use it to check a higher-timeframe trend without a second data source.

def run(ctx): htf = ctx.timeframe("4h").bars htf_trend = ta.ema(htf, 21).slope > 0 fast = ta.ema(ctx.bars, 9) slow = ta.ema(ctx.bars, 21) if fast.crossed_above(slow) and htf_trend: return Signal.buy()

ta.ema / ta.sma

ta.ema(bars, period) → Series[float]

Exponential moving average. bars is list[Bar] — pass ctx.bars or ctx.window(n).

ta.sma(bars, period) → Series[float]

Simple moving average.

fast = ta.ema(ctx.bars, 9) if fast.crossed_above(slow): ...

ta.rsi

ta.rsi(bars, period=14) → Series[float]

Relative strength index, 0-100.

rsi = ta.rsi(ctx.bars) if rsi.current < 30: return Signal.buy()

ta.macd

ta.macd(bars, fast=12, slow=26, signal=9) → MACD

Trend-following momentum: the gap between a fast and slow EMA, plus a smoothed trigger line.

FieldTypeDescription
.macdSeries[float]MACD line — fast EMA minus slow EMA
.signalSeries[float]EMA of the MACD line — the trigger line
.histogramSeries[float].macd minus .signal
m = ta.macd(ctx.bars) if m.macd.crossed_above(m.signal): return Signal.buy()

ta.bollinger

ta.bollinger(bars, period=20, num_std=2.0) → BollingerBands

A moving average with volatility bands num_std standard deviations above/below it.

FieldTypeDescription
.middleSeries[float]The SMA basis the bands are built around
.upperSeries[float]middle + num_std × rolling std
.lowerSeries[float]middle − num_std × rolling std
.widthSeries[float]upper − lower, in price units
.percent_bSeries[float](close − lower) / (upper − lower) — 0 = at the lower band, 1 = at the upper band, 0.5 when the bands are flat
.bandwidthSeries[float]width / middle — width normalized by price, comparable across time/instruments
bb = ta.bollinger(ctx.bars) if ctx.last().close <= bb.lower.current: return Signal.buy()

ta.atr

ta.atr(bars, period=14) → ATR (Series subclass)

Average true range, Wilder-smoothed. The ATR value itself IS the Series (use .current/.previous like any other indicator) — these are extra fields on top.

FieldTypeDescription
.risingbool.current > .previous
.expandingboolcurrent reading above its own trailing average
.contractingboolcurrent reading below its own trailing average
.percentilefloat | None0-100 — where the current reading ranks against its own trailing history
atr = ta.atr(ctx.window(50)) if atr.expanding: return Signal.buy().stop_atr(1.5).target_atr(3)

ta.structure

ta.structure(bars, lookback=50, swing_strength=2) → Structure

Swing-based market structure: recent confirmed swing highs/lows, and whether price is making higher highs/higher lows (uptrend) or the reverse.

FieldTypeDescription
.trendstr"uptrend" / "downtrend" / "range"
.higher_highboollatest confirmed swing high > the one before it
.lower_highboollatest confirmed swing high < the one before it
.higher_lowboollatest confirmed swing low > the one before it
.lower_lowboollatest confirmed swing low < the one before it
.swing_highfloat | Nonemost recent confirmed swing high
.swing_lowfloat | Nonemost recent confirmed swing low
.breakoutboolcurrent bar's close > .swing_high
.breakdownboolcurrent bar's close < .swing_low
.inside_barboolcurrent bar's range is fully inside the prior bar's
.outside_barboolcurrent bar's range fully engulfs the prior bar's
s = ta.structure(ctx.window(50)) if s.trend == "uptrend" and ctx.last().close > s.swing_high: return Signal.buy()

ta.squeeze

ta.squeeze(bars, bb_period=20, bb_std=2.0, kc_period=20, kc_mult=1.5) → Squeeze

Volatility squeeze detector (Bollinger Bands inside Keltner Channels). Fires when volatility compresses then releases — the classic setup before an expansion move.

FieldTypeDescription
.activeboolsqueeze is on right now (BB inside KC)
.scorefloat0-100, higher = tighter compression
.directionstr"up" / "down" / "neutral" — slope of recent closes
.releaseboolsqueeze was active last bar, inactive this bar — the breakout signal
.compressionfloat | NoneBB width ÷ KC width; < 1 means squeezed
.durationintconsecutive bars the squeeze has been active
sq = ta.squeeze(ctx.window(50)) if sq.release and sq.direction == "up": return Signal.buy()

ta.manipulation

ta.manipulation(bars, lookback=50, swing_strength=2, volume_lookback=20) → Manipulation

Wyckoff spring/upthrust detector: a bar that pierces a recent swing low (or high) — sweeping the stops resting just beyond it — and closes back inside the prior range on the same bar. Reuses ta.structure() for the swing levels, so .swing_high/.swing_low always reflect a pivot confirmed strictly before the current bar.

FieldTypeDescription
.springboolbullish: this bar pierced below the recent swing low, then closed back above it
.upthrustboolbearish: this bar pierced above the recent swing high, then closed back below it
.directionstr"bullish" / "bearish" / "neutral"
.strengthfloat0-100 — the classic Accumulation/Distribution money-flow multiplier: how convincingly this bar closed back into range (0 = barely, 100 = closed at the extreme opposite the sweep)
.volume_ratiofloat | Nonethis bar's volume ÷ its own trailing average; None if not enough bars. Exposed rather than hard-coded into a threshold, since which side of "high volume" actually confirms a spring is genuinely debated even within Wyckoff method
.swing_highfloat | Nonethe swing high this reading was measured against
.swing_lowfloat | Nonethe swing low this reading was measured against
m = ta.manipulation(ctx.window(50)) if m.spring and m.strength > 60: return Signal.buy().stop_atr(1.3).target_atr(2.5)

ta.regime

ta.regime(bars, atr_period=14, bb_period=20, bb_std=2.0, lookback=100, threshold=20.0) → Regime

Classifies the current market as choppy vs. trending and volatility as high/low, using ATR percentile and Bollinger bandwidth percentile. Use it to gate a strategy so it only trades the conditions it was designed for.

FieldTypeDescription
.choppyboollow volatility AND a tight recent range
.trendingboolelevated volatility AND a wide recent range
.volatilitystr"low" / "normal" / "high"
.high_volatilitybool.volatility == "high"
.low_volatilitybool.volatility == "low"
.atr_percentilefloat | None0-100 — ATR's rank against its own trailing history
.bandwidth_percentilefloat | None0-100 — Bollinger bandwidth's rank against its own trailing history
r = ta.regime(ctx.window(50)) if r.choppy and rsi.current < 30: return Signal.buy() # mean-reversion only makes sense when choppy

ta.crossover / ta.crossunder / ta.cross

ta.crossover(a, b) → bool

Equivalent to a.crossed_above(b) — a standalone function form.

ta.crossunder(a, b) → bool

Equivalent to a.crossed_below(b).

ta.cross(a, b) → bool

True on either direction of cross.

ta.highest / ta.lowest

ta.highest(bars, period) → float

Highest close over the trailing period bars — a single number, not a Series.

ta.lowest(bars, period) → float

Lowest close over the trailing period bars.

# ctx.bars[:-1] excludes the current bar — otherwise the current # close is always included in its own "breakout level" breakout_level = ta.highest(ctx.bars[:-1], 20) if ctx.last().close > breakout_level: return Signal.buy()

ta.vwap

ta.vwap(bars) → Series[float]

Session Volume-Weighted Average Price: cumulative (typical price × volume) / cumulative volume, resetting each UTC calendar day. Only covers the current session's barslen(result) is bars-in-today's-session, not len(bars), unlike every other indicator here. Use .current/.previous, never positional indexing against bars.

vw = ta.vwap(ctx.bars) if len(vw) < 10 or vw.previous is None: return None # not enough of today's session yet prev, curr = ctx.bars[-2], ctx.bars[-1] if prev.close < vw.previous and curr.close > vw.current: return Signal.buy() # reclaimed VWAP from below

Series

What every indicator returns. A list of values (oldest first) plus lookback and comparison helpers, so you rarely need to index manually.

.currentlast value
.previoussecond-to-last value
.slopecurrent − previous
.accelerationchange in slope
.crossed_above(other)bool
.crossed_below(other)bool
.all() / .any()over the whole series

Series support elementwise arithmetic (series_a - series_b, series * 2). Gotcha: a bare Series is deliberately restrictive in a boolean context (if my_series: raises rather than silently checking "is non-empty" the way a plain list would) — always compare a specific value, e.g. if series.current > 0, not if series.

stats

Lightweight statistics helpers for working with raw list[float] alongside the indicator library. Use them when you need a number the ta module doesn't already compute for you as part of an indicator.

stats.mean(values)float
stats.std(values)float (population std, not sample)
stats.percentile(values, pct)float — pct is 0-100
stats.zscore(values)float — how many std-devs the last value is from the mean

Score

A weighted confluence accumulator — build up evidence from multiple named conditions, then convert to a Signal once a threshold is met. Useful when no single indicator alone should trigger a trade.

Score(max_score=100.0)

max_score is only the denominator .percent/.normalize() divide by — it's not enforced, your components can add up to more or less than it.

.add(name, points) → Score

name is a label string (for .breakdown()/.best_factor), points is a number — not a condition + weight. Gate the points yourself: score.add("Squeeze released", 30 if sq.release else 0).

.totalsum of every .add()'s points
.percenttotal / max_score * 100
.best_factorname of the highest-point component, or None
.normalize(target=100.0)total / max_score * target
.breakdown(){name: points} dict
score = Score() score.add("Squeeze released", 30 if sq.release else 0) score.add("Uptrend structure", 20 if s.trend == "uptrend" else 0) score.add("Volume confirmed", 15 if vol_confirmed else 0) if score.total >= 50: return Signal.buy().with_confidence(score.percent / 100)

Signal

What run(ctx) returns to act on this bar.

Signal.buy() / Signal.sell()

Open or close a long position.

Signal.short() / Signal.cover()

Open or close a short position. Backtesting only — live trading currently only handles buy/sell actions, so a script using short()/cover() will backtest correctly but silently no-op those actions when run live. Keep live-bound scripts long-only until that's implemented.

Trade management chain

Chain these off a Signal to give the engine (backtest or live) native exit handling, instead of managing stops/targets yourself in run() on later bars.

.stop_atr(multiple) → Signal

Stop-loss at multiple × ATR from entry. Also becomes the risk basis for expectancy calculations.

.target_atr(multiple) → Signal

Take-profit at multiple × ATR from entry.

.trail_atr(multiple) → Signal

Trailing stop, updated as price moves favorably.

.break_even(r) → Signal

Move stop to entry once the trade is r × its initial risk in profit — units of the stop distance .stop_atr() set, not raw ATR directly (only the same number when your stop multiple is exactly 1). Requires .stop_atr() to also be set — that's what defines "initial risk"; without it this is a no-op.

.timeout(bars) → Signal

Force-close the position after bars bars if neither stop nor target has hit.

Signal.buy() \ .stop_atr(1.5) \ .target_atr(3) \ .break_even(1) \ .timeout(20)

Confidence, probability, expectancy

.with_confidence(value) → Signal

Set by your script (0-1) — how strongly the current setup matches the pattern the strategy is looking for. Subjective, computed from whatever evidence the script has on hand this bar.

.probability framework

Historical win rate for this exact script + symbol, from its own backtest history. Not set by your script — auto-attached when a signal comes from strategies.<name>(ctx).

.expectancy framework

Average P&L per trade in R-multiples (units of initial risk), from backtest history. This is usually the better ranking signal than .probability: a 45% win rate with 3.5R winners beats an 80% win rate with tiny gains. Risk basis comes from .stop_atr() if the strategy set one, otherwise the engine's configured stop-loss %.

.sample_size framework

Number of closed trades behind .probability / .expectancy. Treat small sample sizes as noise — expectancy_ranked.py (below) requires at least 15 trades before trusting a comparison.

.tested_at framework

Timestamp of the backtest run these numbers came from.

These four are None until a sub-strategy has actually been backtested for that exact symbol — "never measured" and "measured and it's bad" are different things, so check for None before comparing, not just falsiness.

strategies.<name>(ctx)

Call one of your other saved scripts as a sub-strategy and combine results. Each call is memoized per run, gets its own isolated ctx.state and indicator cache, and returns whatever that script's run(ctx) returned — a Signal, or None if it didn't fire this bar — with .probability / .expectancy / .sample_size / .tested_at auto-attached from that script's own backtest history.

m = strategies.regime_trend(ctx)

Cycles are detected and raise an error rather than infinite-looping — a script can't (directly or transitively) call itself. You can only compose your own saved scripts, not another user's.

# composed_confluence.py — first candidate that fires wins def run(ctx): for name in ("regime_trend", "structure_breakout", "squeeze_breakout"): sig = getattr(strategies, name)(ctx) if sig is not None: return sig return None
# expectancy_ranked.py — pick by measured expectancy, not order MIN_SAMPLE_SIZE = 15 def run(ctx): candidates = [c for c in [ strategies.regime_trend(ctx), strategies.structure_breakout(ctx), strategies.squeeze_breakout(ctx), ] if c is not None] if not candidates: return None trusted = [c for c in candidates if c.expectancy is not None and c.sample_size >= MIN_SAMPLE_SIZE] if trusted: return max(trusted, key=lambda s: s.expectancy) return candidates[0] # cold start: nothing trusted yet, take whatever fired

Settings

Broker API keys are stored per-user in the database. Fields below are always blank on load — that's not an empty account, it's so this page never shows (or accidentally re-saves) your real secret. Leave a field blank and hit Save to keep its current value; type a new value to replace it; use Clear to remove it.

Robinhood  

Alpaca  

Paper Trading

Applied to every strategy running against the in-house paper broker (see paper_broker.py) — no API keys needed for these. Changes only affect fills from now on, not trades already made.

Buys fill this many basis points above the reference price, sells this many below — 100 bps = 1%.
Charged on both entry and exit, as a percentage of trade value — 0 means commission-free (the original default).
Running backtest...