Graph Theory: Heuristic Search Algorithm (A-Star) Applied in Trading
Table of Contents
Introduction
On live markets, price prints dozens of local highs and lows, and it quickly becomes unclear which of these are meaningful structure and which are noise. Traders often sense a "direction" but cannot convert it into repeatable rules: which swings matter, where the path of least resistance runs, what is an achievable target, and which level to use for a structural stop so it isn’t arbitrary. For an algorithmic approach to the challenge is stricter: without a graph model and numeric path scoring, it is impossible to consistently filter setups and make reproducible on‑bar decisions. This article addresses that problem directly—we turn validated swings into graph nodes, construct a weighted map (ATR‑normalized distance, spread, and noise penalties), and use the A* algorithm to find the optimal route for price. The result is a formal, testable framework for identifying high‑probability entries, structural stops, and logical profit targets.
System Overview
The Heuristic A* EA works as a discrete decision-making engine, not a continuous indicator. On each new bar, it scans historical price action to identify key swing highs and lows, which act as nodes in a graph. These nodes are validated to ensure the price has not already moved through them. The EA then connects the nodes with edges, where each edge has a cost based on normalized price distance, spread, and market noise. This converts the price chart into a structured, weighted map that the A* algorithm can navigate.

The EA builds a weighted map of unbreached swing points. Each connection has a cost based on distance and market friction.
Once the graph is built, the A* engine finds the optimal path from the node nearest to the current price to a projected trend-direction target. It evaluates paths using both the actual cost traveled (G) and the estimated remaining cost (H), always expanding the most efficient option. The final path is checked for quality. It must meet directional consistency, stay within acceptable cost limits, and remain free of opposing swing obstacles. If all conditions are met, the EA executes a trade, using the final node as the take-profit and the previous opposing swing as the stop-loss.

