Tutorial 4: Filtering Trade Signals
Most people reach for a neural network to predict price or the next return, and — as Tutorial 1 and Tutorial 3 are honest about — that is genuinely hard and often disappointing. This tutorial shows the technique that, in practice, is the most accessible and tends to work best: instead of forecasting the market, you keep a trading system you already have and use a network to filter its signals — keeping the entries that look like winners and dropping the ones that look like losers. It is the single most practical way to put a network to work in trading.
The reason it works better is simple. Predicting the market from scratch is an almost impossibly open question. Grading an entry your own rules already produced is a much narrower, better-posed one: given the market right now, is this particular trade likely to win? You are not asking the network to find a strategy — you bring the strategy — only to sort the signals it generates. That is a job a network can actually do.
A ready-to-run copy of the worked example below is installed with the toolbox at
WiseTraderToolbox\Neural Networks\Tutorial 4 - Signal Filter.afl.
The idea: a primary system and a secondary filter
The approach has a name — meta-labeling — and two parts:
- A primary system: your own rules — a moving-average or Donchian breakout, an RSI pullback, whatever you trade. It produces candidate entries, and like any real system, plenty of them lose.
- A secondary network: trained to grade each candidate entry. Given the market state at the signal bar, it outputs a score for how likely that one trade is to be a winner. You then take only the entries it scores above a threshold you choose.
Because the answer is "winner or loser", this is a classification problem,
so it reuses everything from Tutorial 2: a sigmoid output
(activation code 0) paired with SetErrorAlgorithm(1), and the
same overfitting toolkit. If you have read Tutorial 2, the network half of this will feel
familiar — the new idea is what we train it on.
Train only on the signal bars
This is the mechanic that makes the whole thing work, and it falls out of one rule in the
multi-input trainer: a bar is used for training
only if every input and the output are valid on that bar; any bar with a
Null in any channel is dropped. So to train on nothing but the entry bars, you
gate each feature and the label to the entries — set them to
Null everywhere else:
f1 = IIf(entrySig, ROC(Close, 5), Null); // a feature, but only on entry bars
lbl = IIf(entrySig, wins, Null); // the win/loss label, only on entry bars
Off a signal both are Null, so the trainer skips those bars entirely; the
network only ever sees the moments your system actually fires. When you later
run the network, those same gated inputs are valid on every entry bar, so you get
a score exactly where you need one. There is nothing else to switch on — the trimming is
automatic.
The worked example
Here is the complete formula. It defines the primary system, labels each past entry a winner or loser, and trains the filter on those entries when you click Train on a chart. The backtest below it loads the saved filter and applies it to the entries — and because a click can't fire in the Analysis window, that backtest never retrains. Read it top to bottom, then we will pull out each part.
// WiseTrader Toolbox - Neural Network Tutorial 4: Signal Filter
// --------------------------------------------------------------------------
// The most practical way to put a neural network to work in trading: not
// predicting price, but FILTERING the entries of a system you already trust.
// A primary rule-based system (here a Donchian breakout in an uptrend) throws
// off lots of candidate entries - many of them losers. A secondary network is
// trained to grade each candidate: given the market at the signal bar, how
// likely is THIS trade to win? You then take only the entries it scores highly.
//
// This is a CLASSIFICATION problem (winner vs loser), so it reuses Tutorial 2's
// recipe: a sigmoid output + SetErrorAlgorithm(1) and the overfitting toolkit.
// The key mechanic is that we train ONLY on the primary system's signal bars:
// every feature and the label is set to Null off the entry bars, and the
// multi-input trainer drops any row that has a Null in it.
//
// WORKFLOW: put this on a chart and click Train to train the filter once and
// save it; the click cannot fire in the Analysis window, so a backtest there
// loads the saved network and never retrains. The button technique is explained
// in full under "Running a Network on a Chart".
// --------------------------------------------------------------------------
SetBarsRequired(99999, 99999);
Horizon = 10; // holding horizon: used both to label trades and to time-stop them
// --- PRIMARY system: a 20-bar Donchian breakout, taken only in an uptrend ---
upTrend = Close > MA(Close, 200);
entrySig = upTrend AND Cross(Close, Ref(HHV(High, 20), -1));
// --- LABEL: was the trade a winner? Judge it by the forward return over the
// holding horizon. Reaching into the future is fine for the LABEL - the
// trained network never sees it when it predicts. The last Horizon bars
// have no known outcome yet, so we leave them out of training. ---
fwdRet = (Ref(Close, Horizon) / Close - 1) * 100;
wins = fwdRet > 0; // 1 = winner, 0 = loser
labeled = entrySig AND NOT IsNull(fwdRet); // entry bars with a known outcome
// 1. Settings - a classifier with the full overfitting toolkit (see Tutorial 2)
SetLearningAlgorithm(8); // AdamW : modern optimizer with weight decay
SetLearningRate(0.002); // the Adam family wants a small learning rate
SetAdamWeightDecay(0.01); // gently shrink weights (regularization)
SetNetworkWithActivationLayer1(6, 1, 0); // 6 tanh hidden neurons, SIGMOID output (0..1)
SetErrorAlgorithm(1); // classification loss (pairs with a sigmoid output)
SetScalingAlgorithm(0); // mean / standard-deviation scaling
SetPercentTestingData(30); // hold back the most recent 30% as unseen test data
SetEarlyStoppingPatience(30);
SetDropoutRate(0.2); // randomly ignore 20% of hidden units while training
SetMaximumEpochs(400);
SetSeed(1);
netName = Name() + "_T4_Filter";
netPath = "WiseTraderToolbox\\NeuralNetwork\\" + netName;
ClearNeuralNetworkInputs();
// 2. Has the filter been trained yet? Ask the disk.
hasNet = False;
fh = fopen(netPath, "r");
if(fh)
{
fclose(fh); // close every handle fopen returns (or Error 53)
hasNet = True;
}
// 3. The Train / Retrain button. On a chart a click triggers a recalc and
// trains; in the Analysis window the cursor functions return 0, so a backtest
// never trains - it just loads whatever network is already saved.
function DrawButton(Text, x1, y1, minW, h, colorFrom, colorTo)
{
GfxSetOverlayMode(0);
GfxSelectFont("Segoe UI", 9, 700); // pick the font BEFORE measuring the caption
GfxSetBkMode(1);
padX = 12; // space to leave on each side of the caption
w = Max(minW, GfxGetTextWidth(Text) + 2 * padX); // widen the button if the label is long
GfxGradientRect(x1, y1, x1 + w, y1 + h, colorFrom, colorTo);
GfxSetTextColor(colorWhite); // white caption on the green box - reads on any theme
GfxDrawText(Text, x1, y1, x1 + w, y1 + h, 32|4|1); // single line, centred horizontally + vertically
return w;
}
BtnX = 5;
BtnY = 40; // pushed down so it clears the chart title
BtnW = 170;
BtnH = 30;
BtnTop = ColorRGB(59, 130, 246); // button gradient - lighter blue on top ...
BtnBot = ColorRGB(37, 99, 235); // ... deepening to a darker blue at the bottom
LBClick = GetCursorMouseButtons() == 9; // 9 = left button down AND cursor over this pane
MouseX = Nz(GetCursorXPosition(1)); // pixel coordinates
MouseY = Nz(GetCursorYPosition(1));
if(hasNet)
BtnLabel = "Retrain filter";
else
BtnLabel = "Train filter";
BtnW = DrawButton(BtnLabel, BtnX, BtnY, BtnW, BtnH, BtnTop, BtnBot); // BtnW becomes the width actually drawn
CursorInBtn = MouseX >= BtnX AND MouseX <= BtnX + BtnW AND MouseY >= BtnY AND MouseY <= BtnY + BtnH;
BtnClicked = CursorInBtn AND LBClick;
// 4. TRAIN - only on a click. Gate every feature and the label to the entry
// bars (Null elsewhere), so the trainer keeps only the signal bars.
if(BtnClicked)
{
ClearNeuralNetworkInputs();
AddNeuralNetworkInput(IIf(entrySig, ROC(Close, 5), Null), 0); // recent 5-bar return
AddNeuralNetworkInput(IIf(entrySig, RSI(14) / 100, Null), 0); // momentum, 0..1
AddNeuralNetworkInput(IIf(entrySig, (ATR(10) / Close) * 100, Null), 0); // volatility, % of price
AddNeuralNetworkInput(IIf(entrySig, ADX(14) / 100, Null), 0); // trend strength
AddNeuralNetworkInput(IIf(entrySig, (Close - MA(Close, 50)) / MA(Close, 50) * 100, Null), 0); // % above the 50-bar MA
AddNeuralNetworkOutput(IIf(labeled, wins, Null), 0); // 1 if the trade wins
fdelete(netPath); // drop any stale network so we retrain fresh
TrainMultiInputNeuralNetwork(netName);
StaticVarSetText("WTT_" + netName, "Training MSE = " + NeuralNetworkMSE +
" Test MSE = " + TestingDataNeuralNetworkMSE);
ClearNeuralNetworkInputs();
}
// 5. Re-read the disk after training: hasNet is true only if the save worked.
hasNet = False;
fh = fopen(netPath, "r");
if(fh)
{
fclose(fh);
hasNet = True;
}
// 6. SCORE every entry bar with the saved filter (cheap - safe on every recalc
// and in the backtest). Without a saved network there is nothing to score.
score = Null;
if(hasNet)
{
ClearNeuralNetworkInputs(); // run set: the same gated inputs, NO output
AddNeuralNetworkInput(IIf(entrySig, ROC(Close, 5), Null), 0);
AddNeuralNetworkInput(IIf(entrySig, RSI(14) / 100, Null), 0);
AddNeuralNetworkInput(IIf(entrySig, (ATR(10) / Close) * 100, Null), 0);
AddNeuralNetworkInput(IIf(entrySig, ADX(14) / 100, Null), 0);
AddNeuralNetworkInput(IIf(entrySig, (Close - MA(Close, 50)) / MA(Close, 50) * 100, Null), 0);
score = RunMultiInputNeuralNetwork(netName);
}
// 7. Backtest the system. Run this in the Analysis window AFTER you have trained
// the filter once on a chart.
SetOption("InitialEquity", 100000);
SetPositionSize(10, spsPercentOfEquity); // 10% of equity per trade
SetTradeDelays(1, 1, 1, 1); // signal on the close, fill at next bar's open
BuyPrice = Open;
SellPrice = Open;
scoreThresh = 0.55; // raise to take fewer, higher-graded trades
useFilter = True; // set to False to backtest the RAW primary system
doFilter = useFilter AND hasNet; // can only filter once the network exists
if(doFilter)
Buy = entrySig AND (score > scoreThresh);
else
Buy = entrySig;
Sell = Cross(MA(Close, 200), Close); // trend break: close back below the 200-bar MA
Buy = ExRem(Buy, Sell);
Sell = ExRem(Sell, Buy);
ApplyStop(stopTypeNBar, stopModeBars, Horizon); // time stop: exit after the holding horizon
ApplyStop(stopTypeLoss, stopModePercent, 8, True); // protective 8% max-loss stop
// 8. Display: price, the kept entries and the button. Until the filter is
// trained the chart shows the RAW entries and a message in the middle.
Plot(Close, "Close", colorDefault, styleCandle);
PlotShapes(IIf(Buy, shapeUpArrow, shapeNone), colorGreen, 0, Low, -15);
PlotShapes(IIf(Sell, shapeDownArrow, shapeNone), colorRed, 0, High, -15);
if(hasNet)
{
Title = "Tutorial 4 - filtered entries (score > " + scoreThresh + ") " +
StaticVarGetText("WTT_" + netName);
}
else
{
// White text over a black outline, so it stays readable whether the chart
// background is white or black.
msg = "The filter has not been trained yet.";
pw = Status("pxwidth");
ph = Status("pxheight");
GfxSetOverlayMode(0);
GfxSelectFont("Segoe UI", 12, 700);
GfxSetBkMode(1);
GfxSetTextColor(colorBlack);
GfxDrawText(msg, 1, 0, pw + 1, ph, 32|1|4|16);
GfxDrawText(msg, -1, 0, pw - 1, ph, 32|1|4|16);
GfxDrawText(msg, 0, 1, pw, ph + 1, 32|1|4|16);
GfxDrawText(msg, 0, -1, pw, ph - 1, 32|1|4|16);
GfxSetTextColor(colorWhite);
GfxDrawText(msg, 0, 0, pw, ph, 32|1|4|16);
Title = "Tutorial 4 - click Train to train the filter (raw entries shown)";
}
// 9. Cleanup
EnableProgress();
RestoreDefaults();
ClearNeuralNetworkInputs();
The workflow has two steps. First put the formula on a chart and click Train filter — that trains the network on the entry bars and saves it. Then run the backtest in the Analysis window: a click can't fire there, so it loads the saved filter and never retrains, which also keeps the backtest fast. The button mechanism — the disk check, the click handling and the input-set sequencing — is explained step by step under Running a Network on a Chart.
The primary system and the label
The primary system is a plain trend-following breakout: in an uptrend
(Close > MA(Close, 200)), buy when price closes above the prior 20-bar high.
Trend-following has a naturally low win rate — most breakouts fade — which
is exactly why good entry selection is worth so much here, and why it is the standard
setting for this technique.
To train a filter you need to tell it which past entries were good. We label each entry by
its forward return over the holding horizon: if price is higher
Horizon bars later, the trade was a winner (wins = 1), otherwise a
loser. Using future data for the label is fine — it is only the target, and the
trained network never sees it when it predicts. The one wrinkle is the tail of the chart:
the last Horizon bars have no known outcome yet, so NOT IsNull(fwdRet)
leaves them out of training while still letting the network score them live.
A forward return over a fixed horizon is the simplest sturdy label. A better one is the triple-barrier label: mark a trade a winner only if a profit target is hit before a stop, using the same exits you actually trade. It lines the label up with how the position really closes, so the network grades the trade you will take rather than an idealised one.
The features
The inputs describe the market at the entry bar, and like every network in this manual they are stationary and strictly backward-looking — a recent return, a momentum gauge, a volatility measure, a trend-strength reading, and how far price has stretched above its 50-bar average. None of them peeks at a future bar. The whole point of the filter is to learn which of these readings tend to separate the breakouts that follow through from the ones that fail.
Keep every feature backward-looking. Only the label reaches into the future. A
feature built with a positive Ref shift, or an indicator that re-centres on
later bars, leaks the answer into the score: the filter will look superb in the backtest
and fall apart in real time.
The classifier settings
The settings block is Tutorial 2's classifier recipe, unchanged: AdamW with a small learning rate and a little weight decay, a sigmoid output with the matching classification loss, mean/standard-deviation scaling, and the overfitting guards — a held-out test split, early stopping, dropout and a deliberately small network. The Accuracy & Overfitting page covers each of these and why they matter; there is nothing filter-specific to add here.
Using the filter in the backtest
The backtest is ordinary AFL. Trades fill at the next bar's open
(SetTradeDelays with BuyPrice = Open), exit on a trend break or
after the holding horizon, and carry a protective stop. The filter is the single
AND clause on the entry:
doFilter = useFilter AND hasNet; // only filter once a saved network exists
if(doFilter) Buy = entrySig AND (score > scoreThresh); // filtered
else Buy = entrySig; // the raw primary system
That is the experiment worth running. Once the filter is trained, set
useFilter = False to backtest the raw breakout, then set it back to
True and compare. Typically you will see
fewer trades and a higher win rate — the filter has thrown away the entries
it judged weakest. Raising scoreThresh is stricter still: fewer, higher-graded
trades. Because the sigmoid score is not a calibrated probability (a 0.7 does
not mean "wins 70% of the time"), pick the threshold by how it behaves on the
held-out test data, not on the bars the network trained on.
There is published evidence this approach can work well. A 2025 study in Expert Systems with Applications applied an LSTM to filter the entry signals of a trend-following strategy on four index futures (Taiwan TX, S&P 500, Dow Jones and NASDAQ 100). The LSTM beat five other machine-learning models on accuracy, precision, recall and F1 — accuracy above 85% and precision above 80% in most cases — and the authors report that filtering the entries reduced the number of trades while improving returns, success rate and robustness. Those numbers are on index futures; your own market, features and labels will give different results, but they show the idea is sound.
Be honest about what a filter can and can't do
A filter is a sorting tool, not an edge generator. It is worth being clear-eyed about its limits:
- It cannot rescue a losing system. The filter only re-orders the primary system's own signals. If those signals have no edge to begin with, the best the network can do is pick a slightly-less-bad subset. Bring a primary system that at least breaks even.
- It buys precision by trading less. The usual outcome is fewer trades with a better hit rate. That is genuinely valuable, but make sure what is left still trades often enough to matter and to be worth judging.
- The single-file backtest is optimistic. This formula trains and
backtests over the same history, so over the training span the network has effectively
seen which of those trades won. Trust the held-out
TestingDataNeuralNetworkMSE, and for a realistic equity curve train the filter on older data, save it, then run it forward on a later block you never trained on — the same Standard-versus-Walk-Forward point made in Tutorial 1. - It overfits like any model. A small network, the test split, early stopping, no look-ahead, and changing few things at a time all still apply. A filter tuned until the backtest sparkles is just a slower way to curve-fit.
Going further: a recurrent filter
The study above used an LSTM rather than the feed-forward network here, and
a recurrent filter is a natural fit: it reads the sequence of bars leading into the
entry, so it can weigh the trade in the context of how the move built up — useful for a
trend-following entry. You would train it as in Tutorial 3,
with SetNeuralNetworkType(1) and SetRecurrentParams.
One mechanical difference matters, though. The recurrent engine slides a window along a
continuous series of bars, so you cannot blank out the non-signal
bars the way the MLP does — a window that overlapped a Null bar would be
discarded, and you would lose almost all of them. For a recurrent filter you therefore keep
the full continuous feature series, train it as a sequence model, and simply read its score
at your entry bars. Remember too that the recurrent output is linear, not a
0–1 sigmoid, so treat the score as an ordering and cut it at a threshold you judge on the
test data. Keep the LSTM as an advanced variation; the feed-forward filter above is the
accessible place to start.
That rounds out the four tutorials. The arc runs from predicting a return (Tutorial 1), to classifying the next direction (Tutorial 2), to a recurrent model for swings (Tutorial 3), and finally to the most practical use of all — letting a network sharpen a system you already trade. Of the four, this is the one most likely to earn its place in your research.