マルチカレンシー・オーバーレイ・ヘッジ戦略
MetaTrader 4 エキスパートアドバイザー "Multicurrency hedge example EA (overlay hedge)" を StockSharp のハイレベル API に変換したものです。
概要
- ユーザーが提供する外国為替シンボルのユニバースで動作し、すべてのユニークなペアを監視します。
- ローリングのピアソン相関と ATR レシオを計算して、どのシンボルが一緒に動くかを判断し、両方のレッグのサイズを決定します。
- 合成価格オーバーレイを構築して、メインインストゥルメントが設定可能なしきい値を超えて相関パートナーから乖離するタイミングを検出します。
- 相関の符号とオーバーレイの方向に応じてヘッジブロック(買い/売り、買い/買い、売り/買い、売り/売り)を開きます。
- ポイントまたはポートフォリオ通貨での相互テイクプロフィット目標が達成されたら、ブロック全体を閉じます。
ワークフロー
- ユニバース内のすべての銘柄の完了したローソク足にサブスクライブし、最新の high/low/close の値を保存します。
- ヘッジを送信する前にスプレッドフィルターを適用するため、各銘柄の Level1 クォートにサブスクライブします。
- 1日1回(デフォルトでサーバー時間 01:00)取引可能なペアのリストを再構築します:
- 絶対相関が設定されたしきい値を超えるペアのみを保持します。
- メインレッグのボリュームをスケーリングするために ATR レシオを計算します。
- 完了したローソク足ごとにオーバーレイ距離を確認します:
- 正の相関 ⇒ 乖離が
-OverlayThresholdポイントを下回る場合、メインを買いサブを売る;+OverlayThresholdポイントを上回る場合、メインを売りサブを買う。 - 負の相関 ⇒ 負のしきい値を下回る場合、両方のレッグを買い;正のしきい値を上回る場合、両方のレッグを売る。
- 正の相関 ⇒ 乖離が
- オープンなヘッジブロックを追跡し、集計利益がテイクプロフィット条件のいずれかに達したら閉じます。
パラメーター
| パラメーター | 説明 | デフォルト |
|---|---|---|
Universe |
スキャンする Security オブジェクトのコレクション。少なくとも2つのエントリーが必要。 |
空 |
CandleType |
計算に使用するローソク足データタイプ。 | 1分時間軸 |
RangeLength |
価格エンベロープの計算に使用するバー数。 | 400 |
CorrelationLookback |
ピアソン相関に使用するバー数。 | 500 |
AtrLookback |
ATR レシオサイジングに使用するバー数。 | 200 |
CorrelationThreshold |
ペアを保持するための最小絶対相関(0–1)。 | 0.90 |
OverlayThreshold |
メインインストゥルメントのステップを使用して測定したオーバーレイ距離(ポイント)。 | 100 |
TakeProfitByPoints / TakeProfitPoints |
ポイントベースの相互テイクプロフィットを有効化・設定します。 | true / 10 |
TakeProfitByCurrency / TakeProfitCurrency |
通貨ベースの相互テイクプロフィットを有効化・設定します。 | false / 10 |
MaxOpenPairs |
同時に開く最大ヘッジブロック数。 | 10 |
BaseVolume |
セカンダリレッグのボリューム(メインレッグボリューム = BaseVolume * ATR ratio)。 |
1 |
RecalculationHour |
相関が再計算される時刻(時)。 | 1 |
MaxSpread |
レッグごとに許可される最大 bid-ask スプレッド(ポイント)。 | 10 |
データ要件
- 指定した
CandleTypeでUniverse内のすべての銘柄の過去と実績のローソク足データ。 - スプレッドを検証するための各銘柄の Level1 クォートアップデート。
- 注文登録のためのポートフォリオ情報。
使用上の注意
- 戦略はユニバースを自動設定しません;開始前に希望する外国為替シンボルを渡してください。
- MetaTrader のサイジングロジックを模倣するには、
BaseVolumeをセカンダリレッグのロットサイズと同じに保ちます。メインレッグのボリュームは ATR レシオによって自動的にスケーリングされます。 - スプレッドデータが利用できない場合、最初の注文板スナップショットが届くまで新しいエントリーはスキップされます。
- 決済ロジックは、インストゥルメントの価格ステップとステップ価格を使用して各レッグの符号付き動きを組み合わせることで相互利益を推定します。
オリジナル EA との違い
- タイマーベースのポーリングの代わりに StockSharp のサブスクリプション(
SubscribeCandles、SubscribeLevel1)を使用します。 - テイクプロフィットロジックは生のトレード利益/手数料ではなく、平均化された価格ステップ情報で実装されています。
- 明示的なユニバースパラメーターが必要で、StockSharp がサポートするインストゥルメントの任意のサブセットで戦略を実行できます。
- 注文執行はトレーサビリティのためのヘッジごとのコメント付きの StockSharp マーケット注文を通じて行われます。
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 StockSharp.Algo;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Multicurrency overlay hedge strategy converted from MQL.
/// Scans a universe of forex symbols, pairs positively/negatively correlated instruments and opens hedged blocks when the overlay threshold is breached.
/// </summary>
public class MulticurrencyOverlayHedgeStrategy : Strategy
{
private readonly StrategyParam<IEnumerable<Security>> _universe;
private readonly StrategyParam<DataType> _candleType;
private readonly StrategyParam<int> _rangeLength;
private readonly StrategyParam<int> _correlationLookback;
private readonly StrategyParam<int> _atrLookback;
private readonly StrategyParam<decimal> _correlationThreshold;
private readonly StrategyParam<decimal> _overlayThreshold;
private readonly StrategyParam<bool> _takeProfitByPoints;
private readonly StrategyParam<decimal> _takeProfitPoints;
private readonly StrategyParam<bool> _takeProfitByCurrency;
private readonly StrategyParam<decimal> _takeProfitCurrency;
private readonly StrategyParam<int> _maxOpenPairs;
private readonly StrategyParam<decimal> _baseVolume;
private readonly StrategyParam<int> _recalcHour;
private readonly StrategyParam<decimal> _maxSpread;
private readonly Dictionary<Security, SecurityContext> _contexts = new();
private readonly Dictionary<HedgePairKey, HedgeState> _pairs = new();
private readonly Dictionary<Security, List<HedgePairKey>> _pairsBySecurity = new();
private readonly List<Security> _universeList = new();
private DateTime _lastRecalcDay = DateTime.MinValue;
/// <summary>
/// Securities used for correlation scan.
/// </summary>
public IEnumerable<Security> Universe
{
get => _universe.Value;
set => _universe.Value = value;
}
/// <summary>
/// Candle type used for all calculations.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Lookback window used to compute price ranges.
/// </summary>
public int RangeLength
{
get => _rangeLength.Value;
set => _rangeLength.Value = value;
}
/// <summary>
/// Number of bars used to measure correlation.
/// </summary>
public int CorrelationLookback
{
get => _correlationLookback.Value;
set => _correlationLookback.Value = value;
}
/// <summary>
/// Number of bars used to compute ATR ratio.
/// </summary>
public int AtrLookback
{
get => _atrLookback.Value;
set => _atrLookback.Value = value;
}
/// <summary>
/// Minimum absolute correlation required to create a pair.
/// </summary>
public decimal CorrelationThreshold
{
get => _correlationThreshold.Value;
set => _correlationThreshold.Value = value;
}
/// <summary>
/// Overlay threshold in points for triggering a hedge.
/// </summary>
public decimal OverlayThreshold
{
get => _overlayThreshold.Value;
set => _overlayThreshold.Value = value;
}
/// <summary>
/// Enables point based mutual take profit.
/// </summary>
public bool TakeProfitByPoints
{
get => _takeProfitByPoints.Value;
set => _takeProfitByPoints.Value = value;
}
/// <summary>
/// Target points required to close the hedge block.
/// </summary>
public decimal TakeProfitPoints
{
get => _takeProfitPoints.Value;
set => _takeProfitPoints.Value = value;
}
/// <summary>
/// Enables currency based mutual take profit.
/// </summary>
public bool TakeProfitByCurrency
{
get => _takeProfitByCurrency.Value;
set => _takeProfitByCurrency.Value = value;
}
/// <summary>
/// Currency profit threshold for closing the hedge block.
/// </summary>
public decimal TakeProfitCurrency
{
get => _takeProfitCurrency.Value;
set => _takeProfitCurrency.Value = value;
}
/// <summary>
/// Maximum number of simultaneously open hedge pairs.
/// </summary>
public int MaxOpenPairs
{
get => _maxOpenPairs.Value;
set => _maxOpenPairs.Value = value;
}
/// <summary>
/// Base volume used for the secondary leg.
/// </summary>
public decimal BaseVolume
{
get => _baseVolume.Value;
set => _baseVolume.Value = value;
}
/// <summary>
/// Hour of the day when correlations are recalculated.
/// </summary>
public int RecalculationHour
{
get => _recalcHour.Value;
set => _recalcHour.Value = value;
}
/// <summary>
/// Maximum allowed spread in points for each leg.
/// </summary>
public decimal MaxSpread
{
get => _maxSpread.Value;
set => _maxSpread.Value = value;
}
/// <summary>
/// Initializes a new instance of <see cref="MulticurrencyOverlayHedgeStrategy"/>.
/// </summary>
public MulticurrencyOverlayHedgeStrategy()
{
_universe = Param<IEnumerable<Security>>(nameof(Universe), Array.Empty<Security>())
.SetDisplay("Universe", "Collection of forex symbols", "General");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1).TimeFrame())
.SetDisplay("Candle Type", "Time frame used for analysis", "General");
_rangeLength = Param(nameof(RangeLength), 400)
.SetGreaterThanZero()
.SetDisplay("Range Length", "Bars used to build price envelopes", "Parameters");
_correlationLookback = Param(nameof(CorrelationLookback), 500)
.SetGreaterThanZero()
.SetDisplay("Correlation Lookback", "Bars used for Pearson correlation", "Parameters");
_atrLookback = Param(nameof(AtrLookback), 200)
.SetGreaterThanZero()
.SetDisplay("ATR Lookback", "Bars used to compute ATR ratio", "Parameters");
_correlationThreshold = Param(nameof(CorrelationThreshold), 0.9m)
.SetDisplay("Correlation Threshold", "Absolute correlation required for pairing", "Parameters");
_overlayThreshold = Param(nameof(OverlayThreshold), 100m)
.SetGreaterThanZero()
.SetDisplay("Overlay Threshold", "Distance in points to trigger hedging", "Trading");
_takeProfitByPoints = Param(nameof(TakeProfitByPoints), true)
.SetDisplay("TP by Points", "Enable point based take profit", "Risk");
_takeProfitPoints = Param(nameof(TakeProfitPoints), 10m)
.SetGreaterThanZero()
.SetDisplay("Points Target", "Mutual take profit in points", "Risk");
_takeProfitByCurrency = Param(nameof(TakeProfitByCurrency), false)
.SetDisplay("TP by Currency", "Enable currency based take profit", "Risk");
_takeProfitCurrency = Param(nameof(TakeProfitCurrency), 10m)
.SetGreaterThanZero()
.SetDisplay("Currency Target", "Mutual take profit in account currency", "Risk");
_maxOpenPairs = Param(nameof(MaxOpenPairs), 10)
.SetGreaterThanZero()
.SetDisplay("Max Pairs", "Maximum simultaneously open hedges", "Risk");
_baseVolume = Param(nameof(BaseVolume), 1m)
.SetGreaterThanZero()
.SetDisplay("Base Volume", "Secondary leg volume in lots", "Trading");
_recalcHour = Param(nameof(RecalculationHour), 1)
.SetDisplay("Recalc Hour", "Hour to rebuild pair statistics", "Trading");
_maxSpread = Param(nameof(MaxSpread), 10m)
.SetGreaterThanZero()
.SetDisplay("Max Spread", "Max allowed spread in points", "Trading");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
var universe = Universe;
if (universe == null)
yield break;
foreach (var security in universe)
{
if (security == null)
continue;
yield return (security, CandleType);
}
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_contexts.Clear();
_pairs.Clear();
_pairsBySecurity.Clear();
_universeList.Clear();
_lastRecalcDay = DateTime.MinValue;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
var universe = Universe;
if (universe == null)
throw new InvalidOperationException("Universe must be configured before starting the strategy.");
_universeList.Clear();
foreach (var security in universe)
{
if (security == null)
continue;
if (!_universeList.Contains(security))
_universeList.Add(security);
}
if (_universeList.Count < 2)
throw new InvalidOperationException("Universe must contain at least two securities.");
foreach (var security in _universeList)
{
var correlationCapacity = Math.Max(2, CorrelationLookback);
var context = new SecurityContext(security, correlationCapacity, RangeLength, AtrLookback);
_contexts[security] = context;
_pairsBySecurity[security] = new List<HedgePairKey>();
// Subscribe to finished candles for this security.
SubscribeCandles(CandleType, true, security)
.Bind(candle => ProcessCandle(candle, security))
.Start();
// Track best bid/ask for spread filtering.
SubscribeLevel1(security)
.Bind(message => context.UpdateLevel1(message))
.Start();
}
StartProtection(null, null);
}
private void ProcessCandle(ICandleMessage candle, Security security)
{
if (candle.State != CandleStates.Finished)
return;
var context = _contexts[security];
context.Update(candle);
if (ShouldRecalculate(candle))
RecalculatePairs();
ManageOpenHedges();
if (_pairsBySecurity.TryGetValue(security, out var pairs))
{
for (var i = 0; i < pairs.Count; i++)
{
TryOpenHedge(pairs[i]);
}
}
}
private bool ShouldRecalculate(ICandleMessage candle)
{
var day = candle.OpenTime.Date;
if (day == _lastRecalcDay)
return false;
if (candle.OpenTime.Hour < RecalculationHour)
return false;
_lastRecalcDay = day;
return true;
}
private void RecalculatePairs()
{
foreach (var list in _pairsBySecurity.Values)
list.Clear();
var count = _universeList.Count;
for (var i = 0; i < count; i++)
{
var first = _universeList[i];
var firstContext = _contexts[first];
if (!firstContext.HasCorrelationData(CorrelationLookback))
continue;
for (var j = i + 1; j < count; j++)
{
var second = _universeList[j];
var secondContext = _contexts[second];
if (!secondContext.HasCorrelationData(CorrelationLookback))
continue;
var correlation = CalculateCorrelation(firstContext, secondContext);
var absCorrelation = Math.Abs(correlation);
if (absCorrelation < CorrelationThreshold)
continue;
var atrRatio = CalculateAtrRatio(firstContext, secondContext);
if (atrRatio <= 0m)
continue;
var key = new HedgePairKey(first, second);
if (!_pairs.TryGetValue(key, out var state))
{
state = new HedgeState(key);
_pairs[key] = state;
}
state.IsPositive = correlation >= 0m;
state.AtrRatio = atrRatio;
_pairsBySecurity[first].Add(key);
_pairsBySecurity[second].Add(key);
}
}
var toRemove = new List<HedgePairKey>();
foreach (var pair in _pairs)
{
var key = pair.Key;
var state = pair.Value;
if (state.IsOpen)
continue;
if (!_pairsBySecurity.TryGetValue(key.First, out var list) || !list.Contains(key))
toRemove.Add(key);
}
for (var i = 0; i < toRemove.Count; i++)
_pairs.Remove(toRemove[i]);
}
private void ManageOpenHedges()
{
foreach (var pair in _pairs)
{
var state = pair.Value;
if (!state.IsOpen)
continue;
var points = CalculatePoints(state);
if (TakeProfitByPoints && points >= TakeProfitPoints)
{
CloseHedge(state, "TP_POINTS");
continue;
}
var currency = CalculateCurrency(state);
if (TakeProfitByCurrency && currency >= TakeProfitCurrency)
CloseHedge(state, "TP_CURRENCY");
}
}
private void TryOpenHedge(HedgePairKey key)
{
if (!_pairs.TryGetValue(key, out var state))
return;
if (state.IsOpen)
return;
var firstContext = _contexts[key.First];
var secondContext = _contexts[key.Second];
if (!firstContext.HasRangeData(RangeLength) || !secondContext.HasRangeData(RangeLength))
return;
if (!IsSecurityAvailable(key.First) || !IsSecurityAvailable(key.Second))
return;
if (MaxOpenPairs > 0 && GetOpenPairsCount() >= MaxOpenPairs)
return;
if (!IsSpreadWithinLimit(firstContext) || !IsSpreadWithinLimit(secondContext))
return;
var action = DetermineAction(state, firstContext, secondContext);
if (action == HedgeActions.None)
return;
var baseVolume = BaseVolume;
if (baseVolume <= 0m)
return;
var scaledVolume = baseVolume * state.AtrRatio;
if (scaledVolume <= 0m)
return;
var directions = GetDirections(action);
var targetFirst = directions.dirFirst * scaledVolume;
var targetSecond = directions.dirSecond * baseVolume;
TradeToTarget(key.First, targetFirst, state.Tag);
TradeToTarget(key.Second, targetSecond, state.Tag);
state.Dir1 = directions.dirFirst;
state.Dir2 = directions.dirSecond;
state.Volume1 = scaledVolume;
state.Volume2 = baseVolume;
state.Entry1 = firstContext.LastClose;
state.Entry2 = secondContext.LastClose;
state.IsOpen = true;
}
private bool IsSecurityAvailable(Security security)
{
foreach (var pair in _pairs)
{
var state = pair.Value;
if (!state.IsOpen)
continue;
if (pair.Key.First == security || pair.Key.Second == security)
return false;
}
return true;
}
private int GetOpenPairsCount()
{
var count = 0;
foreach (var pair in _pairs)
{
if (pair.Value.IsOpen)
count++;
}
return count;
}
private bool IsSpreadWithinLimit(SecurityContext context)
{
if (MaxSpread <= 0m)
return true;
var spread = context.GetSpreadPoints();
if (spread == decimal.MaxValue)
return true;
return spread <= MaxSpread;
}
private HedgeActions DetermineAction(HedgeState state, SecurityContext first, SecurityContext second)
{
var highMain = first.GetHigh(RangeLength);
var lowMain = first.GetLow(RangeLength);
if (highMain <= lowMain)
return HedgeActions.None;
decimal subHigh;
decimal subLow;
if (state.IsPositive)
{
subHigh = second.GetHigh(RangeLength);
subLow = second.GetLow(RangeLength);
}
else
{
subHigh = second.GetLow(RangeLength);
subLow = second.GetHigh(RangeLength);
}
if (subHigh <= subLow)
return HedgeActions.None;
var mainCenter = (highMain + lowMain) / 2m;
var subCenter = (subHigh + subLow) / 2m;
var denominator = subHigh - subLow;
if (denominator == 0m)
return HedgeActions.None;
var pipsRatio = (highMain - lowMain) / denominator;
if (pipsRatio == 0m)
return HedgeActions.None;
var subCloseOffset = second.LastClose - subCenter;
var syntheticClose = mainCenter + subCloseOffset * pipsRatio;
var step = first.Security.PriceStep ?? 0m;
if (step <= 0m)
step = 1m;
var hedgeRange = (first.LastClose - syntheticClose) / step;
if (hedgeRange < -OverlayThreshold)
return state.IsPositive ? HedgeActions.BuyMainSellSub : HedgeActions.BuyBoth;
if (hedgeRange > OverlayThreshold)
return state.IsPositive ? HedgeActions.SellMainBuySub : HedgeActions.SellBoth;
return HedgeActions.None;
}
private (int dirFirst, int dirSecond) GetDirections(HedgeActions action)
{
return action switch
{
HedgeActions.BuyMainSellSub => (1, -1),
HedgeActions.SellMainBuySub => (-1, 1),
HedgeActions.BuyBoth => (1, 1),
HedgeActions.SellBoth => (-1, -1),
_ => (0, 0)
};
}
private void TradeToTarget(Security security, decimal targetVolume, string tag)
{
if (Portfolio == null)
return;
var current = GetPositionValue(security, Portfolio) ?? 0m;
var diff = targetVolume - current;
if (Math.Abs(diff) < 1e-6m)
return;
var order = new Order
{
Security = security,
Portfolio = Portfolio,
Volume = Math.Abs(diff),
Side = diff > 0m ? Sides.Buy : Sides.Sell,
Type = OrderTypes.Market,
Comment = tag
};
RegisterOrder(order);
}
private void CloseHedge(HedgeState state, string reason)
{
TradeToTarget(state.First, 0m, reason);
TradeToTarget(state.Second, 0m, reason);
state.IsOpen = false;
state.Dir1 = 0;
state.Dir2 = 0;
state.Volume1 = 0m;
state.Volume2 = 0m;
state.Entry1 = 0m;
state.Entry2 = 0m;
}
private decimal CalculatePoints(HedgeState state)
{
var first = _contexts[state.First];
var second = _contexts[state.Second];
var stepFirst = first.Security.PriceStep ?? 1m;
var stepSecond = second.Security.PriceStep ?? 1m;
if (stepFirst == 0m)
stepFirst = 1m;
if (stepSecond == 0m)
stepSecond = 1m;
var moveFirst = state.Dir1 * (first.LastClose - state.Entry1) / stepFirst * state.Volume1;
var moveSecond = state.Dir2 * (second.LastClose - state.Entry2) / stepSecond * state.Volume2;
return moveFirst + moveSecond;
}
private decimal CalculateCurrency(HedgeState state)
{
var first = _contexts[state.First];
var second = _contexts[state.Second];
var stepFirst = first.Security.PriceStep ?? 1m;
var stepSecond = second.Security.PriceStep ?? 1m;
if (stepFirst == 0m)
stepFirst = 1m;
if (stepSecond == 0m)
stepSecond = 1m;
var priceStepFirst = this.GetSecurityValue<decimal?>(first.Security, Level1Fields.StepPrice) ?? stepFirst;
var priceStepSecond = this.GetSecurityValue<decimal?>(second.Security, Level1Fields.StepPrice) ?? stepSecond;
var pnlFirst = state.Dir1 * (first.LastClose - state.Entry1) / stepFirst * priceStepFirst * state.Volume1;
var pnlSecond = state.Dir2 * (second.LastClose - state.Entry2) / stepSecond * priceStepSecond * state.Volume2;
return pnlFirst + pnlSecond;
}
private decimal CalculateCorrelation(SecurityContext first, SecurityContext second)
{
var lookback = CorrelationLookback;
var available = Math.Min(first.CloseCount, second.CloseCount);
if (lookback <= 0 || lookback > available)
lookback = available;
if (lookback < 2)
return 0m;
decimal sumX = 0m;
decimal sumY = 0m;
decimal sumXY = 0m;
decimal sumX2 = 0m;
decimal sumY2 = 0m;
using var enumX = first.GetRecentCloses(lookback).GetEnumerator();
using var enumY = second.GetRecentCloses(lookback).GetEnumerator();
while (enumX.MoveNext() && enumY.MoveNext())
{
var x = enumX.Current;
var y = enumY.Current;
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
sumY2 += y * y;
}
var numerator = lookback * sumXY - sumX * sumY;
var denomPart1 = lookback * sumX2 - sumX * sumX;
var denomPart2 = lookback * sumY2 - sumY * sumY;
if (denomPart1 <= 0m || denomPart2 <= 0m)
return 0m;
var denominator = (decimal)Math.Sqrt((double)(denomPart1 * denomPart2));
if (denominator == 0m)
return 0m;
return numerator / denominator;
}
private decimal CalculateAtrRatio(SecurityContext first, SecurityContext second)
{
var lookback = AtrLookback;
var available = Math.Min(first.TrueRangeCount, second.TrueRangeCount);
if (lookback <= 0 || lookback > available)
lookback = available;
if (lookback <= 0)
return 0m;
var atrFirst = first.GetAverageTrueRange(lookback);
var atrSecond = second.GetAverageTrueRange(lookback);
if (atrFirst <= 0m || atrSecond <= 0m)
return 0m;
return atrSecond / atrFirst;
}
private enum HedgeActions
{
None,
BuyMainSellSub,
SellMainBuySub,
BuyBoth,
SellBoth
}
private sealed class HedgeState
{
public HedgeState(HedgePairKey key)
{
Key = key;
Tag = $"HEDGE_{key.First?.Id}_{key.Second?.Id}";
}
public HedgePairKey Key { get; }
public Security First => Key.First;
public Security Second => Key.Second;
public bool IsPositive { get; set; }
public decimal AtrRatio { get; set; }
public bool IsOpen { get; set; }
public int Dir1 { get; set; }
public int Dir2 { get; set; }
public decimal Volume1 { get; set; }
public decimal Volume2 { get; set; }
public decimal Entry1 { get; set; }
public decimal Entry2 { get; set; }
public string Tag { get; }
}
private readonly struct HedgePairKey : IEquatable<HedgePairKey>
{
public HedgePairKey(Security first, Security second)
{
First = first ?? throw new ArgumentNullException(nameof(first));
Second = second ?? throw new ArgumentNullException(nameof(second));
}
public Security First { get; }
public Security Second { get; }
public bool Equals(HedgePairKey other)
{
return First == other.First && Second == other.Second;
}
public override bool Equals(object obj)
{
return obj is HedgePairKey other && Equals(other);
}
public override int GetHashCode()
{
return HashCode.Combine(First, Second);
}
}
private sealed class SecurityContext
{
private readonly RollingBuffer _closes;
private readonly RollingBuffer _highs;
private readonly RollingBuffer _lows;
private readonly RollingBuffer _trueRanges;
private decimal _previousClose;
private bool _hasPreviousClose;
public SecurityContext(Security security, int correlationCapacity, int rangeCapacity, int atrCapacity)
{
Security = security ?? throw new ArgumentNullException(nameof(security));
_closes = new RollingBuffer(Math.Max(2, correlationCapacity));
_highs = new RollingBuffer(Math.Max(1, rangeCapacity));
_lows = new RollingBuffer(Math.Max(1, rangeCapacity));
_trueRanges = new RollingBuffer(Math.Max(1, atrCapacity));
}
public Security Security { get; }
public decimal LastClose { get; private set; }
public decimal? BestBid { get; private set; }
public decimal? BestAsk { get; private set; }
public int CloseCount => _closes.Count;
public int TrueRangeCount => _trueRanges.Count;
public void Update(ICandleMessage candle)
{
_closes.Add(candle.ClosePrice);
_highs.Add(candle.HighPrice);
_lows.Add(candle.LowPrice);
decimal trueRange;
if (_hasPreviousClose)
{
var range = candle.HighPrice - candle.LowPrice;
var highDiff = Math.Abs(candle.HighPrice - _previousClose);
var lowDiff = Math.Abs(candle.LowPrice - _previousClose);
trueRange = Math.Max(range, Math.Max(highDiff, lowDiff));
}
else
{
trueRange = candle.HighPrice - candle.LowPrice;
_hasPreviousClose = true;
}
_trueRanges.Add(trueRange);
_previousClose = candle.ClosePrice;
LastClose = candle.ClosePrice;
}
public void UpdateLevel1(Level1ChangeMessage message)
{
BestBid = message.TryGetDecimal(Level1Fields.BestBidPrice) ?? BestBid;
BestAsk = message.TryGetDecimal(Level1Fields.BestAskPrice) ?? BestAsk;
}
public bool HasCorrelationData(int required)
{
if (required <= 0)
return _closes.Count >= 2;
return _closes.Count >= required;
}
public bool HasRangeData(int required)
{
return _highs.Count >= required && _lows.Count >= required;
}
public IEnumerable<decimal> GetRecentCloses(int count) => _closes.EnumerateRecent(count);
public decimal GetHigh(int count) => _highs.Max(count);
public decimal GetLow(int count) => _lows.Min(count);
public decimal GetAverageTrueRange(int count) => _trueRanges.Average(count);
public decimal GetSpreadPoints()
{
var step = Security.PriceStep ?? 0m;
if (BestBid is not decimal bid || BestAsk is not decimal ask || step <= 0m)
return decimal.MaxValue;
return (ask - bid) / step;
}
}
private sealed class RollingBuffer
{
private readonly decimal[] _buffer;
private int _start;
private int _count;
public RollingBuffer(int capacity)
{
_buffer = new decimal[Math.Max(1, capacity)];
_start = 0;
_count = 0;
}
public int Count => _count;
public void Add(decimal value)
{
if (_count < _buffer.Length)
{
var index = (_start + _count) % _buffer.Length;
_buffer[index] = value;
_count++;
}
else
{
_buffer[_start] = value;
_start = (_start + 1) % _buffer.Length;
}
}
public IEnumerable<decimal> EnumerateRecent(int count)
{
if (count > _count)
count = _count;
for (var i = 0; i < count; i++)
{
var index = (_start + _count - count + i) % _buffer.Length;
yield return _buffer[index];
}
}
public decimal Max(int count)
{
if (_count == 0)
return 0m;
if (count > _count)
count = _count;
var max = decimal.MinValue;
for (var i = 0; i < count; i++)
{
var index = (_start + _count - count + i) % _buffer.Length;
var value = _buffer[index];
if (value > max)
max = value;
}
return max;
}
public decimal Min(int count)
{
if (_count == 0)
return 0m;
if (count > _count)
count = _count;
var min = decimal.MaxValue;
for (var i = 0; i < count; i++)
{
var index = (_start + _count - count + i) % _buffer.Length;
var value = _buffer[index];
if (value < min)
min = value;
}
return min;
}
public decimal Average(int count)
{
if (_count == 0)
return 0m;
if (count > _count || count <= 0)
count = _count;
decimal sum = 0m;
for (var i = 0; i < count; i++)
{
var index = (_start + _count - count + i) % _buffer.Length;
sum += _buffer[index];
}
return sum / count;
}
}
}
import clr
import math
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.BusinessEntities")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan, Math, DateTime
from System.Collections.Generic import IEnumerable
from StockSharp.Messages import DataType, CandleStates, Level1Fields, Sides, OrderTypes
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Security, Order
# ---------------------------------------------------------------------------
# Helper classes (ported from C# inner classes)
# ---------------------------------------------------------------------------
class RollingBuffer:
"""Fixed-capacity circular buffer of floats with aggregate helpers."""
def __init__(self, capacity):
self._capacity = max(1, capacity)
self._buffer = [0.0] * self._capacity
self._start = 0
self._count = 0
@property
def count(self):
return self._count
def add(self, value):
if self._count < self._capacity:
index = (self._start + self._count) % self._capacity
self._buffer[index] = value
self._count += 1
else:
self._buffer[self._start] = value
self._start = (self._start + 1) % self._capacity
def enumerate_recent(self, n):
if n > self._count:
n = self._count
result = []
for i in range(n):
index = (self._start + self._count - n + i) % self._capacity
result.append(self._buffer[index])
return result
def max_val(self, n):
if self._count == 0:
return 0.0
if n > self._count:
n = self._count
best = -1e308
for i in range(n):
index = (self._start + self._count - n + i) % self._capacity
v = self._buffer[index]
if v > best:
best = v
return best
def min_val(self, n):
if self._count == 0:
return 0.0
if n > self._count:
n = self._count
best = 1e308
for i in range(n):
index = (self._start + self._count - n + i) % self._capacity
v = self._buffer[index]
if v < best:
best = v
return best
def average(self, n):
if self._count == 0:
return 0.0
if n > self._count or n <= 0:
n = self._count
total = 0.0
for i in range(n):
index = (self._start + self._count - n + i) % self._capacity
total += self._buffer[index]
return total / n
class SecurityContext:
"""Per-security state: rolling closes, highs, lows, true-ranges, and Level1 prices."""
def __init__(self, security, correlation_capacity, range_capacity, atr_capacity):
self.security = security
self._closes = RollingBuffer(max(2, correlation_capacity))
self._highs = RollingBuffer(max(1, range_capacity))
self._lows = RollingBuffer(max(1, range_capacity))
self._true_ranges = RollingBuffer(max(1, atr_capacity))
self._previous_close = 0.0
self._has_previous_close = False
self.last_close = 0.0
self.best_bid = None
self.best_ask = None
@property
def close_count(self):
return self._closes.count
@property
def true_range_count(self):
return self._true_ranges.count
def update(self, candle):
high = float(candle.HighPrice)
low = float(candle.LowPrice)
close = float(candle.ClosePrice)
self._closes.add(close)
self._highs.add(high)
self._lows.add(low)
if self._has_previous_close:
rng = high - low
high_diff = abs(high - self._previous_close)
low_diff = abs(low - self._previous_close)
true_range = max(rng, max(high_diff, low_diff))
else:
true_range = high - low
self._has_previous_close = True
self._true_ranges.add(true_range)
self._previous_close = close
self.last_close = close
def update_level1(self, message):
bid = message.TryGetDecimal(Level1Fields.BestBidPrice)
ask = message.TryGetDecimal(Level1Fields.BestAskPrice)
if bid is not None:
self.best_bid = float(bid)
if ask is not None:
self.best_ask = float(ask)
def has_correlation_data(self, required):
if required <= 0:
return self._closes.count >= 2
return self._closes.count >= required
def has_range_data(self, required):
return self._highs.count >= required and self._lows.count >= required
def get_recent_closes(self, n):
return self._closes.enumerate_recent(n)
def get_high(self, n):
return self._highs.max_val(n)
def get_low(self, n):
return self._lows.min_val(n)
def get_average_true_range(self, n):
return self._true_ranges.average(n)
def get_spread_points(self):
step = self.security.PriceStep
if step is not None:
step = float(step)
else:
step = 0.0
if self.best_bid is None or self.best_ask is None or step <= 0.0:
return 1e308
return (self.best_ask - self.best_bid) / step
class HedgePairKey:
"""Hashable pair identifier for two securities."""
def __init__(self, first, second):
self.first = first
self.second = second
def __eq__(self, other):
if not isinstance(other, HedgePairKey):
return False
return self.first == other.first and self.second == other.second
def __hash__(self):
return hash((id(self.first), id(self.second)))
class HedgeState:
"""Mutable state for one hedge pair."""
def __init__(self, key):
self.key = key
first_id = key.first.Id if key.first is not None else "?"
second_id = key.second.Id if key.second is not None else "?"
self.tag = "HEDGE_{0}_{1}".format(first_id, second_id)
self.is_positive = True
self.atr_ratio = 0.0
self.is_open = False
self.dir1 = 0
self.dir2 = 0
self.volume1 = 0.0
self.volume2 = 0.0
self.entry1 = 0.0
self.entry2 = 0.0
@property
def first(self):
return self.key.first
@property
def second(self):
return self.key.second
# Hedge action enum
HEDGE_NONE = 0
HEDGE_BUY_MAIN_SELL_SUB = 1
HEDGE_SELL_MAIN_BUY_SUB = 2
HEDGE_BUY_BOTH = 3
HEDGE_SELL_BOTH = 4
# ---------------------------------------------------------------------------
# Strategy
# ---------------------------------------------------------------------------
class multicurrency_overlay_hedge_strategy(Strategy):
"""
Multicurrency overlay hedge strategy converted from MQL.
Scans a universe of forex symbols, pairs positively/negatively correlated
instruments and opens hedged blocks when the overlay threshold is breached.
"""
def __init__(self):
super(multicurrency_overlay_hedge_strategy, self).__init__()
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(1))) \
.SetDisplay("Candle Type", "Time frame used for analysis", "General")
self._range_length = self.Param("RangeLength", 400) \
.SetDisplay("Range Length", "Bars used to build price envelopes", "Parameters")
self._correlation_lookback = self.Param("CorrelationLookback", 500) \
.SetDisplay("Correlation Lookback", "Bars used for Pearson correlation", "Parameters")
self._atr_lookback = self.Param("AtrLookback", 200) \
.SetDisplay("ATR Lookback", "Bars used to compute ATR ratio", "Parameters")
self._correlation_threshold = self.Param("CorrelationThreshold", 0.9) \
.SetDisplay("Correlation Threshold", "Absolute correlation required for pairing", "Parameters")
self._overlay_threshold = self.Param("OverlayThreshold", 100.0) \
.SetDisplay("Overlay Threshold", "Distance in points to trigger hedging", "Trading")
self._take_profit_by_points = self.Param("TakeProfitByPoints", True) \
.SetDisplay("TP by Points", "Enable point based take profit", "Risk")
self._take_profit_points = self.Param("TakeProfitPoints", 10.0) \
.SetDisplay("Points Target", "Mutual take profit in points", "Risk")
self._take_profit_by_currency = self.Param("TakeProfitByCurrency", False) \
.SetDisplay("TP by Currency", "Enable currency based take profit", "Risk")
self._take_profit_currency = self.Param("TakeProfitCurrency", 10.0) \
.SetDisplay("Currency Target", "Mutual take profit in account currency", "Risk")
self._max_open_pairs = self.Param("MaxOpenPairs", 10) \
.SetDisplay("Max Pairs", "Maximum simultaneously open hedges", "Risk")
self._base_volume_param = self.Param("BaseVolume", 1.0) \
.SetDisplay("Base Volume", "Secondary leg volume in lots", "Trading")
self._recalc_hour = self.Param("RecalculationHour", 1) \
.SetDisplay("Recalc Hour", "Hour to rebuild pair statistics", "Trading")
self._max_spread = self.Param("MaxSpread", 10.0) \
.SetDisplay("Max Spread", "Max allowed spread in points", "Trading")
self._universe_param = self.Param("Universe", None) \
.SetDisplay("Universe", "Collection of forex symbols", "General")
# Internal state
self._contexts = {} # Security -> SecurityContext
self._pairs = {} # HedgePairKey -> HedgeState
self._pairs_by_security = {} # Security -> [HedgePairKey]
self._universe_list = [] # [Security]
self._last_recalc_day = DateTime.MinValue
# --- Properties ---------------------------------------------------------
@property
def CandleType(self):
return self._candle_type.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type.Value = value
@property
def RangeLength(self):
return int(self._range_length.Value)
@property
def CorrelationLookback(self):
return int(self._correlation_lookback.Value)
@property
def AtrLookback(self):
return int(self._atr_lookback.Value)
@property
def CorrelationThreshold(self):
return float(self._correlation_threshold.Value)
@property
def OverlayThreshold(self):
return float(self._overlay_threshold.Value)
@property
def TakeProfitByPoints(self):
return bool(self._take_profit_by_points.Value)
@property
def TakeProfitPoints(self):
return float(self._take_profit_points.Value)
@property
def TakeProfitByCurrency(self):
return bool(self._take_profit_by_currency.Value)
@property
def TakeProfitCurrency(self):
return float(self._take_profit_currency.Value)
@property
def MaxOpenPairs(self):
return int(self._max_open_pairs.Value)
@property
def BaseVolume(self):
return float(self._base_volume_param.Value)
@property
def RecalculationHour(self):
return int(self._recalc_hour.Value)
@property
def MaxSpread(self):
return float(self._max_spread.Value)
# --- Overrides ----------------------------------------------------------
def GetWorkingSecurities(self):
result = []
universe = self._universe_param.Value
if universe is not None:
for sec in universe:
if sec is not None:
result.append((sec, self.CandleType))
return result
def OnReseted(self):
super(multicurrency_overlay_hedge_strategy, self).OnReseted()
self._contexts.clear()
self._pairs.clear()
self._pairs_by_security.clear()
self._universe_list = []
self._last_recalc_day = DateTime.MinValue
def OnStarted2(self, time):
super(multicurrency_overlay_hedge_strategy, self).OnStarted2(time)
universe = self._universe_param.Value
self._universe_list = []
if universe is not None:
for sec in universe:
if sec is not None and sec not in self._universe_list:
self._universe_list.append(sec)
if len(self._universe_list) < 2:
raise Exception("Universe must contain at least two securities.")
for sec in self._universe_list:
corr_cap = max(2, self.CorrelationLookback)
ctx = SecurityContext(sec, corr_cap, self.RangeLength, self.AtrLookback)
self._contexts[sec] = ctx
self._pairs_by_security[sec] = []
self.SubscribeCandles(self.CandleType, True, sec) \
.Bind(lambda candle, s=sec: self._process_candle(candle, s)) \
.Start()
self.SubscribeLevel1(sec) \
.Bind(lambda msg, c=ctx: c.update_level1(msg)) \
.Start()
self.StartProtection(None, None)
def CreateClone(self):
return multicurrency_overlay_hedge_strategy()
# --- Candle processing --------------------------------------------------
def _process_candle(self, candle, security):
if candle.State != CandleStates.Finished:
return
ctx = self._contexts.get(security)
if ctx is None:
return
ctx.update(candle)
if self._should_recalculate(candle):
self._recalculate_pairs()
self._manage_open_hedges()
pair_keys = self._pairs_by_security.get(security)
if pair_keys is not None:
for key in list(pair_keys):
self._try_open_hedge(key)
# --- Recalculation timing -----------------------------------------------
def _should_recalculate(self, candle):
try:
open_time = candle.OpenTime.UtcDateTime
except Exception:
open_time = candle.OpenTime
day = open_time.Date
if day == self._last_recalc_day:
return False
if open_time.Hour < self.RecalculationHour:
return False
self._last_recalc_day = day
return True
# --- Pair recalculation -------------------------------------------------
def _recalculate_pairs(self):
for lst in self._pairs_by_security.values():
del lst[:]
count = len(self._universe_list)
correlation_lookback = self.CorrelationLookback
correlation_threshold = self.CorrelationThreshold
for i in range(count):
first = self._universe_list[i]
first_ctx = self._contexts[first]
if not first_ctx.has_correlation_data(correlation_lookback):
continue
for j in range(i + 1, count):
second = self._universe_list[j]
second_ctx = self._contexts[second]
if not second_ctx.has_correlation_data(correlation_lookback):
continue
correlation = self._calculate_correlation(first_ctx, second_ctx)
abs_corr = abs(correlation)
if abs_corr < correlation_threshold:
continue
atr_ratio = self._calculate_atr_ratio(first_ctx, second_ctx)
if atr_ratio <= 0.0:
continue
key = HedgePairKey(first, second)
state = self._pairs.get(key)
if state is None:
state = HedgeState(key)
self._pairs[key] = state
state.is_positive = correlation >= 0.0
state.atr_ratio = atr_ratio
self._pairs_by_security[first].append(key)
self._pairs_by_security[second].append(key)
to_remove = []
for key, state in list(self._pairs.items()):
if state.is_open:
continue
first_list = self._pairs_by_security.get(key.first)
if first_list is None or key not in first_list:
to_remove.append(key)
for key in to_remove:
del self._pairs[key]
# --- Open hedge management ----------------------------------------------
def _manage_open_hedges(self):
for key, state in list(self._pairs.items()):
if not state.is_open:
continue
points = self._calculate_points(state)
if self.TakeProfitByPoints and points >= self.TakeProfitPoints:
self._close_hedge(state, "TP_POINTS")
continue
currency = self._calculate_currency(state)
if self.TakeProfitByCurrency and currency >= self.TakeProfitCurrency:
self._close_hedge(state, "TP_CURRENCY")
# --- Try opening a hedge ------------------------------------------------
def _try_open_hedge(self, key):
state = self._pairs.get(key)
if state is None:
return
if state.is_open:
return
first_ctx = self._contexts.get(key.first)
second_ctx = self._contexts.get(key.second)
if first_ctx is None or second_ctx is None:
return
range_length = self.RangeLength
if not first_ctx.has_range_data(range_length) or not second_ctx.has_range_data(range_length):
return
if not self._is_security_available(key.first) or not self._is_security_available(key.second):
return
max_open = self.MaxOpenPairs
if max_open > 0 and self._get_open_pairs_count() >= max_open:
return
if not self._is_spread_within_limit(first_ctx) or not self._is_spread_within_limit(second_ctx):
return
action = self._determine_action(state, first_ctx, second_ctx)
if action == HEDGE_NONE:
return
base_volume = self.BaseVolume
if base_volume <= 0.0:
return
scaled_volume = base_volume * state.atr_ratio
if scaled_volume <= 0.0:
return
dir_first, dir_second = self._get_directions(action)
target_first = dir_first * scaled_volume
target_second = dir_second * base_volume
self._trade_to_target(key.first, target_first, state.tag)
self._trade_to_target(key.second, target_second, state.tag)
state.dir1 = dir_first
state.dir2 = dir_second
state.volume1 = scaled_volume
state.volume2 = base_volume
state.entry1 = first_ctx.last_close
state.entry2 = second_ctx.last_close
state.is_open = True
# --- Utilities ----------------------------------------------------------
def _is_security_available(self, security):
for key, state in self._pairs.items():
if not state.is_open:
continue
if key.first == security or key.second == security:
return False
return True
def _get_open_pairs_count(self):
count = 0
for state in self._pairs.values():
if state.is_open:
count += 1
return count
def _is_spread_within_limit(self, ctx):
max_spread = self.MaxSpread
if max_spread <= 0.0:
return True
spread = ctx.get_spread_points()
if spread >= 1e308:
return True
return spread <= max_spread
# --- Determine hedge action ---------------------------------------------
def _determine_action(self, state, first_ctx, second_ctx):
range_length = self.RangeLength
high_main = first_ctx.get_high(range_length)
low_main = first_ctx.get_low(range_length)
if high_main <= low_main:
return HEDGE_NONE
if state.is_positive:
sub_high = second_ctx.get_high(range_length)
sub_low = second_ctx.get_low(range_length)
else:
sub_high = second_ctx.get_low(range_length)
sub_low = second_ctx.get_high(range_length)
if sub_high <= sub_low:
return HEDGE_NONE
main_center = (high_main + low_main) / 2.0
sub_center = (sub_high + sub_low) / 2.0
denominator = sub_high - sub_low
if denominator == 0.0:
return HEDGE_NONE
pips_ratio = (high_main - low_main) / denominator
if pips_ratio == 0.0:
return HEDGE_NONE
sub_close_offset = second_ctx.last_close - sub_center
synthetic_close = main_center + sub_close_offset * pips_ratio
step = first_ctx.security.PriceStep
if step is not None:
step = float(step)
else:
step = 0.0
if step <= 0.0:
step = 1.0
hedge_range = (first_ctx.last_close - synthetic_close) / step
overlay_threshold = self.OverlayThreshold
if hedge_range < -overlay_threshold:
return HEDGE_BUY_MAIN_SELL_SUB if state.is_positive else HEDGE_BUY_BOTH
if hedge_range > overlay_threshold:
return HEDGE_SELL_MAIN_BUY_SUB if state.is_positive else HEDGE_SELL_BOTH
return HEDGE_NONE
def _get_directions(self, action):
if action == HEDGE_BUY_MAIN_SELL_SUB:
return (1, -1)
elif action == HEDGE_SELL_MAIN_BUY_SUB:
return (-1, 1)
elif action == HEDGE_BUY_BOTH:
return (1, 1)
elif action == HEDGE_SELL_BOTH:
return (-1, -1)
return (0, 0)
# --- Order execution ----------------------------------------------------
def _trade_to_target(self, security, target_volume, tag):
if self.Portfolio is None:
return
pos_val = self.GetPositionValue(security, self.Portfolio)
current = float(pos_val) if pos_val is not None else 0.0
diff = target_volume - current
if abs(diff) < 1e-6:
return
order = Order()
order.Security = security
order.Portfolio = self.Portfolio
order.Volume = abs(diff)
order.Side = Sides.Buy if diff > 0 else Sides.Sell
order.Type = OrderTypes.Market
order.Comment = tag
self.RegisterOrder(order)
def _close_hedge(self, state, reason):
self._trade_to_target(state.first, 0.0, reason)
self._trade_to_target(state.second, 0.0, reason)
state.is_open = False
state.dir1 = 0
state.dir2 = 0
state.volume1 = 0.0
state.volume2 = 0.0
state.entry1 = 0.0
state.entry2 = 0.0
# --- P&L calculations ---------------------------------------------------
def _calculate_points(self, state):
first_ctx = self._contexts.get(state.first)
second_ctx = self._contexts.get(state.second)
if first_ctx is None or second_ctx is None:
return 0.0
step_first = first_ctx.security.PriceStep
step_first = float(step_first) if step_first is not None else 1.0
if step_first == 0.0:
step_first = 1.0
step_second = second_ctx.security.PriceStep
step_second = float(step_second) if step_second is not None else 1.0
if step_second == 0.0:
step_second = 1.0
move_first = state.dir1 * (first_ctx.last_close - state.entry1) / step_first * state.volume1
move_second = state.dir2 * (second_ctx.last_close - state.entry2) / step_second * state.volume2
return move_first + move_second
def _calculate_currency(self, state):
first_ctx = self._contexts.get(state.first)
second_ctx = self._contexts.get(state.second)
if first_ctx is None or second_ctx is None:
return 0.0
step_first = first_ctx.security.PriceStep
step_first = float(step_first) if step_first is not None else 1.0
if step_first == 0.0:
step_first = 1.0
step_second = second_ctx.security.PriceStep
step_second = float(step_second) if step_second is not None else 1.0
if step_second == 0.0:
step_second = 1.0
# Try to get the monetary value of one price step; fall back to the step itself.
price_step_first = step_first
price_step_second = step_second
try:
v = self.GetSecurityValue[object](first_ctx.security, Level1Fields.StepPrice)
if v is not None:
price_step_first = float(v)
except Exception:
pass
try:
v = self.GetSecurityValue[object](second_ctx.security, Level1Fields.StepPrice)
if v is not None:
price_step_second = float(v)
except Exception:
pass
pnl_first = state.dir1 * (first_ctx.last_close - state.entry1) / step_first * price_step_first * state.volume1
pnl_second = state.dir2 * (second_ctx.last_close - state.entry2) / step_second * price_step_second * state.volume2
return pnl_first + pnl_second
# --- Correlation --------------------------------------------------------
def _calculate_correlation(self, first_ctx, second_ctx):
lookback = self.CorrelationLookback
available = min(first_ctx.close_count, second_ctx.close_count)
if lookback <= 0 or lookback > available:
lookback = available
if lookback < 2:
return 0.0
xs = first_ctx.get_recent_closes(lookback)
ys = second_ctx.get_recent_closes(lookback)
sum_x = 0.0
sum_y = 0.0
sum_xy = 0.0
sum_x2 = 0.0
sum_y2 = 0.0
for x, y in zip(xs, ys):
sum_x += x
sum_y += y
sum_xy += x * y
sum_x2 += x * x
sum_y2 += y * y
numerator = lookback * sum_xy - sum_x * sum_y
denom_part1 = lookback * sum_x2 - sum_x * sum_x
denom_part2 = lookback * sum_y2 - sum_y * sum_y
if denom_part1 <= 0.0 or denom_part2 <= 0.0:
return 0.0
denominator = math.sqrt(denom_part1 * denom_part2)
if denominator == 0.0:
return 0.0
return numerator / denominator
# --- ATR ratio ----------------------------------------------------------
def _calculate_atr_ratio(self, first_ctx, second_ctx):
lookback = self.AtrLookback
available = min(first_ctx.true_range_count, second_ctx.true_range_count)
if lookback <= 0 or lookback > available:
lookback = available
if lookback <= 0:
return 0.0
atr_first = first_ctx.get_average_true_range(lookback)
atr_second = second_ctx.get_average_true_range(lookback)
if atr_first <= 0.0 or atr_second <= 0.0:
return 0.0
return atr_second / atr_first