🔁 Node-RED Backtest Flow

NVDA EMA Z-Score
Node-RED Backtest

Import this Node-RED flow into your MachineTrader™ instance to backtest the NVDA EMA z-score mean-reversion strategy using historical 1-minute bars from Alpaca's IEX feed.

Overview

This Node-RED flow implements the complete NVDA EMA z-score backtest entirely within MachineTrader's visual flow editor — no Python required. It fetches historical 1-minute bars from Alpaca, runs the full EMA engine simulation in JavaScript function nodes, and outputs detailed results including daily P&L, trade logs, and buy-and-hold comparison.

The flow uses paginated API requests to retrieve all 1-minute bars from January 2, 2026 to the present, then processes them through the identical two-speed EWMA engine used by the live trader — fast EMA (λ=0.75) for the signal, slow variance (λ=0.95) for the z-score denominator.

Strategy Parameters

These parameters are set in Step 1 and match the live EMA NVDA trader exactly:

Symbol
NVDA
EMA Lambda (λ)
0.75
Variance Lambda (λvar)
0.95
Z-Score Threshold
±2.0
Trade Quantity
25 shares
Long Limit
400 shares
Short Limit
-400 shares
Account Balance
$125,000
Var Price (seed)
0.001
Var Cap
100
Var Floor
0.05
Z Factor
1

Backtest Assumptions

  • Backtest start: January 2, 2026 at 9:30 AM ET (first trading day of 2026)
  • Backtest end: Present (current date/time when flow is run)
  • Data source: Alpaca Data API v2, IEX feed, 1-minute bars with split adjustment
  • Data retrieval: Paginated — fetches up to 10,000 bars per page with 250ms delay between pages
  • Price used: VWAP of each 1-minute bar
  • Trade execution: Simulated fills at VWAP (no actual orders placed)
  • Position limits: Max +400 shares long, −400 shares short
  • Market hours filter: 9:30 AM – 4:00 PM ET, weekdays only, excluding 2026 NYSE holidays
  • Starting balance: $125,000
  • Commissions/slippage: Not modeled ($0 commissions via Alpaca)

How the Flow Works

The flow has 3 sequential steps. Click each inject node in order:

1

Initialize Strategy Parameters & Backtest State

Sets all strategy parameters (lambdaf, lambdaVar, zScoreThresh, tradeqty, etc.) and resets all backtest state variables (bt_ema, bt_variance, bt_position, bt_trades, bt_dailyPnl) to their initial values. Also stores the 2026 NYSE holiday calendar for market-hours filtering.

inject Initialize backtest params
2

Fetch Historical Bars & Run Simulation

Builds a paginated Alpaca Data API request for 1-minute bars starting from 2026-01-02T14:30:00Z. Each page returns up to 10,000 bars. The accumulator node collects bars and loops back through a 250ms delay for the next page. Once all bars are fetched (no more next_page_token), the complete dataset is sent to the simulation node which runs the full EMA z-score engine over every bar — computing signals, filling simulated trades, tracking position/cost basis, and building daily P&L records.

inject Build first bars request Alpaca Historical Bars Accumulate bars + paginate
↳ Output 1 (more pages): 250ms delay loops back to HTTP request
↳ Output 2 (all done): Run EMA simulation on all bars Backtest Summary
3

Display Detailed Backtest Report

Reads the simulation results stored in flow context and outputs a formatted report to the debug sidebar: daily P&L with 📈/📉 emojis, the last 20 trades with side/price/z-score/position, and a full summary box with total P&L, ending balance, buy-and-hold comparison, and win/loss day counts. Results are also stored in global context variables for use by dashboards or other flows.

inject Display detailed backtest report Full Report

Flow Architecture

Visual overview of all nodes and their connections:

Step 1: Initialize
⏱ Run once
ƒ Initialize backtest params
Step 2: Fetch & Simulate
⏱ Run once
ƒ Build first bars request
🌐 Alpaca Historical Bars
ƒ Accumulate + paginate
↳ more pages
⏳ 250ms delay
back to HTTP request
↳ all fetched
ƒ Run EMA simulation
🐛 Backtest Summary
Step 3: Report
⏱ Run once
ƒ Display detailed report
🐛 Full Report

Key Node Details

🧮 EMA Engine (Two-Speed EWMA)

The simulation node implements a two-speed Exponential Weighted Moving Average: a fast EMA (λ=0.75) tracks price, while a slow variance estimator (λvar=0.95, ~20-bar lookback) measures volatility. The z-score = (price − EMA) / √variance. This allows z-scores to spike on sudden moves before variance catches up.