Only high-quality paths that meet strict criteria become trades, with stops anchored to prior structure.
Configurable modules and their functions within the system architecture:
| Module | Input Group | Primary Function |
|---|---|---|
| Node Detection | Swing Detection | Identifies local price extremes using a symmetrical lookback window and filters redundant or breached levels. |
| Graph Builder | A* Engine | Constructs the edge network between nodes, calculating traversal cost based on ATR, spread, and noise penalties. |
| Pathfinder | A* Engine | Executes the heuristic search to find the most efficient route through the price structure towards a target. |
| Signal Filter | Trade Decision | Validates the path by checking the directional ratio, total normalized cost, and presence of structural blockades. |
| Risk Manager | Risk Management | Calculates position size based on a fixed percentage of account balance and the distance to the structural stop-loss. |
| Visualizer | Visualization | Renders swing nodes, graph edges, and the selected A* path directly on the chart for transparency. |
Getting Started
//+------------------------------------------------------------------+ //| Heuristic A_Star.mq5 | //| Git, Copyright 2025, MetaQuotes Ltd. | //| https://www.mql5.com/en/users/johnhlomohang/ | //+------------------------------------------------------------------+ #property copyright "Git, Copyright 2025, MetaQuotes Ltd." #property link "https://www.mql5.com/en/users/johnhlomohang/" #property version "1.00" #include <Trade\Trade.mqh> //+------------------------------------------------------------------+ //| INPUT PARAMETERS | //+------------------------------------------------------------------+ //--- Swing Detection --- input group "=== Swing Detection ===" input int InpSwingLookback = 5; // Bars left/right for swing detection input int InpMaxNodes = 100; // Maximum swing nodes to keep input int InpMinSwingBars = 3; // Minimum bars between swings //--- A* Engine --- input group "=== A* Engine ===" input double InpSpreadPenalty = 1.5; // Spread cost multiplier input double InpNoisePenalty = 0.5; // Noise penalty weight input int InpATRPeriod = 14; // ATR period for normalization //--- Trade Decision --- input group "=== Trade Decision ===" input double InpMinPathScore = 0.55; // Minimum directional ratio to trade (0-1) input double InpMaxPathCost = 5.0; // Maximum total path cost (ATR multiples) input double InpSLBufferATR = 0.5; // SL buffer in ATR units //--- Risk Management --- input group "=== Risk Management ===" input double InpRiskPercent = 1.5; // Risk per trade (% of balance) input double InpMaxSpreadPoints = 40; // Max allowed spread in points input int InpMagicNumber = 20250413; // EA magic number input string InpComment = "AStarEA"; //--- Visualization --- input group "=== Visualization ===" input bool InpDrawNodes = true; // Draw swing nodes input bool InpDrawEdges = true; // Draw graph edges input bool InpDrawPath = true; // Draw A* best path input color InpHighNodeColor = clrDodgerBlue; input color InpLowNodeColor = clrOrangeRed; input color InpEdgeColor = clrDimGray; input color InpPathColor = clrLime; input int InpNodeSize = 3;
To get started, we define the input parameters that control how the A* Expert Advisor operates. We organize them into groups for clarity and ease of use. The swing detection settings determine how we identify and filter swing highs and lows. The A* engine settings control how path costs are calculated using spread, noise, and ATR. The trade decision section defines when a path is strong enough to execute a trade. The risk management inputs handle position sizing, spread limits, and trade identification. Finally, the visualization settings allow us to draw nodes, edges, and paths on the chart with customizable colors and sizes.
//+------------------------------------------------------------------+ //| DATA STRUCTURES | //+------------------------------------------------------------------+ struct SwingNode { double price; datetime time; bool isHigh; bool isValid; int barIndex; // bar index at detection time }; struct Edge { int from; int to; double cost; }; struct AStarNode { int index; double g; double h; double f; int parent; bool inOpen; bool inClosed; }; //+------------------------------------------------------------------+ //| GLOBAL VARS | //+------------------------------------------------------------------+ CTrade trade; SwingNode Nodes[]; Edge Edges[]; AStarNode AStar[]; int BestPath[]; // indices into Nodes[] int NodeCount = 0; int EdgeCount = 0; int PathLength = 0; datetime LastBarTime = 0; double CachedATR = 0; //--- Visualization object name prefix string OBJ_PREFIX = "ASTAR_"; //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { trade.SetExpertMagicNumber(InpMagicNumber); trade.SetDeviationInPoints(20); trade.SetTypeFilling(ORDER_FILLING_FOK); ArrayResize(Nodes, InpMaxNodes + 10); ArrayResize(Edges, (InpMaxNodes * 3) + 10); ArrayResize(AStar, InpMaxNodes + 10); ArrayResize(BestPath, InpMaxNodes + 10); Print("Heuristic A_Star EA initialized. Magic=", InpMagicNumber); return INIT_SUCCEEDED; }
Here, we define the core data structures that represent our graph and A* logic. The SwingNode structure stores key market points, including price, time, type (high or low), and validity. The edge structure connects these nodes and assigns a movement cost between them. The AStarNode structure holds the values needed for pathfinding, such as the actual cost (g), estimated cost (h), total cost (f), and parent reference. These structures allow us to model price action as a weighted graph that the A* algorithm can process.
We then declare global variables to store nodes, edges, and A* states, along with the resulting best path. We track counts for nodes, edges, and path length to manage memory efficiently. Additional variables store timing and ATR values for calculations. The OnInit() function prepares the EA by setting trade parameters such as magic number and execution type. It also resizes all arrays based on input limits and confirms initialization with a log message.
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { ClearVisualization(); Print("Heuristic A_Star EA removed."); } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- Only process on new bar if(!IsNewBar()) return; //--- Cache ATR for this bar cycle CachedATR = GetATR(); if(CachedATR <= 0) return; //--- Check spread double spreadPoints = (double)SymbolInfoInteger(_Symbol, SYMBOL_SPREAD); if(spreadPoints > InpMaxSpreadPoints) { Print("Spread too wide: ", spreadPoints, " points. Skipping."); return; } //--- Core pipeline UpdateSwings(); // Detect swing nodes BuildGraph(); // Construct edge graph RunAStar(); // Heuristic + A* search EvaluatePath(); // Decision logic + execution DrawVisualization(); // Render to chart }
The OnDeinit() function clears all chart objects to keep the workspace clean and log that the EA has been removed. In the OnTick() function, we ensure that the system only runs once per new bar to improve efficiency. We then cache the ATR value for consistent calculations and check that the current spread is within acceptable limits before proceeding.
If all conditions are met, we execute the full processing pipeline in sequence. We detect and update swing nodes, build the graph structure, and run the A* algorithm to find the optimal path. We then evaluate the path to decide whether a trade should be executed. Finally, we draw the nodes, edges, and selected path on the chart for visualization and analysis.
//+------------------------------------------------------------------+ //| SWING DETECTION (NODE GENERATION) | //+------------------------------------------------------------------+ void UpdateSwings() { NodeCount = 0; int totalBars = MathMin(Bars(_Symbol, _Period) - InpSwingLookback - 1, InpMaxNodes * 4); double currentPrice = SymbolInfoDouble(_Symbol, SYMBOL_BID); for(int i = InpSwingLookback; i < totalBars && NodeCount < InpMaxNodes; i++) { bool isSwingHigh = IsSwingHigh(i, InpSwingLookback); bool isSwingLow = IsSwingLow(i, InpSwingLookback); if(!isSwingHigh && !isSwingLow) continue; double nodePrice = isSwingHigh ? iHigh(_Symbol, _Period, i) : iLow(_Symbol, _Period, i); datetime nodeTime = iTime(_Symbol, _Period, i); //--- CRITICAL: Remove nodes where price has already passed through //--- (blown-through levels are invalid) if(!IsNodeValid(nodePrice, isSwingHigh, i, currentPrice)) continue; //--- Minimum separation filter — avoid duplicate adjacent swings if(NodeCount > 0) { int prevIdx = NodeCount - 1; if(Nodes[prevIdx].isHigh == isSwingHigh && MathAbs(Nodes[prevIdx].price - nodePrice) < CachedATR * 0.3) continue; } Nodes[NodeCount].price = nodePrice; Nodes[NodeCount].time = nodeTime; Nodes[NodeCount].isHigh = isSwingHigh; Nodes[NodeCount].isValid = true; Nodes[NodeCount].barIndex = i; NodeCount++; } //--- Trim to max if(NodeCount > InpMaxNodes) NodeCount = InpMaxNodes; } //+------------------------------------------------------------------+ //| Swing High | //+------------------------------------------------------------------+ bool IsSwingHigh(int bar, int n) { double h = iHigh(_Symbol, _Period, bar); for(int j = 1; j <= n; j++) { if(iHigh(_Symbol, _Period, bar - j) >= h) return false; if(iHigh(_Symbol, _Period, bar + j) >= h) return false; } //--- True if bar[i].high is greater than N bars on each side return true; } //+------------------------------------------------------------------+ //| Swing Low | //+------------------------------------------------------------------+ bool IsSwingLow(int bar, int n) { double l = iLow(_Symbol, _Period, bar); for(int j = 1; j <= n; j++) { if(iLow(_Symbol, _Period, bar - j) <= l) return false; if(iLow(_Symbol, _Period, bar + j) <= l) return false; } //--- True if bar[i].low is lower than N bars on each side return true; }
In this code section, we handle swing detection by scanning historical price data and converting key turning points into graph nodes. At the start of UpdateSwings(), we reset the node count and limit how many bars we analyze to maintain performance. For each bar within the range, we check whether it forms a swing high or swing low using predefined lookback conditions. If a valid swing is found, we extract its price and time. We then apply a critical validation step to ensure price has not already moved through that level, since such nodes are no longer meaningful for future decisions. This keeps our graph clean and focused on relevant structure.
We also apply a spacing filter to avoid clustering similar swing points too close together, using ATR as a dynamic threshold. If a new swing is too close to the previous one and of the same type, we ignore it. Once a node passes all checks, we store its details, including price, time, type, and bar index. The helper functions IsSwingHigh() and IsSwingLow() ensure that a bar is only classified as a swing if it is higher or lower than a set number of bars on both sides. This strict condition helps us capture meaningful market structure rather than noise, resulting in a well-defined set of nodes for the A* engine.
//+------------------------------------------------------------------+ //| Validate node | //+------------------------------------------------------------------+ bool IsNodeValid(double price, bool isHigh, int barIndex, double currentPrice) { //--- Check bars between barIndex and bar 0 (current) for(int b = barIndex - 1; b >= 0; b--) { if(isHigh) { //--- If any close above the swing high -> node breached if(iClose(_Symbol, _Period, b) > price) return false; } else { //--- If any close below the swing low -> node breached if(iClose(_Symbol, _Period, b) < price) return false; } } return true; } //+------------------------------------------------------------------+ //| GRAPH CONSTRUCTION | //+------------------------------------------------------------------+ void BuildGraph() { EdgeCount = 0; if(NodeCount < 2) return; double spread = SymbolInfoDouble(_Symbol, SYMBOL_ASK) - SymbolInfoDouble(_Symbol, SYMBOL_BID); double atr = CachedATR; for(int i = 0; i < NodeCount; i++) { //--- Connect to immediate neighbours (prev + next) for(int delta = -1; delta <= 1; delta += 2) { int j = i + delta; if(j < 0 || j >= NodeCount) continue; double edgeCost = CalculateEdgeCost(i, j, atr, spread); //--- Store edge if(EdgeCount < ArraySize(Edges) - 1) { Edges[EdgeCount].from = i; Edges[EdgeCount].to = j; Edges[EdgeCount].cost = edgeCost; EdgeCount++; } } //--- Optional cross-connections: skip one node for(int delta = -2; delta <= 2; delta += 4) { int j = i + delta; if(j < 0 || j >= NodeCount) continue; double edgeCost = CalculateEdgeCost(i, j, atr, spread) * 1.2; // slight penalty if(EdgeCount < ArraySize(Edges) - 1) { Edges[EdgeCount].from = i; Edges[EdgeCount].to = j; Edges[EdgeCount].cost = edgeCost; EdgeCount++; } } } }
In the IsNodeValid() function, the system ensures that each swing node is still relevant before including it in the graph. The function checks all candles between the node’s formation and the current bar. For a swing high, if any closing price moves above that level, the node is considered breached and rejected. For a swing low, the opposite applies, where any close below the level invalidates it. This process removes levels that price has already interacted with, ensuring that only untouched and meaningful structure is preserved for further analysis.
Within the BuildGraph() function, the focus shifts to constructing connections between valid nodes. The process begins by resetting the edge count and confirming that there are enough nodes to form a graph. Each node is then connected to its immediate neighbors, creating a simple and structured path network. Additional cross-connections are introduced by skipping one node, allowing alternative routes to exist. These connections carry a slightly higher cost to keep them less favorable. All edge costs are calculated using ATR and spread, which ensures the graph reflects real market conditions and can be effectively used by the A* algorithm.
//+------------------------------------------------------------------+ //| Calculate Edge Cost | //+------------------------------------------------------------------+ double CalculateEdgeCost(int from, int to, double atr, double spread) { double priceDiff = MathAbs(Nodes[from].price - Nodes[to].price); //--- Normalize by ATR double normalizedDist = (atr > 0) ? priceDiff / atr : priceDiff; //--- Spread cost (in ATR units) double spreadCost = (atr > 0) ? spread / atr : 0; //--- Noise penalty: penalize same-type adjacent nodes (low→low = noise) double noisePenalty = 0; if(Nodes[from].isHigh == Nodes[to].isHigh) noisePenalty = InpNoisePenalty; return normalizedDist + (InpSpreadPenalty * spreadCost) + noisePenalty; } //+------------------------------------------------------------------+ //| HEURISTIC FUNCTION | //+------------------------------------------------------------------+ double Heuristic(int current, int target) { double distance = MathAbs(Nodes[current].price - Nodes[target].price); return (CachedATR > 0) ? distance / CachedATR : distance; } //+------------------------------------------------------------------+ //| A* ENGINE | //+------------------------------------------------------------------+ void RunAStar() { PathLength = 0; if(NodeCount < 2) return; //--- Determine start: node closest to current price int startNode = FindStartNode(); //--- Determine goal: support/resistance target int goalNode = FindGoalNode(startNode); if(startNode < 0 || goalNode < 0 || startNode == goalNode) return; //--- Initialize A* state ArrayResize(AStar, NodeCount); for(int i = 0; i < NodeCount; i++) { AStar[i].index = i; AStar[i].g = 1e18; AStar[i].h = 0; AStar[i].f = 1e18; AStar[i].parent = -1; AStar[i].inOpen = false; AStar[i].inClosed = false; } AStar[startNode].g = 0; AStar[startNode].h = Heuristic(startNode, goalNode); AStar[startNode].f = AStar[startNode].h; AStar[startNode].inOpen = true; bool goalReached = false; while(true) { //--- Pick node in OPEN with lowest f int current = -1; double bestF = 1e18; for(int i = 0; i < NodeCount; i++) { if(AStar[i].inOpen && AStar[i].f < bestF) { bestF = AStar[i].f; current = i; } } if(current < 0) break; // OPEN is empty AStar[current].inOpen = false; AStar[current].inClosed = true; if(current == goalNode) { goalReached = true; break; } //--- Expand neighbours via edges for(int e = 0; e < EdgeCount; e++) { if(Edges[e].from != current) continue; int neighbor = Edges[e].to; if(neighbor < 0 || neighbor >= NodeCount) continue; if(AStar[neighbor].inClosed) continue; double tentativeG = AStar[current].g + Edges[e].cost; if(tentativeG < AStar[neighbor].g) { AStar[neighbor].g = tentativeG; AStar[neighbor].h = Heuristic(neighbor, goalNode); AStar[neighbor].f = AStar[neighbor].g + AStar[neighbor].h; AStar[neighbor].parent = current; AStar[neighbor].inOpen = true; } } } //--- Reconstruct path if goal was reached if(goalReached) { int temp[]; ArrayResize(temp, NodeCount); int len = 0; int cur = goalNode; while(cur != -1 && len < NodeCount) { temp[len++] = cur; cur = AStar[cur].parent; } //--- Reverse into BestPath ArrayResize(BestPath, len); for(int i = 0; i < len; i++) BestPath[i] = temp[len - 1 - i]; PathLength = len; } }
The CalculateEdgeCost() function defines how expensive it is for price to move between two nodes. It starts by measuring the absolute price difference and then normalizes it using ATR to account for market volatility. A spread cost is added to reflect real trading conditions, also scaled by ATR. The function then applies a noise penalty when two connected nodes are of the same type, such as high-to-high or low-to-low, since this often represents choppy or less meaningful movement. The final cost combines all these components, producing a balanced and realistic weight for each edge in the graph.
Then, the Heuristic() function provides the estimated cost from a current node to the target node. It calculates the price distance between the two nodes and normalizes it using the cached ATR value. This keeps the heuristic consistent with the edge cost logic and ensures that distance is evaluated relative to market volatility. By doing this, the A* algorithm can make more informed decisions about which paths are likely to be efficient, rather than relying on raw price differences alone.

