Page 2 of 3
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:10 pm
by PTScalper
To elevate this from a functional script to an enterprise-grade execution tool, the architecture needs proper separation of concerns, strong typing for validation states, and a real-time UI overlay so you aren't forced to dig through the terminal log to know your current status.
Here is the refactored version. It decouples the validation logic from the main robot class, uses a custom ValidationResult struct to handle state, and implements a lightweight, real-time WPF-style Heads-Up Display (HUD) directly on the cTrader chart.
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:10 pm
by PTScalper
Ctrader Pro version 1.0
Code: Select all
using System;
using System.Linq;
using cAlgo.API;
using cAlgo.API.Internals;
namespace cAlgo.Robots
{
[Robot(TimeZone = TimeZones.UTC, AccessRights = AccessRights.None)]
public class ProExecutionFilter : Robot
{
// --- Parameters ---
[Parameter("Base Max Spread", Group = "Thresholds (Pips)", DefaultValue = 0.6, MinValue = 0)]
public double BaseSpreadMax { get; set; }
[Parameter("CPI Week Max", Group = "Thresholds (Pips)", DefaultValue = 0.5, MinValue = 0)]
public double CpiWeekSpreadMax { get; set; }
[Parameter("Post-Loss Max", Group = "Thresholds (Pips)", DefaultValue = 0.4, MinValue = 0)]
public double PostLossSpreadMax { get; set; }
[Parameter("Loss Window (Minutes)", Group = "Thresholds (Pips)", DefaultValue = 10)]
public int LossWindowMinutes { get; set; }
[Parameter("Is CPI Week?", Group = "CPI Schedule", DefaultValue = false)]
public bool IsCpiWeek { get; set; }
[Parameter("Is CPI Day?", Group = "CPI Schedule", DefaultValue = false)]
public bool IsCpiDay { get; set; }
[Parameter("Block Start (Broker Time)", Group = "CPI Schedule", DefaultValue = "15:20")]
public string CpiStartTimeStr { get; set; }
[Parameter("Block End (Broker Time)", Group = "CPI Schedule", DefaultValue = "15:50")]
public string CpiEndTimeStr { get; set; }
[Parameter("Volume (Units)", Group = "Execution", DefaultValue = 100000)]
public double VolumeInUnits { get; set; }
// --- Services ---
private ExecutionValidator _validator;
private ChartHud _hud;
protected override void OnStart()
{
if (!TimeSpan.TryParse(CpiStartTimeStr, out TimeSpan cpiStart) ||
!TimeSpan.TryParse(CpiEndTimeStr, out TimeSpan cpiEnd))
{
Print("🔴 ERROR: Invalid CPI Time format. Use HH:mm");
Stop();
return;
}
// Initialize domain services
_validator = new ExecutionValidator(this, cpiStart, cpiEnd);
_hud = new ChartHud(this);
Chart.KeyDown += OnChartKeyDown;
// Initial UI render
UpdateHud();
}
protected override void OnTick()
{
// Keep the on-chart dashboard updated in real-time
UpdateHud();
}
private void OnChartKeyDown(ChartKeyboardEventArgs args)
{
if (args.Key == Key.B) TryExecute(TradeType.Buy);
else if (args.Key == Key.S) TryExecute(TradeType.Sell);
}
private void TryExecute(TradeType tradeType)
{
ValidationResult result = _validator.Validate();
if (!result.IsValid)
{
Print($"🔴 REJECTED: {result.Reason} | Spread: {result.CurrentSpread:F1} | Limit: {result.ActiveThreshold:F1}");
return;
}
Print($"🟢 ACCEPTED: {result.Reason} | Spread: {result.CurrentSpread:F1} | Limit: {result.ActiveThreshold:F1}");
ExecuteMarketOrder(tradeType, SymbolName, VolumeInUnits, "ExecutionFilter", 0, 0);
}
private void UpdateHud()
{
ValidationResult result = _validator.Validate();
_hud.Update(result);
}
}
// --- Domain Models & Services ---
public struct ValidationResult
{
public bool IsValid { get; }
public string Reason { get; }
public double CurrentSpread { get; }
public double ActiveThreshold { get; }
public ValidationResult(bool isValid, string reason, double currentSpread, double activeThreshold)
{
IsValid = isValid;
Reason = reason;
CurrentSpread = currentSpread;
ActiveThreshold = activeThreshold;
}
}
public class ExecutionValidator
{
private readonly ProExecutionFilter _bot;
private readonly TimeSpan _cpiStart;
private readonly TimeSpan _cpiEnd;
public ExecutionValidator(ProExecutionFilter bot, TimeSpan cpiStart, TimeSpan cpiEnd)
{
_bot = bot;
_cpiStart = cpiStart;
_cpiEnd = cpiEnd;
}
public ValidationResult Validate()
{
double currentSpreadPips = Math.Round(_bot.Symbol.Spread / _bot.Symbol.PipSize, 1);
double activeThreshold = _bot.BaseSpreadMax;
string stateReason = "Base Rule";
// 1. Time Block Check
if (_bot.IsCpiDay)
{
TimeSpan currentTime = _bot.Server.Time.TimeOfDay;
if (currentTime >= _cpiStart && currentTime <= _cpiEnd)
{
return new ValidationResult(false, "CPI Time Block Active", currentSpreadPips, 0);
}
}
// 2. Determine Active Threshold
if (_bot.IsCpiWeek)
{
activeThreshold = _bot.CpiWeekSpreadMax;
stateReason = "CPI Week";
}
bool hasRecentLoss = _bot.History.Where(x =>
x.SymbolName == _bot.SymbolName &&
x.NetProfit < 0 &&
(_bot.Server.TimeInUtc - x.ClosingTime).TotalMinutes <= _bot.LossWindowMinutes
).Any();
if (hasRecentLoss)
{
activeThreshold = _bot.PostLossSpreadMax;
stateReason = "Post-Loss Recovery";
}
// 3. Evaluate Spread
bool isValid = currentSpreadPips <= activeThreshold;
return new ValidationResult(isValid, stateReason, currentSpreadPips, activeThreshold);
}
}
public class ChartHud
{
private readonly ProExecutionFilter _bot;
private readonly TextBlock _statusText;
private readonly TextBlock _spreadText;
private readonly TextBlock _ruleText;
private readonly Border _container;
public ChartHud(ProExecutionFilter bot)
{
_bot = bot;
_statusText = new TextBlock { FontWeight = FontWeight.ExtraBold, Margin = new Thickness(0, 0, 0, 5) };
_spreadText = new TextBlock { ForegroundColor = Color.White };
_ruleText = new TextBlock { ForegroundColor = Color.Gray, FontSize = 10, Margin = new Thickness(0, 5, 0, 0) };
var stackPanel = new StackPanel { Orientation = Orientation.Vertical, Margin = 10 };
stackPanel.AddChild(_statusText);
stackPanel.AddChild(_spreadText);
stackPanel.AddChild(_ruleText);
_container = new Border
{
BackgroundColor = Color.FromArgb(220, 25, 25, 25),
BorderColor = Color.FromArgb(100, 100, 100, 100),
BorderThickness = 1,
CornerRadius = 3,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Top,
Margin = 20,
Child = stackPanel
};
_bot.Chart.AddControl(_container);
}
public void Update(ValidationResult result)
{
if (result.IsValid)
{
_statusText.Text = "EXECUTION CLEAR";
_statusText.ForegroundColor = Color.LimeGreen;
_container.BorderColor = Color.FromArgb(100, 50, 205, 50);
}
else
{
_statusText.Text = "EXECUTION BLOCKED";
_statusText.ForegroundColor = Color.Red;
_container.BorderColor = Color.FromArgb(200, 255, 0, 0);
}
_spreadText.Text = $"Live Spread: {result.CurrentSpread:F1} pips (Max: {result.ActiveThreshold:F1})";
_ruleText.Text = $"Active State: {result.Reason}";
}
}
}
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:11 pm
by PTScalper
Architectural Upgrades
Immutable State Object: The logic now revolves around the ValidationResult struct. By returning an immutable object containing the boolean result, the active threshold, the live spread, and the exact rule being enforced, the validation logic is completely decoupled from the execution and logging side effects.
Encapsulated Logic (Single Responsibility Principle): The ExecutionValidator class is strictly responsible for polling the order book and LINQ history. The ProExecutionFilter robot class acts purely as the controller, handling parameter inputs and order routing.
On-Chart Rendering (WPF Integration): cTrader natively supports WPF-style UI controls. The ChartHud class dynamically draws a non-intrusive container in the top-right corner of the chart, utilizing StackPanel and TextBlock elements.
Event-Driven HUD Updates: By firing UpdateHud() inside OnTick(), the chart overlay repaints dynamically on every incoming price quote. If a trade stops out in the background, the UI instantly snaps to "EXECUTION BLOCKED" with a red border, warning you off the keyboard before you even touch it.
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:11 pm
by PTScalper
To handle this without stalling the cTrader UI thread or delaying execution evaluation, the logging mechanism must be decoupled and run asynchronously. A clean approach is to use standard ADO.NET with a fire-and-forget Task to push the state variables directly into a local MS SQL Server instance.
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:11 pm
by PTScalper
1. Database Schema
First, spin up a table in your local MS SQL Server instance to capture the market state and order intent at the exact millisecond of rejection.
Code: Select all
CREATE TABLE ExecutionRejections (
Id INT IDENTITY(1,1) PRIMARY KEY,
Timestamp DATETIME2 DEFAULT SYSUTCDATETIME(),
Symbol NVARCHAR(10),
Direction NVARCHAR(4),
VolumeUnits FLOAT,
RejectionReason NVARCHAR(50),
LiveSpread FLOAT,
ActiveThreshold FLOAT
);
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:12 pm
by PTScalper
2. The Logger Service
Create an asynchronous repository class. You will need to add a reference to System.Data.SqlClient (or Microsoft.Data.SqlClient depending on your cTrader .NET target) via cTrader's "Manage References" menu.
Code: Select all
using System;
using System.Data.SqlClient;
using System.Threading.Tasks;
using cAlgo.API;
namespace cAlgo.Robots
{
public class SqlServerLogger
{
private readonly string _connectionString;
public SqlServerLogger(string connectionString)
{
_connectionString = connectionString;
}
// Fire-and-forget async wrapper to prevent blocking the trading thread
public void LogRejectionAsync(ValidationResult result, string symbol, TradeType direction, double volume)
{
Task.Run(async () =>
{
try
{
await InsertRejectionRecord(result, symbol, direction, volume);
}
catch (Exception ex)
{
// In a production bot, log this to a local text file or cTrader print
System.Diagnostics.Debug.WriteLine($"DB Logging Failed: {ex.Message}");
}
});
}
private async Task InsertRejectionRecord(ValidationResult result, string symbol, TradeType direction, double volume)
{
const string query = @"
INSERT INTO ExecutionRejections
(Symbol, Direction, VolumeUnits, RejectionReason, LiveSpread, ActiveThreshold)
VALUES
(@Symbol, @Direction, @Volume, @Reason, @Spread, @Threshold)";
using (var connection = new SqlConnection(_connectionString))
using (var command = new SqlCommand(query, connection))
{
command.Parameters.AddWithValue("@Symbol", symbol);
command.Parameters.AddWithValue("@Direction", direction.ToString());
command.Parameters.AddWithValue("@Volume", volume);
command.Parameters.AddWithValue("@Reason", result.Reason);
command.Parameters.AddWithValue("@Spread", result.CurrentSpread);
command.Parameters.AddWithValue("@Threshold", result.ActiveThreshold);
await connection.OpenAsync();
await command.ExecuteNonQueryAsync();
}
}
}
}
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:12 pm
by PTScalper
3. Wiring into the cBot
Integrate the logger into your existing ProExecutionFilter class. Pass the connection string as a parameter and call LogRejectionAsync whenever validation fails.
Code: Select all
public class ProExecutionFilter : Robot
{
// ... (existing parameters) ...
[Parameter("DB Connection String", Group = "Logging", DefaultValue = "Server=localhost;Database=TradingStats;Integrated Security=True;")]
public string DbConnectionString { get; set; }
private ExecutionValidator _validator;
private ChartHud _hud;
private SqlServerLogger _logger; // Inject the logger
protected override void OnStart()
{
// ... (existing init logic) ...
_logger = new SqlServerLogger(DbConnectionString);
_validator = new ExecutionValidator(this, cpiStart, cpiEnd);
_hud = new ChartHud(this);
Chart.KeyDown += OnChartKeyDown;
UpdateHud();
}
private void TryExecute(TradeType tradeType)
{
ValidationResult result = _validator.Validate();
if (!result.IsValid)
{
Print($"🔴 REJECTED: {result.Reason} | Spread: {result.CurrentSpread:F1}");
// Fire off the DB insert asynchronously
_logger.LogRejectionAsync(result, SymbolName, tradeType, VolumeInUnits);
return;
}
Print("🟢 ACCEPTED");
ExecuteMarketOrder(tradeType, SymbolName, VolumeInUnits, "ExecutionFilter", 0, 0);
}
// ... (remaining methods unchanged)
}
By pushing this data directly to SQL Server, you eliminate the need to parse text logs at the end of the quarter. You can instantly run queries grouping rejections by RejectionReason to see whether CPI volatility or post-loss tilt is saving you from the most bad trades.
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:13 pm
by PTScalper
To link your execution conditions directly to trade outcomes, the database needs to track both the initial intent (the spread at the moment of the click) and the eventual result (the net profit when the position closes).
This requires expanding the table to accept null fields that will be updated later, and subscribing the cBot to the Positions.Closed event.
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:13 pm
by PTScalper
1. Updated Database Schema
Drop the old ExecutionRejections table and create a unified ExecutionLogs table. It introduces an IsAccepted flag and fields to capture the final trade metrics.
Code: Select all
CREATE TABLE ExecutionLogs (
Id INT IDENTITY(1,1) PRIMARY KEY,
Timestamp DATETIME2 DEFAULT SYSUTCDATETIME(),
Symbol NVARCHAR(10),
Direction NVARCHAR(4),
VolumeUnits FLOAT,
LiveSpread FLOAT,
ActiveThreshold FLOAT,
IsAccepted BIT,
RejectionReason NVARCHAR(50) NULL,
-- Fields populated only for accepted trades after they close
PositionId INT NULL,
NetProfit FLOAT NULL,
CloseTime DATETIME2 NULL
);
Re: Calibrating my spread filter that blocks revenge during CPI week
Posted: Mon Oct 05, 2026 5:13 pm
by PTScalper
2. Expanding the Logger Service
The logger needs to handle both the initial insert (which now accepts a PositionId if the trade was allowed) and a new update method to append the profit data once the trade concludes.
Code: Select all
using System;
using System.Data.SqlClient;
using System.Threading.Tasks;
using cAlgo.API;
namespace cAlgo.Robots
{
public class SqlServerLogger
{
private readonly string _connectionString;
public SqlServerLogger(string connectionString)
{
_connectionString = connectionString;
}
public void LogExecutionAsync(ValidationResult result, string symbol, TradeType direction, double volume, bool isAccepted, int? positionId = null)
{
Task.Run(async () =>
{
const string query = @"
INSERT INTO ExecutionLogs
(Symbol, Direction, VolumeUnits, LiveSpread, ActiveThreshold, IsAccepted, RejectionReason, PositionId)
VALUES
(@Symbol, @Direction, @Volume, @Spread, @Threshold, @IsAccepted, @Reason, @PositionId)";
using (var connection = new SqlConnection(_connectionString))
using (var command = new SqlCommand(query, connection))
{
command.Parameters.AddWithValue("@Symbol", symbol);
command.Parameters.AddWithValue("@Direction", direction.ToString());
command.Parameters.AddWithValue("@Volume", volume);
command.Parameters.AddWithValue("@Spread", result.CurrentSpread);
command.Parameters.AddWithValue("@Threshold", result.ActiveThreshold);
command.Parameters.AddWithValue("@IsAccepted", isAccepted);
command.Parameters.AddWithValue("@Reason", result.Reason ?? (object)DBNull.Value);
command.Parameters.AddWithValue("@PositionId", positionId ?? (object)DBNull.Value);
await connection.OpenAsync();
await command.ExecuteNonQueryAsync();
}
});
}
public void UpdateOutcomeAsync(int positionId, double netProfit)
{
Task.Run(async () =>
{
const string query = @"
UPDATE ExecutionLogs
SET NetProfit = @NetProfit,
CloseTime = SYSUTCDATETIME()
WHERE PositionId = @PositionId";
using (var connection = new SqlConnection(_connectionString))
using (var command = new SqlCommand(query, connection))
{
command.Parameters.AddWithValue("@NetProfit", netProfit);
command.Parameters.AddWithValue("@PositionId", positionId);
await connection.OpenAsync();
await command.ExecuteNonQueryAsync();
}
});
}
}
}