📦 Paginated Data Fetch

Alpaca returns at most 10,000 bars per request. The accumulator node checks for a next_page_token in the response and loops back (with a 250ms rate-limit delay) to fetch the next page. This ensures the full backtest period is covered regardless of how many bars exist.

💱 Cost Basis Tracking

The simulation tracks average cost basis and handles all transitions: adding to longs, adding to shorts, closing longs with sells, closing shorts with buys, and flipping from long to short (or vice versa). Realized P&L is computed at each fill.

📊 Comprehensive Output

Results include: total/realized/unrealized P&L, win/loss days, bars processed, trading days, buy-and-hold comparison, daily open/close/high/low, per-day trade counts, position at EOD, and last 200 individual trades with timestamps, z-scores, and running P&L.

🕐 Market Hours Filter

Each bar's timestamp is converted to Eastern Time and filtered to 9:30 AM – 4:00 PM on weekdays only. The 2026 NYSE holiday calendar (New Year's, MLK Day, Presidents' Day, Good Friday, Memorial Day, July 4th, Labor Day, Thanksgiving, Christmas) is respected.

🌐 Global Context Storage

Results are stored in global.backtestEmaNvdaResults, global.backtestEmaNvdaDaily, and global.backtestEmaNvdaTrades for easy access by Node-RED dashboards, other flows, or HTTP endpoints.

Prerequisites

  • Alpaca API keys: Set global.apiKeyLive and global.apiSecretLive in your Node-RED global context (or use a flow that injects them on startup). These are used for the HTTP request node headers.
  • Alpaca account: Paper or live account with data access. The flow uses data.alpaca.markets for historical bars (available on all Alpaca plans).
  • MachineTrader: Any MachineTrader instance running Node-RED v3+.
  • No extra nodes required: The flow only uses built-in Node-RED nodes (inject, function, http request, delay, debug, comment). No custom palette nodes needed.

How to Import This Flow

  1. Copy the JSON code from the box below by clicking the "Copy to Clipboard" button.
  2. Open your MachineTrader Node-RED editor.
  3. Click the hamburger menu (☰) in the top-right corner.
  4. Select Import from the dropdown menu.
  5. Paste the JSON code into the import dialog.
  6. Click Import to add the flow to your workspace.
  7. Set your Alpaca API keys in global context: global.set("apiKeyLive", "YOUR_KEY") and global.set("apiSecretLive", "YOUR_SECRET").
  8. Click Deploy, then click the inject nodes in order: Step 1 → Step 2 → Step 3.

⚠️ Important: This flow does NOT place any live orders — it is a simulation only. All trades are computed in-memory against historical data. However, it does make API calls to Alpaca's data endpoint to fetch bars. Ensure your API keys have data access permissions.

⏱ Note: Step 2 may take 30–60 seconds to complete depending on how many trading days are in the backtest window. Watch the debug sidebar for page-by-page progress updates and the final simulation summary.

Node-RED Flow JSON

Click the button to copy the complete flow configuration to your clipboard:

Backtest EMA NVDA.json
[
    {
        "id": "b2c3d4e5f6a70001",
        "type": "tab",
        "label": "Backtest EMA NVDA Z-Score",
        "disabled": false,
        "info": "Backtests the NVDA EMA z-score mean-reversion strategy using historical 1-min bars from Alpaca (IEX feed).\nClick inject nodes in order: Step 1 \u2192 Step 2 \u2192 Step 3.",
        "env": []
    },
    {
        "id": "b2c3d4e5f6a70002",
        "type": "comment",
        "z": "b2c3d4e5f6a70001",
        "name": "Backtest: EMA z-score strategy on NVDA starting 1/2/2026. Click Inject nodes in order 1 \u2192 2 \u2192 3.",
        "info": "",
        "x": 350,
        "y": 40,
        "wires": []
    },
    {
        "id": "b2c3d4e5f6a70003",
        "type": "comment",
        "z": "b2c3d4e5f6a70001",
        "name": "Step 1: Initialize strategy parameters & backtest state",
        "info": "",
        "x": 260,
        "y": 100,
        "wires": []
    },
    {
        "id": "b2c3d4e5f6a70004",
        "type": "inject",
        "z": "b2c3d4e5f6a70001",
        "name": "Run once",
        "props": [],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "x": 120,
        "y": 140,
        "wires": [
            [
                "b2c3d4e5f6a70005"
            ]
        ]
    },
    {
        "id": "b2c3d4e5f6a70005",
        "type": "function",
        "z": "b2c3d4e5f6a70001",
        "name": "Initialize backtest params",
        "func": "// \u2500\u2500 Strategy parameters (must match live trader) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nflow.set(\"symbol\",       \"NVDA\");\nflow.set(\"lambdaf\",      0.75);\nflow.set(\"lambdaVar\",    0.95);\nflow.set(\"limitLong\",    400);\nflow.set(\"limitShort\",  -400);\nflow.set(\"tradeqty\",     25);\nflow.set(\"balance\",      125000);\nflow.set(\"varPrice\",     0.001);\nflow.set(\"varCap\",       100);\nflow.set(\"varFloor\",     0.05);\nflow.set(\"zFact\",        1);\nflow.set(\"zScoreThresh\", 2.0);\n\n// \u2500\u2500 Backtest config \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n// Backtest start: 1/2/2026 9:30 AM ET = 14:30 UTC\nflow.set(\"backtestStartRFC\", \"2026-01-02T14:30:00Z\");\n\n// \u2500\u2500 State (reset) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nflow.set(\"bt_ema\",        null);\nflow.set(\"bt_variance\",   0);\nflow.set(\"bt_position\",   0);\nflow.set(\"bt_avgCost\",    0);\nflow.set(\"bt_realizedPnl\",0);\nflow.set(\"bt_trades\",     []);\nflow.set(\"bt_dailyPnl\",   {});\nflow.set(\"bt_barCount\",   0);\nflow.set(\"bt_buyCount\",   0);\nflow.set(\"bt_sellCount\",  0);\nflow.set(\"bt_firstPrice\", null);\nflow.set(\"bt_lastPrice\",  null);\nflow.set(\"bt_allBars\",    []);\n\nflow.set(\"holidays2026\", [\n    \"2026-01-01\",\"2026-01-19\",\"2026-02-16\",\"2026-04-03\",\n    \"2026-05-25\",\"2026-07-03\",\"2026-09-07\",\"2026-11-26\",\"2026-12-25\"\n]);\n\nnode.warn(\"\u2705 Backtest initialized \u2014 NVDA EMA z-score | \u03bb=0.75 | \u03bb_var=0.95 | Z-thresh=2.0 | Start: 1/2/2026\");\nreturn msg;\n",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 370,
        "y": 140,
        "wires": [
            []
        ]
    },
    {
        "id": "b2c3d4e5f6a70006",
        "type": "comment",
        "z": "b2c3d4e5f6a70001",
        "name": "Step 2: Fetch all historical 1-min bars (paginated) and run simulation",
        "info": "",
        "x": 310,
        "y": 220,
        "wires": []
    },
    {
        "id": "b2c3d4e5f6a70007",
        "type": "inject",
        "z": "b2c3d4e5f6a70001",
        "name": "Run once",
        "props": [],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "x": 120,
        "y": 260,
        "wires": [
            [
                "b2c3d4e5f6a70008"
            ]
        ]
    },
    {
        "id": "b2c3d4e5f6a70008",
        "type": "function",
        "z": "b2c3d4e5f6a70001",
        "name": "Build first bars request",
        "func": "// Build paginated 1-min bars request for NVDA\nvar symbol = flow.get(\"symbol\") || \"NVDA\";\nvar startRFC = flow.get(\"backtestStartRFC\");\n\n// End = now\nvar now = new Date();\nvar endRFC = now.toISOString();\n\nflow.set(\"bt_endRFC\", endRFC);\nflow.set(\"bt_allBars\", []);\nflow.set(\"bt_pageCount\", 0);\n\nvar params = [\n    \"timeframe=1Min\",\n    \"start=\" + encodeURIComponent(startRFC),\n    \"end=\" + encodeURIComponent(endRFC),\n    \"feed=iex\",\n    \"adjustment=split\",\n    \"limit=10000\"\n].join(\"&\");\n\nmsg.url = \"https://data.alpaca.markets/v2/stocks/\" + symbol + \"/bars?\" + params;\nmsg.method = \"GET\";\nmsg.apiKeyId = global.get(\"apiKeyLive\") || \"\";\nmsg.apiSecretKey = global.get(\"apiSecretLive\") || \"\";\n\nnode.warn(\"\ud83d\udcca Fetching 1-min bars for \" + symbol + \" from \" + startRFC + \" ...\");\nreturn msg;\n",
        "outputs": 1,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 340,
        "y": 260,
        "wires": [
            [
                "b2c3d4e5f6a70009"
            ]
        ]
    },
    {
        "id": "b2c3d4e5f6a70009",
        "type": "http request",
        "z": "b2c3d4e5f6a70001",
        "name": "Alpaca Historical Bars",
        "method": "use",
        "ret": "obj",
        "paytoqs": "ignore",
        "url": "",
        "tls": "",
        "persist": false,
        "proxy": "",
        "insecureHTTPParser": false,
        "authType": "",
        "senderr": false,
        "headers": [
            {
                "keyType": "other",
                "keyValue": "APCA-API-KEY-ID",
                "valueType": "msg",
                "valueValue": "apiKeyId"
            },
            {
                "keyType": "other",
                "keyValue": "APCA-API-SECRET-KEY",
                "valueType": "msg",
                "valueValue": "apiSecretKey"
            },
            {
                "keyType": "other",
                "keyValue": "accept",
                "valueType": "other",
                "valueValue": "application/json"
            }
        ],
        "x": 580,
        "y": 260,
        "wires": [
            [
                "b2c3d4e5f6a7000a"
            ]
        ]
    },
    {
        "id": "b2c3d4e5f6a7000a",
        "type": "function",
        "z": "b2c3d4e5f6a70001",
        "name": "Accumulate bars + paginate",
        "func": "// Accumulate fetched bars and handle pagination\nvar bars = (msg.payload && msg.payload.bars) || [];\nvar nextPageToken = msg.payload ? msg.payload.next_page_token : null;\nvar allBars = flow.get(\"bt_allBars\") || [];\nvar pageCount = (flow.get(\"bt_pageCount\") || 0) + 1;\n\nallBars = allBars.concat(bars);\nflow.set(\"bt_allBars\", allBars);\nflow.set(\"bt_pageCount\", pageCount);\n\nnode.warn(\"  Page \" + pageCount + \": fetched \" + bars.length + \" bars (total: \" + allBars.length + \")\");\n\nif (nextPageToken) {\n    // More pages \u2014 build next request\n    var symbol = flow.get(\"symbol\") || \"NVDA\";\n    var startRFC = flow.get(\"backtestStartRFC\");\n    var endRFC = flow.get(\"bt_endRFC\");\n\n    var params = [\n        \"timeframe=1Min\",\n        \"start=\" + encodeURIComponent(startRFC),\n        \"end=\" + encodeURIComponent(endRFC),\n        \"feed=iex\",\n        \"adjustment=split\",\n        \"limit=10000\",\n        \"page_token=\" + nextPageToken\n    ].join(\"&\");\n\n    msg.url = \"https://data.alpaca.markets/v2/stocks/\" + symbol + \"/bars?\" + params;\n    msg.method = \"GET\";\n    msg.apiKeyId = global.get(\"apiKeyLive\") || \"\";\n    msg.apiSecretKey = global.get(\"apiSecretLive\") || \"\";\n\n    // Send to output 1 (loop back to http request)\n    return [msg, null];\n} else {\n    // Done fetching \u2014 send to output 2 (simulation)\n    node.warn(\"\u2705 All bars fetched: \" + allBars.length + \" total across \" + pageCount + \" pages\");\n    msg.payload = allBars;\n    return [null, msg];\n}\n",
        "outputs": 2,
        "timeout": "",
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 840,
        "y": 260,
        "wires": [
            [
                "b2c3d4e5f6a7000b"
            ],
            [
                "b2c3d4e5f6a7000c"
            ]
        ]
    },
    {
        "id": "b2c3d4e5f6a7000b",
        "type": "delay",
        "z": "b2c3d4e5f6a70001",
        "name": "250ms delay",
        "pauseType": "delay",
        "timeout": "250",
        "timeoutUnits": "milliseconds",
        "rate": "1",
        "nbRateUnits": "1",
        "rateUnits": "second",
        "randomFirst": "1",
        "randomLast": "5",
        "randomUnits": "seconds",
        "drop": false,
        "allowrate": false,
        "outputs": 1,
        "x": 580,
        "y": 320,
        "wires": [
            [
                "b2c3d4e5f6a70009"
            ]
        ]
    },
    {
        "id": "b2c3d4e5f6a7000c",
        "type": "function",
        "z": "b2c3d4e5f6a70001",
        "name": "Run EMA simulation on all bars",
        "func": "// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//  Run the full EMA z-score backtest simulation over all bars.\n//  Identical logic to the live trader EMA engine.\n// \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nvar bars = msg.payload || [];\nif (bars.length === 0) {\n    node.warn(\"\u274c No bars to simulate.\");\n    return null;\n}\n\n// Load parameters\nvar lam      = flow.get(\"lambdaf\")      || 0.75;\nvar lamVar   = flow.get(\"lambdaVar\")    || 0.95;\nvar varFloor = flow.get(\"varFloor\")     || 0.05;\nvar varCap   = flow.get(\"varCap\")       || 100;\nvar varPrice = flow.get(\"varPrice\")     || 0.001;\nvar zFact    = flow.get(\"zFact\")        || 1;\nvar thresh   = flow.get(\"zScoreThresh\") || 2.0;\nvar tradeQty = flow.get(\"tradeqty\")     || 25;\nvar limLong  = flow.get(\"limitLong\")    || 400;\nvar limShort = flow.get(\"limitShort\")   || -400;\nvar balance  = flow.get(\"balance\")      || 125000;\nvar holidays = flow.get(\"holidays2026\") || [];\n\n// Engine state\nvar ema = null;\nvar variance = 0;\n\n// Position state\nvar position = 0;\nvar avgCost = 0;\nvar realizedPnl = 0;\n\n// Counters\nvar barCount = 0;\nvar buyCount = 0;\nvar sellCount = 0;\nvar firstPrice = null;\nvar lastPrice = null;\n\n// Results\nvar trades = [];\nvar dailyPnl = {};\n\n// Helper: check trading day\nfunction isTradingDay(dateStr) {\n    var d = new Date(dateStr);\n    var day = d.getDay();\n    if (day === 0 || day === 6) return false;\n    // Check holidays by formatted date\n    var yyyy = d.getFullYear();\n    var mm = String(d.getMonth() + 1).padStart(2, \"0\");\n    var dd2 = String(d.getDate()).padStart(2, \"0\");\n    var ds = yyyy + \"-\" + mm + \"-\" + dd2;\n    return holidays.indexOf(ds) < 0;\n}\n\nnode.warn(\"Running simulation on \" + bars.length + \" bars...\");\n\nfor (var i = 0; i < bars.length; i++) {\n    var bar = bars[i];\n    var ts = bar.t || \"\";\n\n    // Parse timestamp (Alpaca returns ISO 8601)\n    var barDate = new Date(ts);\n    if (isNaN(barDate.getTime())) continue;\n\n    // Convert to ET\n    var etStr = barDate.toLocaleString(\"en-US\", {timeZone: \"America/New_York\"});\n    var et = new Date(etStr);\n    var h = et.getHours();\n    var m = et.getMinutes();\n    var mins = h * 60 + m;\n\n    // Only 9:30 \u2013 16:00 ET\n    if (mins < 570 || mins >= 960) continue;\n\n    // Trading day check\n    var yyyy = et.getFullYear();\n    var mm = String(et.getMonth() + 1).padStart(2, \"0\");\n    var dd2 = String(et.getDate()).padStart(2, \"0\");\n    var dateKey = yyyy + \"-\" + mm + \"-\" + dd2;\n    if (holidays.indexOf(dateKey) >= 0) continue;\n    if (et.getDay() === 0 || et.getDay() === 6) continue;\n\n    // Use VWAP as price\n    var price = bar.vw || bar.c || 0;\n    if (price <= 0) continue;\n\n    barCount++;\n    if (firstPrice === null) firstPrice = price;\n    lastPrice = price;\n\n    // --- EMA Engine ---\n    var zScore = 0;\n    if (ema === null) {\n        ema = price;\n        variance = varPrice;\n    } else {\n        ema = lam * ema + (1 - lam) * price;\n        var diff = price - ema;\n        var rawVar = lamVar * variance + (1 - lamVar) * (diff * diff);\n        variance = Math.max(varFloor, Math.min(rawVar, varCap));\n        var std = Math.sqrt(variance);\n        zScore = zFact * diff / (std || 1e-9);\n    }\n\n    // --- Signal ---\n    var side = null;\n    var qty = 0;\n    if (zScore < -thresh) {\n        var maxBuy = limLong - position;\n        if (maxBuy > 0) {\n            qty = Math.min(tradeQty, maxBuy);\n            side = \"buy\";\n        }\n    } else if (zScore > thresh) {\n        var maxSell = position - limShort;\n        if (maxSell > 0) {\n            qty = Math.min(tradeQty, maxSell);\n            side = \"sell\";\n        }\n    }\n\n    // --- Fill ---\n    if (side) {\n        if (side === \"buy\") {\n            if (position >= 0) {\n                var tc = avgCost * position + price * qty;\n                position += qty;\n                avgCost = position > 0 ? tc / position : 0;\n            } else {\n                var cov = Math.min(qty, Math.abs(position));\n                realizedPnl += cov * (avgCost - price);\n                position += qty;\n                if (position > 0) avgCost = price;\n                else if (position === 0) avgCost = 0;\n            }\n            buyCount++;\n        } else {\n            if (position <= 0) {\n                var tcs = avgCost * Math.abs(position) + price * qty;\n                position -= qty;\n                avgCost = position !== 0 ? tcs / Math.abs(position) : 0;\n            } else {\n                var cl = Math.min(qty, position);\n                realizedPnl += cl * (price - avgCost);\n                position -= qty;\n                if (position < 0) avgCost = price;\n                else if (position === 0) avgCost = 0;\n            }\n            sellCount++;\n        }\n        trades.push({\n            side: side, qty: qty,\n            price: Number(price.toFixed(2)),\n            position: position,\n            realizedPnl: Number(realizedPnl.toFixed(2)),\n            zScore: Number(zScore.toFixed(4)),\n            timestamp: ts\n        });\n    }\n\n    // --- Unrealized P&L ---\n    var unrealized = 0;\n    if (position > 0) {\n        unrealized = position * (price - avgCost);\n    } else if (position < 0) {\n        unrealized = Math.abs(position) * (avgCost - price);\n    }\n    var totalPnl = realizedPnl + unrealized;\n\n    // --- Daily tracking ---\n    if (!dailyPnl[dateKey]) {\n        dailyPnl[dateKey] = {\n            date: dateKey,\n            open_price: Number(price.toFixed(2)),\n            close_price: Number(price.toFixed(2)),\n            high_price: Number(price.toFixed(2)),\n            low_price: Number(price.toFixed(2)),\n            trades: 0, buys: 0, sells: 0,\n            end_position: 0, realized_pnl: 0, total_pnl: 0\n        };\n    }\n    var day = dailyPnl[dateKey];\n    day.close_price = Number(price.toFixed(2));\n    if (price > day.high_price) day.high_price = Number(price.toFixed(2));\n    if (price < day.low_price) day.low_price = Number(price.toFixed(2));\n    if (side) {\n        day.trades++;\n        if (side === \"buy\") day.buys++;\n        else day.sells++;\n    }\n    day.end_position = position;\n    day.realized_pnl = Number(realizedPnl.toFixed(2));\n    day.total_pnl = Number(totalPnl.toFixed(2));\n}\n\n// --- Final mark-to-market ---\nvar finalUnrealized = 0;\nif (lastPrice && position > 0) {\n    finalUnrealized = position * (lastPrice - avgCost);\n} else if (lastPrice && position < 0) {\n    finalUnrealized = Math.abs(position) * (avgCost - lastPrice);\n}\nvar finalTotalPnl = realizedPnl + finalUnrealized;\nvar finalPct = (finalTotalPnl / balance * 100);\n\n// Buy & hold\nvar bhPnl = 0;\nvar bhPct = 0;\nif (firstPrice && lastPrice) {\n    var bhShares = balance / firstPrice;\n    bhPnl = bhShares * (lastPrice - firstPrice);\n    bhPct = (lastPrice - firstPrice) / firstPrice * 100;\n}\n\n// Daily results sorted\nvar dailyResults = Object.values(dailyPnl).sort(function(a,b) {\n    return a.date.localeCompare(b.date);\n});\n\n// Win/loss days\nvar winDays = 0, lossDays = 0, prevPnl = 0;\ndailyResults.forEach(function(d) {\n    var delta = d.total_pnl - prevPnl;\n    if (delta > 0) winDays++;\n    else if (delta < 0) lossDays++;\n    prevPnl = d.total_pnl;\n});\n\nvar totalTrades = buyCount + sellCount;\n\n// Store in flow\nflow.set(\"bt_results\", {\n    barsProcessed: barCount,\n    tradingDays: dailyResults.length,\n    totalTrades: totalTrades,\n    buys: buyCount,\n    sells: sellCount,\n    finalPosition: position,\n    firstPrice: firstPrice ? Number(firstPrice.toFixed(2)) : null,\n    lastPrice: lastPrice ? Number(lastPrice.toFixed(2)) : null,\n    realizedPnl: Number(realizedPnl.toFixed(2)),\n    unrealizedPnl: Number(finalUnrealized.toFixed(2)),\n    totalPnl: Number(finalTotalPnl.toFixed(2)),\n    totalPnlPct: Number(finalPct.toFixed(2)),\n    endingBalance: Number((balance + finalTotalPnl).toFixed(2)),\n    buyHoldPnl: Number(bhPnl.toFixed(2)),\n    buyHoldPct: Number(bhPct.toFixed(2)),\n    winDays: winDays,\n    lossDays: lossDays\n});\nflow.set(\"bt_dailyResults\", dailyResults);\nflow.set(\"bt_tradeLog\", trades.slice(-200));\n\nnode.warn(\"\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\");\nnode.warn(\"  NVDA EMA Z-SCORE BACKTEST COMPLETE\");\nnode.warn(\"\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\");\nnode.warn(\"  Bars: \" + barCount + \" | Days: \" + dailyResults.length);\nnode.warn(\"  Trades: \" + totalTrades + \" (\" + buyCount + \" buys, \" + sellCount + \" sells)\");\nnode.warn(\"  Position: \" + position + \" shares\");\nnode.warn(\"  First: $\" + (firstPrice||0).toFixed(2) + \" | Last: $\" + (lastPrice||0).toFixed(2));\nvar sign = finalTotalPnl >= 0 ? \"+\" : \"\";\nnode.warn(\"  Realized P&L:   \" + sign + \"$\" + realizedPnl.toFixed(2));\nnode.warn(\"  Unrealized P&L: \" + (finalUnrealized >= 0 ? \"+\" : \"\") + \"$\" + finalUnrealized.toFixed(2));\nnode.warn(\"  Total P&L:      \" + sign + \"$\" + finalTotalPnl.toFixed(2) + \" (\" + sign + finalPct.toFixed(2) + \"%)\");\nnode.warn(\"  Ending Balance: $\" + (balance + finalTotalPnl).toFixed(2));\nnode.warn(\"  Buy & Hold:     \" + (bhPnl >= 0 ? \"+\" : \"\") + \"$\" + bhPnl.toFixed(2) + \" (\" + (bhPct >= 0 ? \"+\" : \"\") + bhPct.toFixed(2) + \"%)\");\nnode.warn(\"  Win/Loss Days:  \" + winDays + \" / \" + lossDays);\nnode.warn(\"\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\");\n\nmsg.payload = flow.get(\"bt_results\");\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 190,
        "y": 420,
        "wires": [
            [
                "b2c3d4e5f6a7000d"
            ]
        ]
    },
    {
        "id": "b2c3d4e5f6a7000d",
        "type": "debug",
        "z": "b2c3d4e5f6a70001",
        "name": "Backtest Summary",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 430,
        "y": 420,
        "wires": []
    },
    {
        "id": "b2c3d4e5f6a7000e",
        "type": "comment",
        "z": "b2c3d4e5f6a70001",
        "name": "Step 3: Display detailed results (daily P&L, trade log)",
        "info": "",
        "x": 270,
        "y": 500,
        "wires": []
    },
    {
        "id": "b2c3d4e5f6a7000f",
        "type": "inject",
        "z": "b2c3d4e5f6a70001",
        "name": "Run once",
        "props": [],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "x": 120,
        "y": 540,
        "wires": [
            [
                "b2c3d4e5f6a70010"
            ]
        ]
    },
    {
        "id": "b2c3d4e5f6a70010",
        "type": "function",
        "z": "b2c3d4e5f6a70001",
        "name": "Display detailed backtest report",
        "func": "var results = flow.get(\"bt_results\");\nvar daily = flow.get(\"bt_dailyResults\") || [];\nvar trades = flow.get(\"bt_tradeLog\") || [];\nvar balance = flow.get(\"balance\") || 125000;\n\nif (!results) {\n    node.warn(\"\u274c No backtest results. Run Steps 1 and 2 first.\");\n    return null;\n}\n\n// \u2500\u2500 Summary \u2500\u2500\nnode.warn(\"\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\");\nnode.warn(\"\u2551     NVDA EMA Z-SCORE BACKTEST \u2014 FULL REPORT      \u2551\");\nnode.warn(\"\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563\");\nnode.warn(\"\u2551  Balance:      $\" + balance.toLocaleString());\nnode.warn(\"\u2551  Bars:         \" + results.barsProcessed.toLocaleString());\nnode.warn(\"\u2551  Trading Days: \" + results.tradingDays);\nnode.warn(\"\u2551  Total Trades: \" + results.totalTrades + \" (\" + results.buys + \"B / \" + results.sells + \"S)\");\nnode.warn(\"\u2551  Final Pos:    \" + results.finalPosition + \" shares\");\nvar s = results.totalPnl >= 0 ? \"+\" : \"\";\nnode.warn(\"\u2551  Total P&L:    \" + s + \"$\" + results.totalPnl.toFixed(2) + \" (\" + s + results.totalPnlPct.toFixed(2) + \"%)\");\nnode.warn(\"\u2551  End Balance:  $\" + results.endingBalance.toFixed(2));\nnode.warn(\"\u2551  Win/Loss:     \" + results.winDays + \"W / \" + results.lossDays + \"L\");\nnode.warn(\"\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\");\n\n// \u2500\u2500 Daily P&L \u2500\u2500\nnode.warn(\"\");\nnode.warn(\"\u2500\u2500 Daily P&L \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\");\ndaily.forEach(function(d) {\n    var sign = d.total_pnl >= 0 ? \"+\" : \"\";\n    var emoji = d.total_pnl >= 0 ? \"\ud83d\udcc8\" : \"\ud83d\udcc9\";\n    node.warn(emoji + \" \" + d.date + \" | O:$\" + d.open_price.toFixed(2) + \" C:$\" + d.close_price.toFixed(2) + \" | Trades: \" + d.trades + \" | Pos: \" + d.end_position + \" | P&L: \" + sign + \"$\" + d.total_pnl.toFixed(2));\n});\n\n// \u2500\u2500 Last 20 trades \u2500\u2500\nnode.warn(\"\");\nnode.warn(\"\u2500\u2500 Recent Trades (last 20) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\");\nvar recent = trades.slice(-20);\nrecent.forEach(function(t) {\n    var ts = t.timestamp ? t.timestamp.substring(0, 19) : \"\";\n    var emoji = t.side === \"buy\" ? \"\ud83d\udfe2\" : \"\ud83d\udd34\";\n    node.warn(emoji + \" \" + ts + \" | \" + t.side.toUpperCase() + \" \" + t.qty + \" @ $\" + t.price.toFixed(2) + \" | z=\" + t.zScore.toFixed(3) + \" | Pos: \" + t.position + \" | Realized: $\" + t.realizedPnl.toFixed(2));\n});\n\n// \u2500\u2500 Store in global \u2500\u2500\nglobal.set(\"backtestEmaNvdaResults\", results);\nglobal.set(\"backtestEmaNvdaDaily\", daily);\nglobal.set(\"backtestEmaNvdaTrades\", trades);\n\nmsg.payload = {\n    summary: results,\n    dailyResults: daily,\n    trades: recent\n};\nreturn msg;\n",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 380,
        "y": 540,
        "wires": [
            [
                "b2c3d4e5f6a70011"
            ]
        ]
    },
    {
        "id": "b2c3d4e5f6a70011",
        "type": "debug",
        "z": "b2c3d4e5f6a70001",
        "name": "Full Report",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "payload",
        "targetType": "msg",
        "statusVal": "",
        "statusType": "auto",
        "x": 610,
        "y": 540,
        "wires": []
    },
    {
        "id": "b2c3d4e5f6a70012",
        "type": "comment",
        "z": "b2c3d4e5f6a70001",
        "name": "Note: Set global.apiKeyLive & global.apiSecretLive for historical data requests. Simulation output \u2192 2nd wire of accumulator node.",
        "info": "",
        "x": 430,
        "y": 620,
        "wires": []
    },
    {
        "id": "b2c3d4e5f6a70013",
        "type": "comment",
        "z": "b2c3d4e5f6a70001",
        "name": "\u2193 Simulation runs here after all bars are fetched (output 2 of accumulator)",
        "info": "",
        "x": 310,
        "y": 380,
        "wires": []
    },
    {
        "id": "1dfe729b4b3149f6",
        "type": "alpaca-account",
        "name": "Paper",
        "keyId": "USE-OAUTH-OR-REPLACE",
        "paper": true
    }
]