In the RunAStar() function, the system runs the A* pathfinding process. It selects the start node nearest to the current price and a goal node based on market structure. It then iteratively expands the lowest-cost node and updates parents and costs until it reaches the goal, after which it reconstructs the final path. This path represents the most efficient route for price movement according to the defined cost and heuristic logic.

//+------------------------------------------------------------------+ //| Find Starting Node | //+------------------------------------------------------------------+ int FindStartNode() { //--- Find node closest to current price (start) double currentPrice = SymbolInfoDouble(_Symbol, SYMBOL_BID); int best = -1; double bestDist = 1e18; for(int i = 0; i < NodeCount; i++) { double d = MathAbs(Nodes[i].price - currentPrice); if(d < bestDist) { bestDist = d; best = i; } } return best; } //+------------------------------------------------------------------+ //| Find Goal Node | //+------------------------------------------------------------------+ int FindGoalNode(int startNode) { //--- Find goal node: nearest unbreached resistance (for buy context) or support (sell) if(startNode < 0) return -1; double currentPrice = SymbolInfoDouble(_Symbol, SYMBOL_BID); double atr = CachedATR; //--- Count upward vs downward nodes from start to get initial bias int upCount = 0, downCount = 0; for(int i = 0; i < NodeCount; i++) { if(Nodes[i].price > currentPrice) upCount++; else downCount++; } bool biasUp = (upCount >= downCount); //--- Goal = first swing of the opposing type, furthest in direction of bias int bestGoal = -1; double bestDist = 0; for(int i = 0; i < NodeCount; i++) { if(i == startNode) continue; bool isAbove = Nodes[i].price > currentPrice; if(isAbove != biasUp) continue; // wrong side double d = MathAbs(Nodes[i].price - currentPrice); if(d > bestDist && d < atr * 8.0) // within 8 ATR range { bestDist = d; bestGoal = i; } } return bestGoal; }
In the FindStartNode() function, we determine where the A* search should begin by locating the node closest to the current market price. We retrieve the current bid price and compare it against all stored node prices. For each node, we calculate the absolute distance from the current price and keep track of the smallest value. The node with the shortest distance is selected as the starting point, since it best represents the market’s current position within the graph structure.
Then in the FindGoalNode() function, we define where the pathfinding process should aim. We first establish a directional bias by counting how many nodes lie above and below the current price. If more nodes are above, we assume an upward bias; otherwise, a downward bias. We then search for a suitable goal node in that direction, ignoring nodes on the opposite side. Among the valid candidates, we select the one that is furthest from the current price but still within a reasonable range, capped at a multiple of ATR. This ensures the goal is both directionally consistent and realistically reachable within current market conditions.
//+------------------------------------------------------------------+ //| PATH EVALUATION & TRADE DECISION | //+------------------------------------------------------------------+ void EvaluatePath() { if(PathLength < 2) return; //--- Compute directional score int upMoves = 0; int downMoves = 0; double totalCost = 0; for(int i = 1; i < PathLength; i++) { double priceDelta = Nodes[BestPath[i]].price - Nodes[BestPath[i-1]].price; if(priceDelta > 0) upMoves++; else downMoves++; } //--- Accumulate total path cost from A* g-value of goal int goalIdx = BestPath[PathLength - 1]; totalCost = AStar[goalIdx].g; double totalMoves = upMoves + downMoves; double upRatio = (totalMoves > 0) ? upMoves / totalMoves : 0.5; double downRatio = 1.0 - upRatio; //--- Check opposing node on path bool hasOpposingNode = CheckOpposingNode(); //--- Decision int signal = 0; // 0=none, 1=buy, -1=sell double currentPrice = SymbolInfoDouble(_Symbol, SYMBOL_BID); double goalPrice = Nodes[goalIdx].price; bool pathGoesUp = goalPrice > currentPrice; bool pathGoesDown = goalPrice < currentPrice; //--- BUY conditions if(pathGoesUp && upRatio >= InpMinPathScore && totalCost <= InpMaxPathCost && !hasOpposingNode) { signal = 1; } //--- SELL conditions else if(pathGoesDown && downRatio >= InpMinPathScore && totalCost <= InpMaxPathCost && !hasOpposingNode) { signal = -1; } if(signal == 0) return; // --- SL / TP Calculation --- double atr = CachedATR; double slBuffer = atr * InpSLBufferATR; double tp = goalPrice; // TP = final node in path double sl = 0; //--- SL = node before start on path, or fallback to previous swing if(PathLength >= 3) { //--- The node just before start in path context: use node index BestPath[1] //--- reversed — actually use the swing opposite to start int prevNode = FindOppositeSwingBeforeStart(BestPath[0]); if(prevNode >= 0) sl = Nodes[prevNode].price; } //--- Fallback SL if(sl == 0) { sl = (signal == 1) ? currentPrice - (atr * 1.5 + slBuffer) : currentPrice + (atr * 1.5 + slBuffer); } else { //--- Add buffer sl = (signal == 1) ? sl - slBuffer : sl + slBuffer; } ExecuteTrade(signal, sl, tp); } //+------------------------------------------------------------------+ //| Check Opposing Node | //+------------------------------------------------------------------+ bool CheckOpposingNode() { //--- Check if path contains a high-confidence opposing node (counter-trend node) if(PathLength < 2) return false; double currentPrice = SymbolInfoDouble(_Symbol, SYMBOL_BID); double goalPrice = Nodes[BestPath[PathLength-1]].price; bool goingUp = goalPrice > currentPrice; for(int i = 1; i < PathLength - 1; i++) { int ni = BestPath[i]; // Opposing = swing high blocking an uptrend, or swing low blocking downtrend if(goingUp && Nodes[ni].isHigh && Nodes[ni].price < goalPrice && Nodes[ni].price > currentPrice) return true; if(!goingUp && !Nodes[ni].isHigh && Nodes[ni].price > goalPrice && Nodes[ni].price < currentPrice) return true; } return false; }
In the EvaluatePath() function, we convert the A* result into an actual trading decision by analyzing the structure of the computed path. We first ensure the path is valid, then measure its directional strength by counting how many moves go upward versus downward between consecutive nodes. We also retrieve the total cost of the path from the A* calculation, which reflects how efficient or “expensive” the route is. From this, we compute directional ratios and check whether the path aligns with a clear bias toward buying or selling. We also validate the path further by checking for any opposing structure that could block price movement, ensuring we only act on clean and directional setups.
Once the path passes all conditions, we generate a trade signal and define execution levels. The take-profit is set at the final node in the path, representing the projected market target. The stop-loss is determined by either a structural swing opposite to the entry or, if unavailable, a volatility-based fallback using ATR and a buffer. This ensures the trade is protected even when the structure is unclear. The CheckOpposingNode() function reinforces safety by scanning the path for conflicting swing points that could interrupt price movement. If such a node exists within the path, the trade is rejected to avoid low-quality or obstructed setups.
//+------------------------------------------------------------------+ //| Find Opposite Swing | //+------------------------------------------------------------------+ int FindOppositeSwingBeforeStart(int startNodeIdx) { //--- Find the opposite swing type just before the start node bool startIsHigh = Nodes[startNodeIdx].isHigh; for(int i = startNodeIdx + 1; i < NodeCount; i++) { if(Nodes[i].isHigh != startIsHigh) return i; } return -1; } //+------------------------------------------------------------------+ //| EXECUTION ENGINE | //+------------------------------------------------------------------+ void ExecuteTrade(int signal, double sl, double tp) { //--- Only one open trade at a time (per magic) if(HasOpenPosition()) return; double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); //--- Validate SL/TP logic if(signal == 1) { if(sl >= ask || tp <= ask) { Print("BUY: Invalid SL/TP. SL=", sl, " TP=", tp, " Ask=", ask); return; } } else { if(sl <= bid || tp >= bid) { Print("SELL: Invalid SL/TP. SL=", sl, " TP=", tp, " Bid=", bid); return; } } //--- Risk-based lot sizing double entryPrice = (signal == 1) ? ask : bid; double slDistance = MathAbs(entryPrice - sl); double lots = CalculateLotSize(slDistance); if(lots <= 0) return; //--- Normalize prices int digits = (int)SymbolInfoInteger(_Symbol, SYMBOL_DIGITS); sl = NormalizeDouble(sl, digits); tp = NormalizeDouble(tp, digits); bool result = false; if(signal == 1) { result = trade.Buy(lots, _Symbol, ask, sl, tp, InpComment); Print("BUY executed. Lots=", lots, " SL=", sl, " TP=", tp, " PathLen=", PathLength); } else { result = trade.Sell(lots, _Symbol, bid, sl, tp, InpComment); Print("SELL executed. Lots=", lots, " SL=", sl, " TP=", tp, " PathLen=", PathLength); } if(!result) Print("Trade failed: ", trade.ResultRetcodeDescription()); } //+------------------------------------------------------------------+ //| Calculate LotSize | //+------------------------------------------------------------------+ double CalculateLotSize(double slDistance) { //--- Position sizing: 1–2% risk per trade if(slDistance <= 0) return 0; double balance = AccountInfoDouble(ACCOUNT_BALANCE); double riskAmount = balance * InpRiskPercent / 100.0; double tickSize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE); double tickValue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE); double minLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); double maxLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX); double lotStep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); if(tickSize <= 0 || tickValue <= 0) return minLot; double slInTicks = slDistance / tickSize; double lotSize = riskAmount / (slInTicks * tickValue); lotSize = MathFloor(lotSize / lotStep) * lotStep; lotSize = MathMax(minLot, MathMin(maxLot, lotSize)); return NormalizeDouble(lotSize, 2); }
In this code section, we first determine a structurally relevant stop-loss reference using FindOppositeSwingBeforeStart(). This function starts from the selected start node and scans forward through the node list to locate the first swing of the opposite type. If the start node is a swing high, we search for a swing low, and vice versa. This helps us anchor risk around meaningful market structure rather than arbitrary price levels. If no opposite swing is found, the function returns -1, indicating that a fallback risk method must be used later.
We then move into the ExecuteTrade() function, which handles the full trade execution process. Before placing any order, we ensure there is no existing open position for the same EA. We validate that the stop-loss and take-profit levels are logically correct relative to the current market price. Next, we calculate the entry risk distance and pass it into the lot sizing function to determine position size based on account balance and risk percentage. Once validated and normalized, the trade is executed using either a buy or sell order, and the result is logged for confirmation or debugging.
We complete the execution logic with the CalculateLotSize() function, which ensures proper risk-based position sizing. This function converts the stop-loss distance into tick exposure and calculates how much capital is being risked per trade. It then determines the appropriate lot size based on account balance, tick value, and tick size. The result is adjusted to comply with broker constraints such as minimum lot size, maximum lot size, and lot step increments. This ensures every trade respects risk management rules while maintaining consistent exposure across different market conditions.
//+------------------------------------------------------------------+ //| New Bar Function | //+------------------------------------------------------------------+ bool IsNewBar() { datetime currentBar = iTime(_Symbol, _Period, 0); if(currentBar == LastBarTime) return false; LastBarTime = currentBar; return true; } //+------------------------------------------------------------------+ //| Clear Objects on the chart | //+------------------------------------------------------------------+ void ClearVisualization() { int total = ObjectsTotal(0); for(int i = total - 1; i >= 0; i--) { string name = ObjectName(0, i); if(StringFind(name, OBJ_PREFIX) == 0) ObjectDelete(0, name); } } //+------------------------------------------------------------------+ //| Get ATR Handle | //+------------------------------------------------------------------+ double GetATR() { int handle = iATR(_Symbol, _Period, InpATRPeriod); if(handle == INVALID_HANDLE) return 0; double buf[1]; if(CopyBuffer(handle, 0, 0, 1, buf) <= 0) return 0; IndicatorRelease(handle); return buf[0]; }
Here, we handle three utility functions that support the EA’s core A* engine. The IsNewBar() function ensures that all processing only happens once per new candle by comparing the current bar time with the previously stored value. This prevents redundant calculations and keeps the system efficient. The ClearVisualization() function removes all chart objects created by the EA by scanning through existing objects and deleting those that match the defined prefix, ensuring a clean chart state when the EA is removed or restarted.
We also include GetATR(), which retrieves the Average True Range value for the current symbol and timeframe. This is done by creating an ATR indicator handle, copying the latest buffer value, and then releasing the handle to avoid memory leaks. The ATR value is used throughout the system for normalizing costs, setting buffers, and controlling risk sensitivity. Together, these helper functions ensure the EA runs efficiently, and maintains a clean visual environment.
//+------------------------------------------------------------------+ //| VISUALIZATION ENGINE | //+------------------------------------------------------------------+ void DrawVisualization() { ClearVisualization(); if(NodeCount == 0) return; //--- Draw edges first (behind nodes) if(InpDrawEdges) { for(int e = 0; e < EdgeCount; e++) { int f = Edges[e].from; int t = Edges[e].to; if(f < 0 || f >= NodeCount || t < 0 || t >= NodeCount) continue; string name = OBJ_PREFIX + "EDGE_" + IntegerToString(e); ObjectCreate(0, name, OBJ_TREND, 0, Nodes[f].time, Nodes[f].price, Nodes[t].time, Nodes[t].price); ObjectSetInteger(0, name, OBJPROP_COLOR, InpEdgeColor); ObjectSetInteger(0, name, OBJPROP_WIDTH, 1); ObjectSetInteger(0, name, OBJPROP_STYLE, STYLE_DOT); ObjectSetInteger(0, name, OBJPROP_RAY_RIGHT, false); ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false); ObjectSetInteger(0, name, OBJPROP_HIDDEN, true); } } //--- Draw best path (thick colored lines) if(InpDrawPath && PathLength >= 2) { for(int p = 0; p < PathLength - 1; p++) { int a = BestPath[p]; int b = BestPath[p + 1]; if(a < 0 || a >= NodeCount || b < 0 || b >= NodeCount) continue; string name = OBJ_PREFIX + "PATH_" + IntegerToString(p); ObjectCreate(0, name, OBJ_TREND, 0, Nodes[a].time, Nodes[a].price, Nodes[b].time, Nodes[b].price); ObjectSetInteger(0, name, OBJPROP_COLOR, InpPathColor); ObjectSetInteger(0, name, OBJPROP_WIDTH, 3); ObjectSetInteger(0, name, OBJPROP_STYLE, STYLE_SOLID); ObjectSetInteger(0, name, OBJPROP_RAY_RIGHT, false); ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false); ObjectSetInteger(0, name, OBJPROP_HIDDEN, true); } } //--- Draw nodes (circles/arrows) if(InpDrawNodes) { for(int i = 0; i < NodeCount; i++) { string name = OBJ_PREFIX + "NODE_" + IntegerToString(i); color col = Nodes[i].isHigh ? InpHighNodeColor : InpLowNodeColor; int arrow = Nodes[i].isHigh ? 217 : 218; // down/up triangle arrows ObjectCreate(0, name, OBJ_ARROW, 0, Nodes[i].time, Nodes[i].price); ObjectSetInteger(0, name, OBJPROP_ARROWCODE, arrow); ObjectSetInteger(0, name, OBJPROP_COLOR, col); ObjectSetInteger(0, name, OBJPROP_WIDTH, InpNodeSize); ObjectSetInteger(0, name, OBJPROP_SELECTABLE, false); ObjectSetInteger(0, name, OBJPROP_HIDDEN, true); //--- Highlight path nodes if(InpDrawPath) { for(int p = 0; p < PathLength; p++) { if(BestPath[p] == i) { ObjectSetInteger(0, name, OBJPROP_COLOR, InpPathColor); ObjectSetInteger(0, name, OBJPROP_WIDTH, InpNodeSize + 2); break; } } } } } ChartRedraw(); } //+------------------------------------------------------------------+
The function implements the visualization engine to graphically represent the A* system directly on the chart. It begins by clearing all previously drawn objects to ensure the display stays updated and clean. It then checks whether any nodes exist before proceeding. If edge drawing is enabled, we plot all connections between nodes using dotted trend lines, effectively showing the full graph structure that represents market relationships. These edges are drawn first so they appear behind other elements.
We then render the optimal A* path by connecting consecutive nodes in the best path using thicker, solid lines to highlight the chosen route through the market structure. After that, we draw all swing nodes as arrow markers, with different colors and shapes depending on whether they represent highs or lows. If a node is part of the optimal path, we visually emphasize it by changing its color and increasing its size. This layered visualization allows us to clearly distinguish between raw market structure, the full graph, and the final A* decision path.
We finish by calling ChartRedraw(), which forces the platform to immediately update the chart so all visual elements appear in real time.

