Spreader 2-Strategie
Übersicht
Die Spreader 2-Strategie ist ein Pair-Trading-System, das aus dem MetaTrader Expert Advisor "Spreader 2" konvertiert wurde. Sie beobachtet zwei korrelierte Instrumente auf einem Ein-Minuten-Zeitrahmen und sucht nach kurzfristigen Abweichungen zwischen ihren Preisbewegungen. Wenn beide Beine innerhalb kontrollierter Volatilitätsgrenzen divergieren und dabei positive Korrelation aufrechterhalten, eröffnet die Strategie einen marktneutralen Spread durch eine Long-Position in einem Symbol und eine Short-Position im anderen. Die kombinierte Position wird geschlossen, wenn der gesamte schwebende Gewinn das konfigurierte Ziel erreicht oder wenn Korrelationsregeln verletzt werden.
Kernlogik
- Fertige Kerzen für das primäre und sekundäre Symbol empfangen und nach Schließzeit ausrichten.
- Rollierende Listen von Schlusspreisen pflegen, damit der Algorithmus auf Werte verweisen kann, die
ShiftLength,2 * ShiftLengthund1440Balken in der Vergangenheit liegen. - Erste Differenzen berechnen (
x1,x2für das primäre Symbol undy1,y2für das sekundäre Symbol), um lokale Schwankungen zu erkennen. - Handel überspringen, wenn ein Instrument zwei aufeinanderfolgende Bewegungen in die gleiche Richtung zeigt (Trendfilter) oder wenn die Produkte
x1 * y1negative Korrelation anzeigen. - Das Volatilitätsverhältnis
a / bauswerten, wobeia = |x1| + |x2|undb = |y1| + |y2|. Nur fortfahren, wenn das Verhältnis zwischen0.3und3.0bleibt. - Das sekundäre Beinvolumen proportional zum Volatilitätsverhältnis skalieren und an Volumen-Schritt, Minimum und Maximum des Kontrakts anpassen.
- Die beabsichtigte Handelsrichtung mit dem 1440-Balken-Rückblick (ungefähr ein Handelstag) bestätigen. Der Spread wird nur eröffnet, wenn die Tagesbewegung das kurzfristige Signal unterstützt.
- Die Strategie eröffnet beide Beine gleichzeitig: das primäre Symbol handelt mit dem konfigurierten
PrimaryVolume, während das sekundäre Symbol die angepasste Größe in entgegengesetzter Richtung handelt. - Während Positionen offen sind, verfolgt das System kontinuierlich den schwebenden Gewinn beider Beine. Wenn der kombinierte Gewinn
TargetProfitübersteigt, schließt es den Spread und setzt die Einstiegsreferenzen zurück. - Sicherheitschecks schließen automatisch verwaiste Positionen, wenn ein Bein unerwartet aussteigt, und eröffnen fehlende Beine wenn möglich neu, um die Absicherung ausgewogen zu halten.
Parameter
- SecondSecurity – sekundäres Instrument, das am Spread teilnimmt. Dieser Parameter ist erforderlich.
- PrimaryVolume – Handelsvolumen (in Lots/Kontrakten) für das primäre Symbol. Standard ist
1. - TargetProfit – absolutes monetäres Gewinnziel für das kombinierte Paar. Standard ist
100. - ShiftLength – Anzahl der Kerzen zwischen Vergleichspunkten, die in Erste-Differenz-Berechnungen verwendet werden. Standard ist
30. - CandleType – Datentyp für Kerzen-Subskriptionen. Standardmäßig arbeitet die Strategie mit Ein-Minuten-Zeitrahmen-Kerzen.
Handelsregeln
- Nur fertige Kerzen werden verarbeitet, um Aktionen auf unvollständigen Daten zu vermeiden.
- Trendfilter müssen für beide Symbole über die letzten zwei
ShiftLength-Fenster entgegengesetzte Bewegungen zeigen. - Die Korrelation muss positiv sein, und das Volatilitätsverhältnis muss im Band
[0.3, 3.0]bleiben. - Die Bestätigungsprüfung gegen den 1440-Balken-Rückblick verhindert Trades, die der längerfristigen Richtung widersprechen.
- Aufträge werden mit
OrderTypes.Marketgesendet. Das sekundäre Bein wird explizit mit dem sekundären Wertpapier und Portfolio registriert, um das MetaTrader-Verhalten widerzuspiegeln. - Der offene Gewinn wird anhand der letzten Kerzenschlüsse und gespeicherten Einstiegspreise berechnet, um zu bestimmen, wann der Spread zu schließen ist.
Hinweise
- Die Strategie setzt voraus, dass beide Instrumente kompatible Kontraktspezifikationen teilen. Wenn Multiplikatoren abweichen, wird der Handel deaktiviert und eine Warnung protokolliert.
- Da der ursprüngliche Algorithmus auf einem vollständigen Tag historischer Daten basiert, wartet auch die StockSharp-Version, bis mindestens 1440 Kerzen für den ersten Einstieg angesammelt sind.
- Alle Risikomanagement-Logik (Gewinnziel, Behandlung verwaister Beine) ist in der Strategie enthalten. Zusätzliche Schutzmaßnahmen wie Stop-Losses können bei Bedarf extern hinzugefügt werden.
using System;
using System.Linq;
using System.Collections.Generic;
using Ecng.Common;
using Ecng.Collections;
using Ecng.Serialization;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
using Ecng.ComponentModel;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Pair trading strategy inspired by the "Spreader 2" MetaTrader expert.
/// Looks for short term mean-reverting moves between two correlated symbols
/// and trades the spread once correlation and volatility filters align.
/// </summary>
public class Spreader2Strategy : Strategy
{
private readonly StrategyParam<Security> _secondSecurityParam;
private readonly StrategyParam<decimal> _primaryVolumeParam;
private readonly StrategyParam<decimal> _targetProfitParam;
private readonly StrategyParam<int> _shiftParam;
private readonly StrategyParam<DataType> _candleTypeParam;
private readonly StrategyParam<int> _dayBarsParam;
private readonly Queue<ICandleMessage> _firstPending = new();
private readonly Queue<ICandleMessage> _secondPending = new();
private readonly List<decimal> _firstCloses = new();
private readonly List<decimal> _secondCloses = new();
private static readonly object _sync = new();
private decimal _lastFirstClose;
private decimal _lastSecondClose;
private decimal _firstEntryPrice;
private decimal _secondEntryPrice;
private decimal _secondPosition;
private Portfolio _secondPortfolio;
private bool _contractsMatch = true;
/// <summary>
/// Secondary security involved in the spread.
/// </summary>
public Security SecondSecurity
{
get => _secondSecurityParam.Value;
set => _secondSecurityParam.Value = value;
}
/// <summary>
/// Trading volume for the primary security.
/// </summary>
public decimal PrimaryVolume
{
get => _primaryVolumeParam.Value;
set => _primaryVolumeParam.Value = value;
}
/// <summary>
/// Target profit (absolute money) for the combined position.
/// </summary>
public decimal TargetProfit
{
get => _targetProfitParam.Value;
set => _targetProfitParam.Value = value;
}
/// <summary>
/// Number of bars between comparison points.
/// </summary>
public int ShiftLength
{
get => _shiftParam.Value;
set => _shiftParam.Value = value;
}
/// <summary>
/// Number of intraday bars considered when calculating daily statistics.
/// </summary>
public int DayBars
{
get => _dayBarsParam.Value;
set => _dayBarsParam.Value = value;
}
/// <summary>
/// Candle type used for analysis.
/// </summary>
public DataType CandleType
{
get => _candleTypeParam.Value;
set => _candleTypeParam.Value = value;
}
/// <summary>
/// Initializes a new instance of the <see cref="Spreader2Strategy"/> class.
/// </summary>
public Spreader2Strategy()
{
_secondSecurityParam = Param<Security>(nameof(SecondSecurity))
.SetDisplay("Second Symbol", "Secondary instrument for the spread trade", "General")
.SetRequired();
_primaryVolumeParam = Param(nameof(PrimaryVolume), 1m)
.SetGreaterThanZero()
.SetDisplay("Primary Volume", "Order volume for the primary symbol", "Trading")
.SetOptimize(0.5m, 3m, 0.5m);
_targetProfitParam = Param(nameof(TargetProfit), 100m)
.SetGreaterThanZero()
.SetDisplay("Target Profit", "Total profit target for the pair position", "Risk")
.SetOptimize(20m, 200m, 20m);
_shiftParam = Param(nameof(ShiftLength), 6)
.SetGreaterThanZero()
.SetDisplay("Shift Length", "Number of bars between comparison points", "Logic")
.SetOptimize(10, 60, 10);
_candleTypeParam = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
.SetDisplay("Candle Type", "Timeframe for pair analysis", "General");
_dayBarsParam = Param(nameof(DayBars), 288)
.SetGreaterThanZero()
.SetDisplay("Day Bars", "Number of intraday bars used for rolling statistics", "Data")
;
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
yield return (Security, CandleType);
yield return (SecondSecurity, CandleType);
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_firstPending.Clear();
_secondPending.Clear();
_firstCloses.Clear();
_secondCloses.Clear();
_lastFirstClose = 0m;
_lastSecondClose = 0m;
_firstEntryPrice = 0m;
_secondEntryPrice = 0m;
_secondPosition = 0m;
_secondPortfolio = null;
_contractsMatch = true;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
if (SecondSecurity == null)
throw new InvalidOperationException("Second security is not specified.");
_secondPortfolio = Portfolio ?? throw new InvalidOperationException("Portfolio is not specified.");
if (Security?.Multiplier != null && SecondSecurity?.Multiplier != null && Security.Multiplier != SecondSecurity.Multiplier)
{
LogWarning($"Contract size mismatch between {Security?.Code} and {SecondSecurity?.Code}. Trading disabled.");
_contractsMatch = false;
}
var primarySubscription = SubscribeCandles(CandleType);
primarySubscription
.Bind(ProcessPrimaryCandle)
.Start();
var secondarySubscription = SubscribeCandles(CandleType, security: SecondSecurity);
secondarySubscription
.Bind(ProcessSecondaryCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, primarySubscription);
DrawOwnTrades(area);
}
}
private void ProcessPrimaryCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
_lastFirstClose = candle.ClosePrice;
lock (_sync)
{
_firstPending.Enqueue(candle);
ProcessPendingCandles();
}
}
private void ProcessSecondaryCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
_lastSecondClose = candle.ClosePrice;
lock (_sync)
{
_secondPending.Enqueue(candle);
ProcessPendingCandles();
}
}
private void ProcessPendingCandles()
{
while (_firstPending.Count > 0 && _secondPending.Count > 0)
{
var first = _firstPending.Peek();
var second = _secondPending.Peek();
if (first is null)
{
_firstPending.Dequeue();
continue;
}
if (second is null)
{
_secondPending.Dequeue();
continue;
}
if (first.CloseTime < second.CloseTime)
{
_firstPending.Dequeue();
continue;
}
if (second.CloseTime < first.CloseTime)
{
_secondPending.Dequeue();
continue;
}
_firstPending.Dequeue();
_secondPending.Dequeue();
HandlePairedCandles(first, second);
}
}
private void HandlePairedCandles(ICandleMessage firstCandle, ICandleMessage secondCandle)
{
var maxHistory = Math.Max(DayBars, ShiftLength * 2) + 10;
AppendHistory(_firstCloses, firstCandle.ClosePrice, maxHistory);
AppendHistory(_secondCloses, secondCandle.ClosePrice, maxHistory);
if (!UpdateProfitCheck(firstCandle.ClosePrice, secondCandle.ClosePrice))
return;
if (!_contractsMatch)
return;
if (PrimaryVolume <= 0m)
return;
if (_firstCloses.Count <= ShiftLength * 2 || _secondCloses.Count <= ShiftLength * 2)
return;
if (_firstCloses.Count <= DayBars || _secondCloses.Count <= DayBars)
return;
var currentIndex = _firstCloses.Count - 1;
var secondIndex = _secondCloses.Count - 1;
var shift = ShiftLength;
var shiftIndex = currentIndex - shift;
var shiftIndex2 = currentIndex - (shift * 2);
var dayIndex = currentIndex - DayBars;
var secondShiftIndex = secondIndex - shift;
var secondShiftIndex2 = secondIndex - (shift * 2);
var secondDayIndex = secondIndex - DayBars;
if (shiftIndex < 0 || shiftIndex2 < 0 || dayIndex < 0)
return;
if (secondShiftIndex < 0 || secondShiftIndex2 < 0 || secondDayIndex < 0)
return;
var closeCur0 = _firstCloses[currentIndex];
var closeCurShift = _firstCloses[shiftIndex];
var closeCurShift2 = _firstCloses[shiftIndex2];
var closeCurDay = _firstCloses[dayIndex];
var closeSec0 = _secondCloses[secondIndex];
var closeSecShift = _secondCloses[secondShiftIndex];
var closeSecShift2 = _secondCloses[secondShiftIndex2];
var closeSecDay = _secondCloses[secondDayIndex];
// Use relative (percentage) moves so the ratio comparison works for instruments with different price scales.
var x1 = closeCurShift == 0m ? 0m : (closeCur0 - closeCurShift) / closeCurShift;
var x2 = closeCurShift2 == 0m ? 0m : (closeCurShift - closeCurShift2) / closeCurShift2;
var y1 = closeSecShift == 0m ? 0m : (closeSec0 - closeSecShift) / closeSecShift;
var y2 = closeSecShift2 == 0m ? 0m : (closeSecShift - closeSecShift2) / closeSecShift2;
if ((x1 * x2) > 0m)
{
LogInfo($"Trend detected on {Security?.Code}, skipping correlation check.");
return;
}
if ((y1 * y2) > 0m)
{
LogInfo($"Trend detected on {SecondSecurity?.Code}, skipping correlation check.");
return;
}
if ((x1 * y1) <= 0m)
{
LogInfo("Negative correlation detected. Waiting for better alignment.");
return;
}
var a = Math.Abs(x1) + Math.Abs(x2);
var b = Math.Abs(y1) + Math.Abs(y2);
if (b == 0m)
return;
var ratio = a / b;
if (ratio > 3m)
return;
if (ratio < 0.3m)
return;
var secondVolume = AdjustSecondaryVolume(ratio * PrimaryVolume);
if (secondVolume <= 0m)
{
LogInfo("Secondary volume too small after adjustment. Skipping trade.");
return;
}
var x3 = closeCurDay == 0m ? 0m : (closeCur0 - closeCurDay) / closeCurDay;
var y3 = closeSecDay == 0m ? 0m : (closeSec0 - closeSecDay) / closeSecDay;
var primarySide = x1 * b > y1 * a ? Sides.Buy : Sides.Sell;
var secondarySide = primarySide == Sides.Buy ? Sides.Sell : Sides.Buy;
if (primarySide == Sides.Buy && (x3 * b) < (y3 * a))
{
LogInfo("Buy signal rejected by daily confirmation check.");
return;
}
if (primarySide == Sides.Sell && (x3 * b) > (y3 * a))
{
LogInfo("Sell signal rejected by daily confirmation check.");
return;
}
OpenPair(primarySide, secondarySide, secondVolume);
}
private bool UpdateProfitCheck(decimal firstClose, decimal secondClose)
{
var primaryPosition = Position;
var hasSecondary = _secondPosition != 0m;
if (primaryPosition == 0m && !hasSecondary)
return true;
if (primaryPosition != 0m && !hasSecondary)
{
LogInfo("Secondary position missing. Closing primary exposure.");
ClosePrimaryPosition();
return false;
}
if (primaryPosition == 0m && hasSecondary)
{
var requiredSide = _secondPosition > 0m ? Sides.Sell : Sides.Buy;
LogInfo("Primary position missing. Opening trade to balance spread.");
OpenPrimary(requiredSide, PrimaryVolume);
return false;
}
if (_firstEntryPrice == 0m || _secondEntryPrice == 0m)
return false;
var primaryVolume = Math.Abs(primaryPosition);
var secondaryVolume = Math.Abs(_secondPosition);
var primaryProfit = primaryPosition > 0m
? (firstClose - _firstEntryPrice) * primaryVolume
: (_firstEntryPrice - firstClose) * primaryVolume;
var secondaryProfit = _secondPosition > 0m
? (secondClose - _secondEntryPrice) * secondaryVolume
: (_secondEntryPrice - secondClose) * secondaryVolume;
var totalProfit = primaryProfit + secondaryProfit;
if (totalProfit >= TargetProfit)
{
LogInfo($"Target profit reached ({totalProfit:F2}). Closing both legs.");
ClosePair();
}
return false;
}
private void OpenPair(Sides primarySide, Sides secondarySide, decimal secondaryVolume)
{
OpenSecondary(secondarySide, secondaryVolume);
OpenPrimary(primarySide, PrimaryVolume);
LogInfo($"Opened spread: {primarySide} {PrimaryVolume} {Security?.Code}, {secondarySide} {secondaryVolume} {SecondSecurity?.Code}.");
}
private void OpenPrimary(Sides side, decimal volume)
{
if (volume <= 0m)
return;
if (side == Sides.Buy)
BuyMarket(volume);
else
SellMarket(volume);
_firstEntryPrice = _lastFirstClose;
}
private void OpenSecondary(Sides side, decimal volume)
{
if (volume <= 0m || SecondSecurity == null || _secondPortfolio == null)
return;
var order = CreateOrder(side, _lastSecondClose, volume);
order.Type = OrderTypes.Market;
order.Security = SecondSecurity;
order.Portfolio = _secondPortfolio;
RegisterOrder(order);
_secondPosition = side == Sides.Buy ? volume : -volume;
_secondEntryPrice = _lastSecondClose;
}
private void ClosePair()
{
ClosePrimaryPosition();
CloseSecondaryPosition();
}
private void ClosePrimaryPosition()
{
var primaryPosition = Position;
if (primaryPosition > 0m)
SellMarket(primaryPosition);
else if (primaryPosition < 0m)
BuyMarket(Math.Abs(primaryPosition));
_firstEntryPrice = 0m;
}
private void CloseSecondaryPosition()
{
if (_secondPosition == 0m || SecondSecurity == null || _secondPortfolio == null)
return;
var side = _secondPosition > 0m ? Sides.Sell : Sides.Buy;
var volume = Math.Abs(_secondPosition);
var order = CreateOrder(side, _lastSecondClose, volume);
order.Type = OrderTypes.Market;
order.Security = SecondSecurity;
order.Portfolio = _secondPortfolio;
RegisterOrder(order);
_secondPosition = 0m;
_secondEntryPrice = 0m;
}
private decimal AdjustSecondaryVolume(decimal requestedVolume)
{
if (SecondSecurity == null)
return 0m;
var volume = Math.Abs(requestedVolume);
var step = SecondSecurity.VolumeStep ?? 0m;
if (step > 0m)
volume = decimal.Floor(volume / step) * step;
var min = SecondSecurity.MinVolume ?? 0m;
if (min > 0m && volume < min)
return 0m;
var max = SecondSecurity.MaxVolume;
if (max != null && volume > max.Value)
volume = max.Value;
return volume;
}
private static void AppendHistory(List<decimal> storage, decimal value, int maxHistory)
{
storage.Add(value);
if (storage.Count > maxHistory)
storage.RemoveAt(0);
}
}
import clr
import math
import threading
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan, Math
from collections import deque
from StockSharp.Messages import DataType, CandleStates, OrderTypes, Sides
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Security
from datatype_extensions import *
class spreader2_strategy(Strategy):
"""
Pair trading strategy inspired by the 'Spreader 2' MetaTrader expert.
Looks for short term mean-reverting moves between two correlated symbols
and trades the spread once correlation and volatility filters align.
"""
def __init__(self):
super(spreader2_strategy, self).__init__()
self._second_security_param = self.Param[Security]("SecondSecurity", None) \
.SetDisplay("Second Symbol", "Secondary instrument for the spread trade", "General") \
.SetRequired()
self._primary_volume_param = self.Param("PrimaryVolume", 1.0) \
.SetGreaterThanZero() \
.SetDisplay("Primary Volume", "Order volume for the primary symbol", "Trading") \
.SetOptimize(0.5, 3.0, 0.5)
self._target_profit_param = self.Param("TargetProfit", 100.0) \
.SetGreaterThanZero() \
.SetDisplay("Target Profit", "Total profit target for the pair position", "Risk") \
.SetOptimize(20.0, 200.0, 20.0)
self._shift_param = self.Param("ShiftLength", 6) \
.SetGreaterThanZero() \
.SetDisplay("Shift Length", "Number of bars between comparison points", "Logic") \
.SetOptimize(10, 60, 10)
self._candle_type_param = self.Param("CandleType", tf(5)) \
.SetDisplay("Candle Type", "Timeframe for pair analysis", "General")
self._day_bars_param = self.Param("DayBars", 288) \
.SetGreaterThanZero() \
.SetDisplay("Day Bars", "Number of intraday bars used for rolling statistics", "Data")
# Internal state
self._first_pending = deque()
self._second_pending = deque()
self._first_closes = []
self._second_closes = []
self._lock = threading.Lock()
self._last_first_close = 0.0
self._last_second_close = 0.0
self._first_entry_price = 0.0
self._second_entry_price = 0.0
self._second_position = 0.0
self._second_portfolio = None
self._contracts_match = True
@property
def SecondSecurity(self):
return self._second_security_param.Value
@SecondSecurity.setter
def SecondSecurity(self, value):
self._second_security_param.Value = value
@property
def PrimaryVolume(self):
return self._primary_volume_param.Value
@PrimaryVolume.setter
def PrimaryVolume(self, value):
self._primary_volume_param.Value = value
@property
def TargetProfit(self):
return self._target_profit_param.Value
@TargetProfit.setter
def TargetProfit(self, value):
self._target_profit_param.Value = value
@property
def ShiftLength(self):
return self._shift_param.Value
@ShiftLength.setter
def ShiftLength(self, value):
self._shift_param.Value = value
@property
def CandleType(self):
return self._candle_type_param.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type_param.Value = value
@property
def DayBars(self):
return self._day_bars_param.Value
@DayBars.setter
def DayBars(self, value):
self._day_bars_param.Value = value
def GetWorkingSecurities(self):
return [
(self.Security, self.CandleType),
(self.SecondSecurity, self.CandleType)
]
def OnReseted(self):
super(spreader2_strategy, self).OnReseted()
self._first_pending.clear()
self._second_pending.clear()
self._first_closes.clear()
self._second_closes.clear()
self._last_first_close = 0.0
self._last_second_close = 0.0
self._first_entry_price = 0.0
self._second_entry_price = 0.0
self._second_position = 0.0
self._second_portfolio = None
self._contracts_match = True
def OnStarted2(self, time):
super(spreader2_strategy, self).OnStarted2(time)
if self.SecondSecurity is None:
raise Exception("Second security is not specified.")
self._second_portfolio = self.Portfolio
if self._second_portfolio is None:
raise Exception("Portfolio is not specified.")
sec = self.Security
sec2 = self.SecondSecurity
if sec is not None and sec2 is not None \
and sec.Multiplier is not None and sec2.Multiplier is not None \
and sec.Multiplier != sec2.Multiplier:
self.LogWarning("Contract size mismatch between {0} and {1}. Trading disabled.".format(
sec.Code, sec2.Code))
self._contracts_match = False
primary_subscription = self.SubscribeCandles(self.CandleType)
primary_subscription.Bind(self._process_primary_candle).Start()
secondary_subscription = self.SubscribeCandles(self.CandleType, security=self.SecondSecurity)
secondary_subscription.Bind(self._process_secondary_candle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, primary_subscription)
self.DrawOwnTrades(area)
def _process_primary_candle(self, candle):
if candle.State != CandleStates.Finished:
return
self._last_first_close = float(candle.ClosePrice)
with self._lock:
self._first_pending.append(candle)
self._process_pending_candles()
def _process_secondary_candle(self, candle):
if candle.State != CandleStates.Finished:
return
self._last_second_close = float(candle.ClosePrice)
with self._lock:
self._second_pending.append(candle)
self._process_pending_candles()
def _process_pending_candles(self):
while len(self._first_pending) > 0 and len(self._second_pending) > 0:
first = self._first_pending[0]
second = self._second_pending[0]
if first is None:
self._first_pending.popleft()
continue
if second is None:
self._second_pending.popleft()
continue
if first.CloseTime < second.CloseTime:
self._first_pending.popleft()
continue
if second.CloseTime < first.CloseTime:
self._second_pending.popleft()
continue
self._first_pending.popleft()
self._second_pending.popleft()
self._handle_paired_candles(first, second)
def _handle_paired_candles(self, first_candle, second_candle):
max_history = max(self.DayBars, self.ShiftLength * 2) + 10
self._append_history(self._first_closes, float(first_candle.ClosePrice), max_history)
self._append_history(self._second_closes, float(second_candle.ClosePrice), max_history)
if not self._update_profit_check(float(first_candle.ClosePrice), float(second_candle.ClosePrice)):
return
if not self._contracts_match:
return
if self.PrimaryVolume <= 0:
return
shift = self.ShiftLength
if len(self._first_closes) <= shift * 2 or len(self._second_closes) <= shift * 2:
return
if len(self._first_closes) <= self.DayBars or len(self._second_closes) <= self.DayBars:
return
current_index = len(self._first_closes) - 1
second_index = len(self._second_closes) - 1
shift_index = current_index - shift
shift_index2 = current_index - (shift * 2)
day_index = current_index - self.DayBars
second_shift_index = second_index - shift
second_shift_index2 = second_index - (shift * 2)
second_day_index = second_index - self.DayBars
if shift_index < 0 or shift_index2 < 0 or day_index < 0:
return
if second_shift_index < 0 or second_shift_index2 < 0 or second_day_index < 0:
return
close_cur0 = self._first_closes[current_index]
close_cur_shift = self._first_closes[shift_index]
close_cur_shift2 = self._first_closes[shift_index2]
close_cur_day = self._first_closes[day_index]
close_sec0 = self._second_closes[second_index]
close_sec_shift = self._second_closes[second_shift_index]
close_sec_shift2 = self._second_closes[second_shift_index2]
close_sec_day = self._second_closes[second_day_index]
# Use relative (percentage) moves so the ratio comparison works
# for instruments with different price scales.
x1 = 0.0 if close_cur_shift == 0 else (close_cur0 - close_cur_shift) / close_cur_shift
x2 = 0.0 if close_cur_shift2 == 0 else (close_cur_shift - close_cur_shift2) / close_cur_shift2
y1 = 0.0 if close_sec_shift == 0 else (close_sec0 - close_sec_shift) / close_sec_shift
y2 = 0.0 if close_sec_shift2 == 0 else (close_sec_shift - close_sec_shift2) / close_sec_shift2
if (x1 * x2) > 0:
sec = self.Security
self.LogInfo("Trend detected on {0}, skipping correlation check.".format(
sec.Code if sec is not None else "?"))
return
if (y1 * y2) > 0:
sec2 = self.SecondSecurity
self.LogInfo("Trend detected on {0}, skipping correlation check.".format(
sec2.Code if sec2 is not None else "?"))
return
if (x1 * y1) <= 0:
self.LogInfo("Negative correlation detected. Waiting for better alignment.")
return
a = abs(x1) + abs(x2)
b = abs(y1) + abs(y2)
if b == 0:
return
ratio = a / b
if ratio > 3.0:
return
if ratio < 0.3:
return
second_volume = self._adjust_secondary_volume(ratio * self.PrimaryVolume)
if second_volume <= 0:
self.LogInfo("Secondary volume too small after adjustment. Skipping trade.")
return
x3 = 0.0 if close_cur_day == 0 else (close_cur0 - close_cur_day) / close_cur_day
y3 = 0.0 if close_sec_day == 0 else (close_sec0 - close_sec_day) / close_sec_day
primary_side = Sides.Buy if x1 * b > y1 * a else Sides.Sell
secondary_side = Sides.Sell if primary_side == Sides.Buy else Sides.Buy
if primary_side == Sides.Buy and (x3 * b) < (y3 * a):
self.LogInfo("Buy signal rejected by daily confirmation check.")
return
if primary_side == Sides.Sell and (x3 * b) > (y3 * a):
self.LogInfo("Sell signal rejected by daily confirmation check.")
return
self._open_pair(primary_side, secondary_side, second_volume)
def _update_profit_check(self, first_close, second_close):
primary_position = float(self.Position)
has_secondary = self._second_position != 0
if primary_position == 0 and not has_secondary:
return True
if primary_position != 0 and not has_secondary:
self.LogInfo("Secondary position missing. Closing primary exposure.")
self._close_primary_position()
return False
if primary_position == 0 and has_secondary:
required_side = Sides.Sell if self._second_position > 0 else Sides.Buy
self.LogInfo("Primary position missing. Opening trade to balance spread.")
self._open_primary(required_side, self.PrimaryVolume)
return False
if self._first_entry_price == 0 or self._second_entry_price == 0:
return False
primary_volume = abs(primary_position)
secondary_volume = abs(self._second_position)
if primary_position > 0:
primary_profit = (first_close - self._first_entry_price) * primary_volume
else:
primary_profit = (self._first_entry_price - first_close) * primary_volume
if self._second_position > 0:
secondary_profit = (second_close - self._second_entry_price) * secondary_volume
else:
secondary_profit = (self._second_entry_price - second_close) * secondary_volume
total_profit = primary_profit + secondary_profit
if total_profit >= self.TargetProfit:
self.LogInfo("Target profit reached ({0:.2f}). Closing both legs.".format(total_profit))
self._close_pair()
return False
def _open_pair(self, primary_side, secondary_side, secondary_volume):
self._open_secondary(secondary_side, secondary_volume)
self._open_primary(primary_side, self.PrimaryVolume)
sec = self.Security
sec2 = self.SecondSecurity
self.LogInfo("Opened spread: {0} {1} {2}, {3} {4} {5}.".format(
primary_side, self.PrimaryVolume,
sec.Code if sec is not None else "?",
secondary_side, secondary_volume,
sec2.Code if sec2 is not None else "?"))
def _open_primary(self, side, volume):
if volume <= 0:
return
if side == Sides.Buy:
self.BuyMarket(volume)
else:
self.SellMarket(volume)
self._first_entry_price = self._last_first_close
def _open_secondary(self, side, volume):
if volume <= 0 or self.SecondSecurity is None or self._second_portfolio is None:
return
order = self.CreateOrder(side, self._last_second_close, volume)
order.Type = OrderTypes.Market
order.Security = self.SecondSecurity
order.Portfolio = self._second_portfolio
self.RegisterOrder(order)
self._second_position = volume if side == Sides.Buy else -volume
self._second_entry_price = self._last_second_close
def _close_pair(self):
self._close_primary_position()
self._close_secondary_position()
def _close_primary_position(self):
primary_position = float(self.Position)
if primary_position > 0:
self.SellMarket(primary_position)
elif primary_position < 0:
self.BuyMarket(abs(primary_position))
self._first_entry_price = 0.0
def _close_secondary_position(self):
if self._second_position == 0 or self.SecondSecurity is None or self._second_portfolio is None:
return
side = Sides.Sell if self._second_position > 0 else Sides.Buy
volume = abs(self._second_position)
order = self.CreateOrder(side, self._last_second_close, volume)
order.Type = OrderTypes.Market
order.Security = self.SecondSecurity
order.Portfolio = self._second_portfolio
self.RegisterOrder(order)
self._second_position = 0.0
self._second_entry_price = 0.0
def _adjust_secondary_volume(self, requested_volume):
if self.SecondSecurity is None:
return 0.0
volume = abs(requested_volume)
step = self.SecondSecurity.VolumeStep
if step is not None:
step = float(step)
if step > 0:
volume = math.floor(volume / step) * step
min_vol = self.SecondSecurity.MinVolume
if min_vol is not None:
min_vol = float(min_vol)
if min_vol > 0 and volume < min_vol:
return 0.0
max_vol = self.SecondSecurity.MaxVolume
if max_vol is not None:
max_vol = float(max_vol)
if volume > max_vol:
volume = max_vol
return volume
@staticmethod
def _append_history(storage, value, max_history):
storage.append(value)
if len(storage) > max_history:
storage.pop(0)
def CreateClone(self):
return spreader2_strategy()