Customization Options

Edit the "Initialize backtest params" function node in Step 1 to customize:

🎯 Change Symbol

Update flow.set("symbol", "NVDA") to any Alpaca-supported ticker to backtest the same EMA strategy on a different stock.

⚙️ Tune EMA Sensitivity

Adjust lambdaf (0→1): lower values make the EMA more responsive. Adjust lambdaVar to control how quickly variance adapts (higher = slower adaptation = more signal spikes).

📏 Adjust Z-Score Threshold

Raise zScoreThresh for fewer but higher-conviction trades. Lower it for more frequent signals. The default of 2.0 corresponds to ~2 standard deviations.

📅 Change Backtest Period

Modify backtestStartRFC to an earlier or later date. Use RFC 3339 format in UTC, e.g., "2025-06-01T13:30:00Z" for June 1, 2025 at 9:30 AM ET.

📊 Position Sizing

Change tradeqty (shares per trade), limitLong, and limitShort to control trade size and maximum exposure in either direction.

🔗 Connect to Dashboard

Add a ui_template node after Step 3 to render results on a Node-RED Dashboard. The global context vars provide all the data you need for charts and tables.

Ready to Run This Backtest?

Import the flow into your MachineTrader™ instance and backtest the NVDA EMA z-score strategy in minutes.