Backtest Results
The backtest was conducted on XAUUSD on the H1 timeframe over a two-month window (02 February 2026 to 01 April 2026) with the following settings:

The equity curve and backtest results are shown below:


Conclusion
We converted a "noisy" price chart into a formal market map and produced a practical MetaTrader 5 EA framework that replaces subjective judgment with measurable criteria. The pipeline UpdateSwings -> BuildGraph -> RunAStar -> EvaluatePath -> ExecuteTrade is the operational artifact: it (1) extracts validated swing nodes (removing levels already breached), (2) builds weighted edges using ATR‑normalized distance, spread cost, and a noise penalty, (3) finds the most efficient route with A*, and (4) filters and converts that route into a trade only when formal metrics are satisfied—directional ratio (up/down move share), total path cost (g), and absence of opposing swing obstacles. Executions set TP at the final node, SL at the nearest opposite structure (with ATR buffer), or use a volatility fallback, and size positions by percentage risk.
Practically, you obtain a transparent EA that draws nodes/edges/path on the chart and only takes trades meeting explicit, configurable thresholds (swing lookback, ATR period, spread/noise penalties, min path score, max path cost, risk%). This makes results reproducible and straightforward to backtest: success is verifiable by the presence of visual objects (nodes/edges/path), by trades that obey the stated decision rules, and by the ability to tune parameters to improve P&L under the same formal criteria.
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
How to implement AutoARIMA forecasting in MQL5
MQL5 Wizard Techniques you should know (Part 88): Using Blooms Filter with a Custom Trailing Class
Features of Experts Advisors
Price Action Analysis Toolkit Development (Part 68): Price-Attached RSI Panel in MQL5
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use