The Fisher-KPP Double-Pass Smoothed Wave Indicator
The Fisher-KPP Double-Pass Smoothed Wave indicator is an advanced, non-linear trend-following and mean-reversion algorithm designed for the Thinkorswim (TOS) platform. By combining partial differential equations (PDEs) from physical chemistry and biology with a high-performance backtesting engine, this system is designed to model, filter, and trade macro price waves.1. Scientific Foundation & Background
The mathematical engine of this indicator is inspired by the Fisher-Kolmogorov-Petrovsky-Piskunov (Fisher-KPP) equation, which historically describes the physical propagation of reaction-diffusion waves (such as a beneficial genetic trait spreading through a population, or a flame front propagating).In physics, the Fisher-KPP equation describes how a self-sustaining wave front moves through a medium without losing its shape (like a drop of dye spreading in water).
This indicator replaces "space" with "historical price data." It uses two opposing forces to model trend momentum:
- Diffusion (The Reaction Filter): This acts as your low-pass filter. It bleeds new price changes into the system's memory, dampening random market noise.
- Reaction (The Accelerator): This is the logistic term (r u (1 - u)). When the market is in choppy equilibrium (u approximates 0.5), this term is at its maximum sensitivity, ready to aggressively accelerate the wave if a breakout occurs. As the trend matures and reaches saturation (u --> 1.00), this term decays to zero, locking the wave in and preventing whipsaws.
In this financial adaptation, price action serves as the "reaction trigger" that initiates a trend, while time-series memory acts as the "diffusion medium."
- The Double-Pass Architecture:Instead of using classic lagging indicators, the system runs two recursive Fisher-KPP loops in sequence.
- Pass 1 maps standardized, noise-filtered price displacement into a logistic sigmoid curve, modeling the initial kinetic reaction to a price move. This builds a smooth baseline wave.
- Pass 2 ingests the heavily smoothed output of Pass 1 and calculates its momentum velocity to drive a secondary wave. It uses the velocity (rate of change) of that first wave to trigger its reaction engine.
- The Zero-Lag Generational Filter:Each pass is conditioned by ultra-smooth, long-period (120-period) exponential moving averages. By processing price transitions through these dual non-linear stages, the final wave (smoothed_u2) stays extremely smooth without introducing the catastrophic lag associated with standard moving averages of similar lengths.
- Because the input to Pass 2 is already smooth, its velocity is clean.
- This allows the final signal wave (u2) to turn instantly at major market pivots with virtually zero lag.
Graph that highlights Pass 2 (smoothed_u2): To show the responsiveness and smoothing of this technique.
2. Tuning Parameters (The Core Inputs).
To adapt the speed, sensitivity, and stability of the wave propagation to different instruments, you can adjust the following tuning inputs:
Input Default Mathematical Function Practical Impact on Signals src vwap Price basis for calculations. Using vwap rather than close anchors the initial displacement to volume-weighted institutional value. length 40 Period for the Hull-filtered True Range. Establishes the threshold for "market noise." A higher value makes the baseline standardization less sensitive to brief volatility spikes. selection_advantage 0.40 The r growth factor in the logistic equation. Controls how aggressively the wave reacts to price displacement. Higher values make the wave reach its boundary zones faster, generating earlier signals. diffusion_coefficient 0.20 The D spatial coefficient. Controls the rate at which current wave values bleed into historical values. High diffusion speeds up wave transitions, while low diffusion dampens them. wave_inertia 0.80 Memory feedback factor. Sets the weight of past values u against new inputs. At 0.80, the wave maintains high memory, smoothing out whipsaws but delaying exits. smooth_pass1_length & smooth_pass2_length 120 Post-computation smoothing filters. The primary macro filters. Defaulted to 120 to guarantee that only major structural waves trigger trading signals.
- Structural Thresholds: Long entries trigger when the wave enters oversold territory (< 0.48) and curls upward. Short entries trigger when the wave enters overbought territory (> 0.52) and curls downward. Exits are dynamically executed when the wave returns to its mathematical equilibrium state (0.50).
3. The Backtest Profit Engine & Its Inputs
Built directly into the indicator, this state machine processes historical data to simulate realistic trading performance, displaying real-time metrics in a clean on-chart dashboard.- Dynamic Price Bars & Alternating Signal Execution: The system automatically paints the chart bars to match your trade position state (Cyan for Long, Orange for Short). It features an alternating signal lock (lastSignalDir) that strictly prevents duplicate, compounding entries in the same direction, ensuring a clean "Long --> Flat --> Short" transition sequence.
- Next-Bar Entry Logic: Rather than unrealistically entering at the close of the signal bar, the profit engine registers the trigger and executes the trade at the Open price of the next bar, reflecting true slippage and execution realities.
- Engine Inputs:
- Take_Profit_Dollars ($100000): The absolute dollar target per contract before the engine registers a profit-target exit. The $100,000 selection will allow the trade to fully play out.
- Stop_Loss_Dollars ($100000): The absolute dollar risk allowed per trade before registering a stop-out. The $100,000 selection will allow the trade to fully play out.
- Trade_Commission ($4.50): Deducts round-trip transaction costs from every trade result to ensure the Net PnL is accurate and realistic.
- TicksLostToSpread ($0.0): Allows you to simulate additional slippage for highly liquid or illiquid instruments.
- Display_Mode (Dollars / Pips): Toggles the entire dashboard, labels, and chart bubbles between fiat currency reporting and tick/pip/point increments.
4. Performance Profile: /ES Futures and /MNQ Futures on the 15-Minute Timeframe
This system could be useful for trading the E-mini S&P 500 futures (/ES) or Micro E-mini NASDAQ-100 futures (/MNQ) on a 15-minute intraday chart.
Code:
# Fisher-KPP Double-Pass Smoothed Wave with Backtest Profit Engine
# by whoDAT
# 7/2026
declare upper;
# --- Inputs: Fisher-KPP Engine ---
input src = vwap;
input length = 40;
input selection_advantage = 0.40; # 'r' factor
input diffusion_coefficient = 0.20; # 'D' factor
input wave_inertia = 0.80; # Memory factor
# Tuning knobs defaulted to 120 for macro stability and smooth wave propagation
input smooth_pass1_length = 120;
input smooth_pass2_length = 120;
# Structural trigger thresholds
def long_entry_zone = 0.48;
def short_entry_zone = 0.52;
def exit_line = 0.50;
# --- Inputs: Profit Engine & Visual Settings ---
input Take_Profit_Dollars = 100000;
input Stop_Loss_Dollars = 100000;
input Trade_Commission = 4.50;
input TicksLostToSpread = 0.0;
input ShowProfitBubbles = yes;
input ShowDebugBubbles = no;
input Display_Mode = {default "Dollars", "Pips"};
# --- 1. SAFE ACCUMULATION GATE & PRE-FLIGHT GUARD ---
# Prevents any calculations from executing until the longest moving average has loaded
def max_smoothing = Max(smooth_pass1_length, smooth_pass2_length);
def isReady = BarNumber() > Max(max_smoothing, length);
# --- 2. Background Variation & Market Noise ---
def price_change = src - src[1];
def raw_noise = MovingAverage(AverageType.HULL, TrueRange(high, src, low), length);
# Ensure noise is never NaN or Zero
def noise = if IsNaN(raw_noise) or raw_noise == 0 then 1 else raw_noise;
def smoothed_change = ExpAverage(price_change, 3);
def raw_displacement = smoothed_change / noise;
def standardized_displacement = if IsNaN(raw_displacement) then 0 else raw_displacement;
# --- 3. Pass 1: Raw Trait Mapping into Fisher-KPP Loop 1 ---
def raw_target_trait_1 = 1 / (1 + Exp(-standardized_displacement));
def target_trait_1 = if IsNaN(raw_target_trait_1) then 0.5 else raw_target_trait_1;
def u1;
# Protect the history steps inside CompoundValue from swallowing early-chart NaNs
def historical_u1 = CompoundValue(1,
wave_inertia * (if IsNaN(u1[1]) then 0.5 else u1[1]) +
(1 - wave_inertia) * (if IsNaN(target_trait_1[1]) then 0.5 else target_trait_1[1]),
0.5
);
def spatial_diff_1 = (target_trait_1 - historical_u1) * diffusion_coefficient;
def logistic_react_1 = selection_advantage * standardized_displacement * historical_u1 * (1 - historical_u1);
u1 = Max(0.001, Min(0.999, historical_u1 + spatial_diff_1 + logistic_react_1));
# Generational Smooth applied to Pass 1 Output
def raw_smoothed_u1 = ExpAverage(u1, smooth_pass1_length);
def smoothed_u1 = if IsNaN(raw_smoothed_u1) then 0.5 else raw_smoothed_u1;
# --- 4. Pass 2: Secondary Fisher-KPP Loop (The Double Pass ---
def u2;
def historical_u2 = CompoundValue(1,
wave_inertia * (if IsNaN(u2[1]) then 0.5 else u2[1]) +
(1 - wave_inertia) * (if IsNaN(smoothed_u1[1]) then 0.5 else smoothed_u1[1]),
0.5
);
def spatial_diff_2 = (smoothed_u1 - historical_u2) * diffusion_coefficient;
# The velocity of the smoothed first wave drives the secondary reaction phase
def raw_velocity = smoothed_u1 - smoothed_u1[1];
def wave1_velocity = if IsNaN(raw_velocity) then 0 else raw_velocity;
def logistic_react_2 = selection_advantage * wave1_velocity * historical_u2 * (1 - historical_u2);
u2 = Max(0.001, Min(0.999, historical_u2 + spatial_diff_2 + logistic_react_2));
# Generational Smooth applied to Pass 2 Output (The Signal Path)
def raw_smoothed_u2 = ExpAverage(u2, smooth_pass2_length);
def smoothed_u2 = if IsNaN(raw_smoothed_u2) then 0.5 else raw_smoothed_u2;
# --- 5. Fisher-KPP Wave Reversals ---
# Long Trigger: u2 is low (< 0.48) and starts increasing
def raw_buy_trigger = smoothed_u2 < long_entry_zone and smoothed_u2 > smoothed_u2[1];
# Short Trigger: u2 is high (> 0.52) and starts decreasing
def raw_sell_trigger = smoothed_u2 > short_entry_zone and smoothed_u2 < smoothed_u2[1];
# --- 6. Strict Directional Trigger Integration ---
def LongSignalRaw = if !isReady then no else raw_buy_trigger;
def ShortSignalRaw = if !isReady then no else raw_sell_trigger;
# Strict alternating signal enforcement
rec lastSignalDir = CompoundValue(1, if !isReady then 0 else if LongSignalRaw then 1 else if ShortSignalRaw then -1 else lastSignalDir[1], 0);
def LongSignal = isReady and LongSignalRaw and lastSignalDir[1] != 1;
def ShortSignal = isReady and ShortSignalRaw and lastSignalDir[1] != -1;
# --- 7. Quantitative Profit Engine Core ---
def ts = if !IsNaN(TickSize()) and TickSize() > 0 then TickSize() else 0.0001;
def tv = if !IsNaN(TickValue()) and TickValue() > 0 then TickValue() else 1.0;
def pipSize = if ts == 0.00001 then 0.0001 else if ts == 0.001 then 0.01 else ts;
def pv = tv * (pipSize / ts);
# Conversions for Target Pricing
def pointsToProfit = Take_Profit_Dollars / (tv / ts);
def pointsToLoss = Stop_Loss_Dollars / (tv / ts);
# Position State Handler
rec tradeState = CompoundValue(1,
if !isReady then 0
else if tradeState[1] == 0 then (
if LongSignal[1] then open
else if ShortSignal[1] then -open
else 0
)
else if tradeState[1] > 0 then (
if ShortSignal or (high - tradeState[1]) >= pointsToProfit or (tradeState[1] - low) >= pointsToLoss then 0
else tradeState[1]
)
else (
if LongSignal or (AbsValue(tradeState[1]) - low) >= pointsToProfit or (high - AbsValue(tradeState[1])) >= pointsToLoss then 0
else tradeState[1]
), 0);
def pos = Sign(tradeState);
def longExit = isReady and tradeState[1] > 0 and tradeState == 0;
def shortExit = isReady and tradeState[1] < 0 and tradeState == 0;
def tradeClosed = longExit or shortExit;
rec entryPrice = CompoundValue(1, if !isReady then 0 else if (tradeState crosses above 0 or tradeState crosses below 0) then open else entryPrice[1], 0);
# Synchronized Exit Execution
def exitPrice = if !tradeClosed then close
else if longExit then (
if ShortSignal then close
else if (high - entryPrice[1]) >= pointsToProfit then entryPrice[1] + pointsToProfit
else entryPrice[1] - pointsToLoss
)
else (
if LongSignal then close
else if (entryPrice[1] - low) >= pointsToProfit then entryPrice[1] - pointsToProfit
else entryPrice[1] + pointsToLoss
);
# Calculated Performance
def longResult = if longExit and !IsNaN(entryPrice) and !IsNaN(exitPrice) then (exitPrice - entryPrice) / pipSize * pv - Trade_Commission else 0;
def shortResult = if shortExit and !IsNaN(entryPrice) and !IsNaN(exitPrice) then (entryPrice - exitPrice) / pipSize * pv - Trade_Commission else 0;
def currentTradeResult = longResult + shortResult;
# Performance Metrics Accumulation (Protected Against NaN Degradation)
rec totalPnl = if IsNaN(totalPnl[1]) then 0 else totalPnl[1] + currentTradeResult;
rec tradeCount = if IsNaN(tradeCount[1]) then 0 else tradeCount[1] + (if tradeClosed then 1 else 0);
rec pLong = if IsNaN(pLong[1]) then 0 else pLong[1] + longResult;
rec cLong = if IsNaN(cLong[1]) then 0 else cLong[1] + (if longExit then 1 else 0);
rec wLong = if IsNaN(wLong[1]) then 0 else wLong[1] + (if longExit and longResult > 0 then 1 else 0);
rec pShort = if IsNaN(pShort[1]) then 0 else pShort[1] + shortResult;
rec cShort = if IsNaN(cShort[1]) then 0 else cShort[1] + (if shortExit then 1 else 0);
rec wShort = if IsNaN(wShort[1]) then 0 else wShort[1] + (if shortExit and shortResult > 0 then 1 else 0);
rec mWin = if IsNaN(mWin[1]) then 0 else if currentTradeResult > mWin[1] then currentTradeResult else mWin[1];
rec mLoss = if IsNaN(mLoss[1]) then 0 else if currentTradeResult < mLoss[1] then currentTradeResult else mLoss[1];
def wrL = if cLong > 0 then (wLong / cLong) * 100 else 0;
def wrS = if cShort > 0 then (wShort / cShort) * 100 else 0;
def isPips = Display_Mode == Display_Mode."Pips";
# --- 8. Chart Dashboard & Labels ---
AddLabel(yes, "Trades: " + tradeCount + " | Net: " + (if isPips then Round(totalPnl,1) + " P" else AsDollars(totalPnl)),
if totalPnl >= 0 then Color.GREEN else Color.RED, Location.BOTTOM_LEFT);
AddLabel(cLong > 0, "Long Win: " + Round(wrL,1) + "%", Color.CYAN, Location.BOTTOM_LEFT);
AddLabel(cShort > 0, "Short Win: " + Round(wrS,1) + "%", Color.ORANGE, Location.BOTTOM_LEFT);
AddLabel(yes, "L Profit: " + (if isPips then Round(pLong,1)+" P" else AsDollars(pLong)), Color.CYAN, Location.BOTTOM_LEFT);
AddLabel(yes, "S Profit: " + (if isPips then Round(pShort,1)+" P" else AsDollars(pShort)), Color.ORANGE, Location.BOTTOM_LEFT);
AddLabel(tradeCount > 0, "Max Win: " + (if isPips then Round(mWin,1)+" P" else AsDollars(mWin)) +
" | Max Loss: " + (if isPips then Round(mLoss,1)+" P" else AsDollars(mLoss)), Color.GRAY, Location.BOTTOM_LEFT);
AddLabel(LastSignalDir == 1 and pos != 0, " . . KPP UP ", Color.LIGHT_GREEN);
AddLabel(LastSignalDir == -1 and pos != 0, " . . KPP DOWN ", Color.LIGHT_RED);
AddLabel(pos == 0, " . . KPP FLAT ", Color.LIGHT_ORANGE);
# --- 9. Execution Signals & Price Chart Visuals ---
plot UpArrow = if LongSignal then low - (ts*10) else Double.NaN;
UpArrow.SetPaintingStrategy(PaintingStrategy.ARROW_UP);
UpArrow.SetDefaultColor(Color.CYAN);
UpArrow.SetLineWeight(3);
plot DownArrow = if ShortSignal then high + (ts*10) else Double.NaN;
DownArrow.SetPaintingStrategy(PaintingStrategy.ARROW_DOWN);
DownArrow.SetDefaultColor(Color.ORANGE);
DownArrow.SetLineWeight(3);
# Dynamics Price Bars based on the Backtest Engine Position State
AssignPriceColor(if pos == 1 then Color.CYAN else if pos == -1 then Color.ORANGE else Color.CURRENT);
# --- 10. Transaction Bubbles ---
def showBubble = tradeClosed and !IsNaN(currentTradeResult);
AddChartBubble(ShowDebugBubbles and showBubble, high,
"Type: " + (if longExit then "LONG" else "SHORT") +
"\nIn: " + Round(entryPrice, 5) +
"\nOut: " + Round(exitPrice, 5) +
"\nResult: " + (if isPips then Round(currentTradeResult,1) + "P" else AsDollars(currentTradeResult)),
Color.GRAY, no);
AddChartBubble(ShowProfitBubbles and showBubble, low,
(if isPips then Round(currentTradeResult,1) + " P" else AsDollars(currentTradeResult)),
if currentTradeResult >= 0 then Color.GREEN else Color.RED, yes);