WiseTrader Toolbox

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.

Note

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();
Note

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.

Tip

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.

Warning

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.

Note

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.