Page 4 of 13
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:32 pm
by PTScalper
Here is the isolated, asynchronous scale-out engine:
Code: Select all
using System;
using System.Collections.Generic;
using cAlgo.API;
using cAlgo.API.Internals;
namespace cAlgo.Robots
{
[Robot(TimeZone = TimeZones.UTC, AccessRights = AccessRights.None)]
public class ScaleOutManager : Robot
{
[Parameter("Scale Out at 1R?", Group = "Trade Management", DefaultValue = true)]
public bool EnableScaleOut { get; set; }
// Memory cache to prevent the bot from scaling out the same position multiple times
private HashSet<int> _scaledOutPositions = new HashSet<int>();
protected override void OnStart()
{
// Subscribe to the closed event to clear memory and prevent leaks
Positions.Closed += OnPositionClosed;
}
protected override void OnTick()
{
if (!EnableScaleOut) return;
foreach (var position in Positions)
{
// Skip if this position has already been scaled out, or lacks a Stop Loss to calculate 1R
if (_scaledOutPositions.Contains(position.Id) || !position.StopLoss.HasValue)
continue;
ManageScaleOut(position);
}
}
private void ManageScaleOut(Position position)
{
// 1. Calculate the initial risk distance (1R) in raw price terms
double riskInPrice = Math.Abs(position.EntryPrice - position.StopLoss.Value);
bool is1RHit = false;
// 2. Check if current price has reached the 1R target
if (position.TradeType == TradeType.Buy)
{
double targetPrice1R = position.EntryPrice + riskInPrice;
if (Symbol.Bid >= targetPrice1R) is1RHit = true;
}
else if (position.TradeType == TradeType.Sell)
{
double targetPrice1R = position.EntryPrice - riskInPrice;
if (Symbol.Ask <= targetPrice1R) is1RHit = true;
}
// 3. Execute the partial close
if (is1RHit)
{
// Calculate 50% of the current volume
double targetVolumeToClose = position.VolumeInUnits / 2.0;
// Normalize STRICTLY down to ensure broker step compliance
double safeVolumeToClose = Symbol.NormalizeVolumeInUnits(targetVolumeToClose, RoundingMode.Down);
if (safeVolumeToClose >= Symbol.VolumeInUnitsMin)
{
// Critical: Add to memory BEFORE execution to prevent micro-tick double-firing
_scaledOutPositions.Add(position.Id);
// Execute asynchronously to prevent locking the main thread
ClosePositionAsync(position, safeVolumeToClose, OnScaleOutComplete);
Print($"[Scale Out] 1R Hit for Position {position.Id}. Closing {safeVolumeToClose} units.");
}
}
}
/// <summary>
/// Callback to handle execution failures.
/// </summary>
private void OnScaleOutComplete(TradeResult result)
{
if (!result.IsSuccessful && result.Position != null)
{
// If the broker rejected the partial close, remove it from memory so the bot tries again
_scaledOutPositions.Remove(result.Position.Id);
Print($"[Scale Out Error] Failed to scale out Position {result.Position.Id}: {result.Error}");
}
}
/// <summary>
/// Housekeeping: Remove the ID from memory once the remaining volume hits TP or SL.
/// </summary>
private void OnPositionClosed(PositionClosedEventArgs args)
{
_scaledOutPositions.Remove(args.Position.Id);
}
}
}
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:33 pm
by PTScalper
Architectural Mechanics
State Memory (HashSet<int>): The _scaledOutPositions collection is the gatekeeper. Adding the Position.Id to this list before calling ClosePositionAsync is mandatory. If you wait for the callback to add it, the OnTick method will fire several times during the network latency window, sending multiple redundant close requests to the broker.
1R Price Calculation: By defining 1R as Math.Abs(position.EntryPrice - position.StopLoss.Value), the logic dynamically adapts to any trade. If your ATR-based entry logic placed a 12-pip Stop Loss, 1R is 12 pips. If the Stop Loss was widened to 18 pips due to volatility, 1R automatically adjusts to 18 pips.
Volume Truncation (RoundingMode.Down): If a position has 3,000 units, 50% is 1,500. Most brokers enforce a minimum step size of 1,000 units. RoundingMode.Down ensures the bot requests exactly 1,000 units to close, leaving 2,000 open. If you do not truncate, the broker server will reject the API request entirely with an "Invalid Volume" error.
Housekeeping: The Positions.Closed event handler automatically purges the memory cache. If you run this bot continuously on a VPS for months, failing to clear the HashSet would result in a memory leak that eventually crashes the cBot.
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:34 pm
by PTScalper
To execute a partial close and a Stop Loss modification simultaneously, you must fire both API requests asynchronously. If you use synchronous methods (ClosePosition and ModifyPosition), the bot will wait for the broker to confirm the partial close before sending the Stop Loss update, exposing you to severe slippage if a news spike occurs during that 100-300ms network delay.
A pure "Break-Even" at the exact entry price will still result in a net loss on the remaining volume due to round-turn commissions and the exit spread. The code below introduces a Break-Even Buffer to offset these costs, ensuring the remaining volume closes at a true fiat zero.
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:34 pm
by PTScalper
Here is the updated architecture combining the scale-out and the risk-free modification:
Code: Select all
using System;
using System.Collections.Generic;
using cAlgo.API;
using cAlgo.API.Internals;
namespace cAlgo.Robots
{
[Robot(TimeZone = TimeZones.UTC, AccessRights = AccessRights.None)]
public class RiskFreeScaleOutManager : Robot
{
[Parameter("Scale Out at 1R?", Group = "Trade Management", DefaultValue = true)]
public bool EnableScaleOut { get; set; }
[Parameter("Move to BE at 1R?", Group = "Trade Management", DefaultValue = true)]
public bool MoveToBreakEven { get; set; }
[Parameter("BE Spread Buffer (Pips)", Group = "Trade Management", DefaultValue = 0.5, MinValue = 0.0)]
public double BreakEvenBufferPips { get; set; }
private HashSet<int> _scaledOutPositions = new HashSet<int>();
protected override void OnStart()
{
Positions.Closed += OnPositionClosed;
}
protected override void OnTick()
{
if (!EnableScaleOut && !MoveToBreakEven) return;
foreach (var position in Positions)
{
if (_scaledOutPositions.Contains(position.Id) || !position.StopLoss.HasValue)
continue;
ManageRiskFreeState(position);
}
}
private void ManageRiskFreeState(Position position)
{
double riskInPrice = Math.Abs(position.EntryPrice - position.StopLoss.Value);
bool is1RHit = false;
if (position.TradeType == TradeType.Buy)
{
if (Symbol.Bid >= (position.EntryPrice + riskInPrice)) is1RHit = true;
}
else if (position.TradeType == TradeType.Sell)
{
if (Symbol.Ask <= (position.EntryPrice - riskInPrice)) is1RHit = true;
}
if (is1RHit)
{
// 1. Lock the position state immediately to prevent OnTick double-firing
_scaledOutPositions.Add(position.Id);
// 2. Fire the Partial Close Request
if (EnableScaleOut)
{
double safeVolumeToClose = Symbol.NormalizeVolumeInUnits(position.VolumeInUnits / 2.0, RoundingMode.Down);
if (safeVolumeToClose >= Symbol.VolumeInUnitsMin)
{
ClosePositionAsync(position, safeVolumeToClose, OnScaleOutComplete);
}
}
// 3. Fire the Stop Loss Modification Request concurrently
if (MoveToBreakEven)
{
ExecuteBreakEvenModification(position);
}
}
}
private void ExecuteBreakEvenModification(Position position)
{
double bufferInPrice = BreakEvenBufferPips * Symbol.PipSize;
double breakEvenPrice;
if (position.TradeType == TradeType.Buy)
{
breakEvenPrice = Math.Round(position.EntryPrice + bufferInPrice, Symbol.Digits);
// Only move SL up. Do not accidentally widen it if the user manually moved it higher.
if (position.StopLoss.Value < breakEvenPrice)
{
ModifyPositionAsync(position, breakEvenPrice, position.TakeProfit, OnBreakEvenComplete);
}
}
else // Sell
{
breakEvenPrice = Math.Round(position.EntryPrice - bufferInPrice, Symbol.Digits);
// Only move SL down.
if (position.StopLoss.Value > breakEvenPrice)
{
ModifyPositionAsync(position, breakEvenPrice, position.TakeProfit, OnBreakEvenComplete);
}
}
}
private void OnScaleOutComplete(TradeResult result)
{
if (!result.IsSuccessful && result.Position != null)
{
_scaledOutPositions.Remove(result.Position.Id);
Print($"[Error] Scale out failed for Pos {result.Position.Id}. Re-arming. Error: {result.Error}");
}
}
private void OnBreakEvenComplete(TradeResult result)
{
if (result.IsSuccessful)
{
Print($"[Risk Free] Position {result.Position.Id} secured at Break-Even + Buffer.");
}
else
{
Print($"[Error] Break-Even modification failed: {result.Error}");
}
}
private void OnPositionClosed(PositionClosedEventArgs args)
{
_scaledOutPositions.Remove(args.Position.Id);
}
}
}
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:34 pm
by PTScalper
Architectural Mechanics
Concurrent Asynchronous Firing: Because ClosePositionAsync and ModifyPositionAsync do not block the thread, the cBot sends both API requests to the broker almost simultaneously. The broker's execution engine will queue and process them in parallel, drastically reducing your exposure time at the 1R boundary.
The Spread Buffer (BreakEvenBufferPips): If you buy at 1.1000 and move your SL exactly to 1.1000, the position will close at 1.1000 on the Bid price. However, you paid the spread on entry (the Ask price was higher), plus fixed commissions. A 0.5-pip buffer ensures the Stop Loss is placed at 1.1005, generating just enough micro-profit to pay the broker fees and leave your account equity genuinely flat.
Stop Loss Validation Logic: The checks position.StopLoss.Value < breakEvenPrice (for Buys) ensure the bot never accidentally degrades your position. If you had manually trailed the Stop Loss to +5 pips before the 1R target was hit, the bot will recognize the current SL is already better than Break-Even and will skip the modification entirely, executing only the scale-out.
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:35 pm
by PTScalper
To solve this without falling into an infinite loop of partial closures, you must stop relying on the broker-assigned Position.Id and instead rely on a Logical Trade ID (LTID).
While native cTrader usually retains the same Position.Id and simply reduces the VolumeInUnits during a partial close, certain FIX API broker bridges or aggregator setups mimic MT4's behavior: they close the parent position entirely and open a new child position with the remaining volume.
When this happens, cTrader guarantees one indestructible link: the child position will inherently inherit the exact Position.Label of the parent.
Here is the architectural pattern to track state across position mutations using an LTID.
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:35 pm
by PTScalper
1. The Strategy: Stateful Labels
Instead of using a generic static label like "MyAlgo", you inject a unique identifier (like a timestamp or GUID) into the label at the exact moment of execution. Your state manager then tracks this string rather than the integer ID.
Code: Select all
using System;
using System.Collections.Generic;
using cAlgo.API;
namespace cAlgo.Robots
{
[Robot(TimeZone = TimeZones.UTC, AccessRights = AccessRights.None)]
public class LineageScaleOutManager : Robot
{
// The memory cache now tracks the unique Label string, not the Position ID
private HashSet<string> _scaledOutLogicalTrades = new HashSet<string>();
protected override void OnStart()
{
Positions.Closed += OnPositionClosed;
}
/// <summary>
/// Entry Logic: Generates a unique Logical Trade ID (LTID) for every new execution.
/// </summary>
private void ExecuteNewTrade()
{
// Append the current exact millisecond tick count to ensure absolute uniqueness
string uniqueLtid = "MyAlgo_" + DateTime.Now.Ticks.ToString();
ExecuteMarketOrder(TradeType.Buy, SymbolName, 100000, uniqueLtid, 10, 20);
}
protected override void OnTick()
{
foreach (var position in Positions)
{
// If this label is in our memory cache, it has already been scaled out.
// It does not matter if the Position.Id changed; the label persists.
if (_scaledOutLogicalTrades.Contains(position.Label) || !position.StopLoss.HasValue)
continue;
ManageScaleOut(position);
}
}
private void ManageScaleOut(Position position)
{
double riskInPrice = Math.Abs(position.EntryPrice - position.StopLoss.Value);
bool is1RHit = Symbol.Bid >= (position.EntryPrice + riskInPrice); // Simplified for Buy
if (is1RHit)
{
double targetVolumeToClose = Symbol.NormalizeVolumeInUnits(position.VolumeInUnits / 2.0, RoundingMode.Down);
if (targetVolumeToClose >= Symbol.VolumeInUnitsMin)
{
// 1. Lock the logical trade state immediately using the Label
_scaledOutLogicalTrades.Add(position.Label);
// 2. Execute the partial close
ClosePositionAsync(position, targetVolumeToClose, OnScaleOutComplete);
Print($"[Scale Out] 1R Hit. Locked Logical Trade: {position.Label}");
}
}
}
private void OnScaleOutComplete(TradeResult result)
{
if (!result.IsSuccessful && result.Position != null)
{
// Unlock the label so the bot can try again on the next tick
_scaledOutLogicalTrades.Remove(result.Position.Label);
}
}
/// <summary>
/// Housekeeping: Safely clears the memory cache ONLY when the entire logical trade is flat.
/// </summary>
private void OnPositionClosed(PositionClosedEventArgs args)
{
string closedLabel = args.Position.Label;
// CRITICAL SAFETY CHECK:
// If the broker created a new Position ID for the remainder, it fires this Closed event
// for the parent. If we blindly remove the label from memory right now, the new child
// position will immediately get scaled-out again on the next tick.
// We must check if ANY positions with this label still exist in the market.
if (Positions.FindAll(closedLabel).Length == 0)
{
// Only if the count is 0 (the entire trade is fully closed/flat), purge the memory.
_scaledOutLogicalTrades.Remove(closedLabel);
Print($"[Memory Purge] Logical Trade {closedLabel} fully closed. Cache cleared.");
}
}
}
}
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:35 pm
by PTScalper
Architectural Mechanics
LTID Injection (DateTime.Now.Ticks): By appending the system's tick count to the label ("MyAlgo_638321045231"), every single trade gets a mathematically unique signature. Even if the bot fires two trades in the exact same second, they will have different LTIDs.
Persistent Mutation Tracking: If the broker splits Position #1001 into a closed #1001 (50,000 units) and a new open #1002 (50,000 units), both will forever carry "MyAlgo_638321045231". The OnTick method evaluates #1002, sees the label is already in the _scaledOutLogicalTrades cache, and safely ignores it.
The Zero-Count Housekeeping Rule: The logic inside OnPositionClosed is the lynchpin. If a partial close triggers a synthetic Closed event for the parent position, the code verifies if the child position exists (Positions.FindAll(closedLabel).Length == 0). Because the child position exists, it skips the memory purge, keeping the cache active and preventing the dreaded infinite scale-out loop.
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:36 pm
by PTScalper
To calculate the exact financial outcome of a fragmented trade, you must bridge the gap between closed data and active data. When a partial close occurs, cTrader moves the executed volume into the History collection (as a realized HistoricalTrade), while the remaining volume stays in the Positions collection (as an unrealized Position).
Because you linked them with a Logical Trade ID (LTID) injected into the Label property, aggregating the total net Profit and Loss (PnL) requires a simple iteration across both collections.
Re: Spread Filters for Scalping: When Not to Enter
Posted: Thu Oct 08, 2026 1:36 pm
by PTScalper
Here is the isolated C# cAlgo method to calculate the true aggregate PnL of a fragmented trade at any given millisecond:
Code: Select all
using System.Linq;
using cAlgo.API;
namespace cAlgo.Robots
{
[Robot(TimeZone = TimeZones.UTC, AccessRights = AccessRights.None)]
public class PnlAggregator : Robot
{
/// <summary>
/// Calculates the total Net PnL (Realized + Unrealized) for a Logical Trade ID.
/// </summary>
/// <param name="logicalTradeId">The unique label assigned at the original entry.</param>
/// <returns>The net fiat profit or loss in the account currency.</returns>
private double CalculateLogicalTradeNetPnL(string logicalTradeId)
{
double realizedNetPnL = 0;
double unrealizedNetPnL = 0;
// 1. Sum Realized PnL from all closed fragments (Partial closes / Scale-outs)
var closedFragments = History.FindAll(logicalTradeId);
foreach (var historicTrade in closedFragments)
{
// NetProfit natively includes all deducted Commissions and Swaps
realizedNetPnL += historicTrade.NetProfit;
}
// 2. Sum Unrealized (Floating) PnL from the remaining open fragments
var openFragments = Positions.FindAll(logicalTradeId);
foreach (var openPosition in openFragments)
{
unrealizedNetPnL += openPosition.NetProfit;
}
double totalNetPnL = realizedNetPnL + unrealizedNetPnL;
return totalNetPnL;
}
// Example trigger to monitor the PnL of an active sequence
protected override void OnTick()
{
// Assuming "MyAlgo_638321045231" is the LTID of an active trade sequence
string currentLtid = "MyAlgo_638321045231";
// Only calculate if the trade is actually active
if (Positions.FindAll(currentLtid).Length > 0)
{
double currentTotalPnL = CalculateLogicalTradeNetPnL(currentLtid);
// Display the true aggregated PnL on the chart for monitoring
Chart.DrawText("pnl_display", $"Trade {currentLtid} Net PnL: {currentTotalPnL:C2}",
Chart.Height - 50, Chart.Width - 200, Color.LimeGreen);
}
}
}
}