Trading Signal Analysis with Marimo and VectorBT Pro
Building an interactive dashboard for signal projection analysis
In this article I will describe how to build an interactive dashboard for signal analysis with Marimo and VectorBT Pro.
VectorBT Pro is a high performance backtesting and signal analysis library. It can process large amounts of data very quickly and has extensive built-in support for signal analysis, projections, and portfolio simulation — most of what we need for this dashboard is already implemented. Note that VectorBT Pro is a paid library — you can find pricing information on its website.
Marimo is a reactive python notebook where cells update automatically when their dependencies change. Compared to Jupyter, it has a better editor, is less prone to errors caused by running cells out of order, and its reactive model makes it a natural fit for interactive dashboards — changing a parameter instantly updates all dependent outputs without manually re-running anything. Marimo notebooks can also be run as standalone web apps using marimo run, which hides the code and presents only the outputs and interactive controls.
The notebook consists of several blocks, each with its own mini-dashboard and UI controls. Overall there are 5 blocks:
Signal Summary — shows basic trade statistics and UI controls for signal parameters.
Projection Bands — shows how prices develop after entry, exit, and random signals, allowing us to compare them and see if the signal has any edge over a random baseline.
Shrink vs Stretch — examines the role of exit timing by comparing price paths cut to a fixed length vs extended beyond the opposite signal.
Random Projections — shows a random sample of individual price paths without quantile bands, allowing us to see actual projections rather than aggregated statistics.
Portfolio Simulation — runs two portfolio simulations to confirm the findings from the previous blocks with actual performance metrics.
The notebook uses daily OHLCV data for 50 USDT pairs from Binance, provided in the corresponding github repository. Trading signals are based on the RSI indicator, but the notebook can be easily adapted to analyze any other signals. The signals are calculated as follows:
Entry signal is generated when RSI crosses above the entry threshold (oversold recovery).
Exit signal is generated when RSI crosses below the exit threshold (overbought recovery).
Thus we have 3 signal parameters: time period used for calculating RSI, entry threshold, and exit threshold. The code below implements this: it calculates RSI using open prices to avoid look-ahead bias, generates raw entry and exit signals from crossovers, cleans them to ensure entries and exits alternate correctly, and runs a portfolio simulation.
# prepare indicator
rsi = vbt.talib("RSI").run(
data.open, # use open prices to avoid look-ahead bias
timeperiod=timeperiod.value,
hide_params=True
)
# generate signals
entries = rsi.rsi.vbt.crossed_above(entry_thr.value)
exits = rsi.rsi.vbt.crossed_below(exit_thr.value)
# clean signals
clean_entries, clean_exits = entries.vbt.signals.clean(exits)
# run portfolio simulation
pf = vbt.Portfolio.from_signals(
data.close,
entries=clean_entries,
exits=clean_exits
)Signal Summary
The first block consists of three components: signal description, signal parameters, and trade statistics. Each component is created separately and then assembled by stacking them horizontally or vertically using Marimo’s layout functions.
To visually separate the components I use two types of borders — an outer border around each block and an inner border around components within a block. Their parameters are defined once at the top of the notebook to avoid repetition:
outer_border = {"border": "1px solid darkgray", "padding": "8px",
"border-radius": "8px", "height":"100%", "width":"100%"}
inner_border = {"border": "1px solid lightgray", "padding": "8px",
"height":"100%", "width":"100%"}
The final block is assembled with the following code:
signal_summary_block = mo.hstack([
mo.vstack([signal_descr, signal_params], align="center"),
stats_block
], widths=[1,2], align="stretch").style({**outer_border})The resulting dashboard is shown below. It displays signal rules, adjustable parameters, and a trade statistics table side by side. Changing any of the signal parameters instantly updates the statistics and all other parts of the notebook. The next block takes advantage of this reactivity to explore how prices develop after each type of signal.
Projection Bands
This block analyzes how prices develop after each type of signal. The main concept here is projections — normalized price paths of a fixed length starting from a signal. Each projection starts at 1 and shows how the price evolves over the next N bars relative to the signal point, making projections from different symbols and time periods directly comparable.
To generate projections we first create delta ranges — index ranges that start at each signal and end after a fixed number of bars. An example of delta ranges is shown below — you can see that each range starts at a signal and lasts for 7 days.
Delta ranges are then used to extract projections. An example of projections is shown below. All projections start at 1 and show normalized price development over the projection period.
We can confirm this by looking at the first projection — it is indeed the normalized price of AAVEUSDT starting from 2024-04-15.
We generate projections for three signal types: entry, exit, and random. Random signals are generated using the same entry probability as real signals for each symbol, which ensures a fair comparison. The code for generating random signals is shown below.
# calculate entry probability per symbol
entry_probs = entries[~data.close.isna()].mean() # use only indeces with valid data
# generate random signals
rand_entries = vbt.pd_acc.signals.generate_random(
clean_entries.vbt.wrapper,
prob=vbt.to_2d_pc_array(entry_probs.astype(float).values),
seed=42
)
# keep signals only at indices with valid data
rand_entries[data.close.isna()] = FalseNow we can calculate projections for all three signal types and build the dashboard.
entry_ranges = clean_entries.vbt.signals.delta_ranges(proj_length.value, close=data.close)
entry_ranges = entry_ranges.status_closed
rand_ranges = rand_entries.vbt.signals.delta_ranges(proj_length.value, close=data.close)
rand_ranges = rand_ranges.status_closed
exit_ranges = clean_exits.vbt.signals.delta_ranges(proj_length.value, close=data.close)
exit_ranges = exit_ranges.status_closed
entry_projections = entry_ranges.get_projections()
rand_projections = rand_ranges.get_projections()
exit_projections = exit_ranges.get_projections()The block has four components: a description, a slider for controlling projection length, a chart showing 20/50/80 quantile bands for each signal type, and a table with summary statistics at the final bar.
Any group can be hidden by clicking on it in the legend. For example, looking at only entry and random projections, we can see that entry projections consistently outperform random in the lower and middle quantiles.
The table confirms this — mean and median projection values are higher for entry signals than for random. The signal seems to have some edge, but so far we haven't taken exit signals into account. The next block does that by looking at what happens between consecutive signals.
Shrink vs Stretch
This block uses a different approach to generating projections. Instead of fixed-length delta ranges, we use between_ranges - ranges that span from one signal to the opposite signal. This gives us two types of projections: entry→exit (price path of a long trade) and exit→entry (price path between closing one trade and opening the next). Exit→entry projections would correspond to short trades if we traded both directions.
# trade ranges: entry->exit and exit->entry
entry_exit_ranges = clean_entries.vbt.signals.between_ranges(clean_exits, close=data.close).status_closed
exit_entry_ranges = clean_exits.vbt.signals.between_ranges(clean_entries, close=data.close).status_closed
# trade projections
entry_exit_projections = entry_exit_ranges.get_projections()
exit_entry_projections = exit_entry_ranges.get_projections()Unlike delta ranges, these projections have varying lengths since trades have different durations. Because shorter projections are padded with NaNs beyond their end point, and shorter trades are more common than longer ones, the bands at later bars are calculated from fewer observations. Vectorbt provides two ways to work with such non-uniform projections — shrinking and stretching. Shrinking trims projections to a fixed number of bars, letting you focus on what happens in the first N bars regardless of trade length. Stretching extends shorter projections beyond the opposite signal — effectively asking: what would have happened if we hadn't exited?
shrink_entry_proj = entry_exit_ranges.get_projections(proj_period=proj_period.value)
stretch_entry_proj = entry_exit_ranges.get_projections(proj_period=proj_period.value, extend=True)
shrink_exit_proj = exit_entry_ranges.get_projections(proj_period=proj_period.value)
stretch_exit_proj = exit_entry_ranges.get_projections(proj_period=proj_period.value, extend=True)The key insight is what stretching reveals. If stretched projections perform better than shrunk ones, it means the price continued moving favorably after the exit signal — in other words, the exits are hurting performance rather than helping it.
The plot for this block shows shrunk and stretched projections side by side with adjustable quantile bands. There are two tabs — one for entry→exit and one for exit→entry projections. By default the lower and upper bands are set to 0.2 and 0.8, meaning the bands cover 60% of all projections. The quantile controls let you adjust this range depending on how much of the distribution you want to see. They are created with marimo’s number inputs and stacked horizontally:
proj_period = mo.ui.number(value=7, start=5, stop=200, step=1, label=”proj_period”)
lower_qq = mo.ui.number(value=0.2, start=0.01, stop=0.5, step=0.01, label=”lower_qq”)
upper_qq = mo.ui.number(value=0.8, start=0.5, stop=0.99, step=0.01, label=”upper_qq”)
controls = mo.hstack([proj_period, lower_qq, upper_qq]).style({**inner_border})
The plotting function creates a two-panel subplot using vectorbt's make_subplots and wraps the result in a marimo plotly element:
def shrink_stretch_plot(shrink_proj, stretch_proj, lower_qq, upper_qq):
fig = vbt.make_subplots(rows=1, cols=2, shared_yaxes=True, subplot_titles=["Shrink", "Stretch"])
shrink_proj.vbt.plot_projections(
plot_lower=f"Q={lower_qq}",
plot_upper=f"Q={upper_qq}",
plot_projections=False,
add_trace_kwargs=dict(row=1, col=1),
fig=fig,
)
stretch_proj.vbt.plot_projections(
plot_lower=f"Q={lower_qq}",
plot_upper=f"Q={upper_qq}",
plot_projections=False,
add_trace_kwargs=dict(row=1, col=2),
fig=fig,
)
fig.update_layout(
template="plotly_white",
width=None,
autosize=True,
showlegend=False,
yaxis_showticklabels=True,
yaxis2_showticklabels=True
)
return mo.ui.plotly(fig)Looking at the entry→exit plot, we can see that stretched projections perform better than shrunk ones. Since stretching extends the price path beyond the exit signal, this means prices continued rising after we exited — our exits are cutting trades short and reducing performance.
The exit→entry plot tells a different story. Here shrunk projections perform better, and prices tend to rise rather than fall after exit signals. This means exit→entry ranges are not good candidates for short trades, but they may actually be better candidates for long trades than the original entry→exit trades. The next block lets us look at individual projections to see whether these patterns hold consistently across different samples.
Random Projections
This block shows individual price paths rather than aggregated quantile bands. Plotting all projections at once would be too slow and hard to read, so instead we randomly sample 20 of them. Entry→exit and exit→entry projections are shown side by side, and pressing the refresh button draws a new random sample. The function for sampling and plotting random projections is straightforward:
def plot_rand_proj(proj, n=20):
rand_cols = np.random.choice(proj.shape[1], n, replace=False)
fig = proj.iloc[:, rand_cols].vbt.plot_projections(plot_bands=False)
fig.update_layout(
template=”plotly_white”,
width=None,
autosize=True,
showlegend=False
)
return mo.ui.plotly(fig)As noted in the vectorbt’s projections tutorial, repeatedly refreshing and looking at different samples is itself a form of validation — if the patterns we observed in the previous block are real, they should appear consistently across different samples. In this case, exit→entry projections do tend to show more upward movement than entry→exit projections, which is consistent with our earlier findings. To confirm this with actual numbers, we run two portfolio simulations.
Portfolio Simulation
The last block runs two portfolio simulations to confirm what we observed in the previous blocks. The first portfolio trades entry→exit signals as usual. The second swaps them — it enters at exit signals and exits at entry signals, effectively treating exit→entry ranges as long trades.
pf_en = vbt.Portfolio.from_signals(
data.close,
entries=clean_entries,
exits=clean_exits
)
pf_ex = vbt.Portfolio.from_signals(
data.close,
entries=clean_exits,
exits=clean_entries
)Each symbol is simulated separately. Calling stats() without arguments aggregates results by taking the mean across all symbols. To also see median statistics we call stats(agg_func=None) which returns per-symbol stats, and then take the median manually. Both are displayed in a tabbed interface so they can be compared easily.
pf_stats_mean = pd.DataFrame(columns=["entry_exit", "exit_entry"])
pf_stats_mean["entry_exit"] = pf_en.stats()
pf_stats_mean["exit_entry"] = pf_ex.stats()
pf_stats_median = pd.DataFrame(columns=["entry_exit", "exit_entry"])
pf_stats_median["entry_exit"] = pf_en.stats(agg_func=None).median()
pf_stats_median["exit_entry"] = pf_ex.stats(agg_func=None).median()
mo.ui.tabs({
"mean": mo.ui.table(pf_stats_mean, pagination=False, selection=None),
"median": mo.ui.table(pf_stats_median, pagination=False, selection=None)
}).style(**outer_border)Let's take a look at the median aggregate statistics shown on the screenshot above. Both strategies have similar Sharpe and Sortino ratios, but entry_exit has higher expectancy and a much higher win rate (67% vs 29%). The key difference is trade duration — exit_entry wins less often but winning trades last much longer (83 days on average vs 32 days), while losing trades are shorter. This suggests that exit signals are not clearly harmful, but the two strategies behave very differently in terms of trade structure.
The dashboard we developed makes signal analysis much more interactive than a static notebook. Instead of manually regenerating plots for each parameter combination, the UI controls let you explore any combination instantly — changing the RSI period, thresholds, or projection length updates all blocks at once. The modular block structure also means each block can be reused in other dashboards with minimal changes.
In the following articles I will build more interactive dashboards covering other aspects of trading signal analysis and introduce more vectorbt features along the way.
Marimo notebook with source code is available here. A non-reactive version of the notebook is also available here.
If you have any questions, suggestions or corrections please post them in the comments. Thanks for reading.










