移动平均策略
概述
移动平均策略重现了 MetaTrader 专家顾问,通过价格与平移后的简单移动平均线(SMA)交叉来进行交易。算法只处理已经收盘的K线,从而确保交易决策基于完整数据。仓位规模使用动态风险模型,与账户权益挂钩,并根据连续亏损自动调整,延续原始 MQL 逻辑。
交易逻辑
- 计算可配置周期的简单移动平均线,并向前平移指定数量的已完成K线。
- 每根完成的K线都会检查是否出现以下情形:开盘价高于平移后的 SMA 且收盘价跌破它(看空交叉),或开盘价低于平移后的 SMA 且收盘价突破它(看多交叉)。
- 策略同时只持有一个方向的仓位。当出现与当前仓位相反的交叉时,会先平仓,当根K线内不会反向开仓。
- 当没有持仓时:
- 看多交叉开多单。
- 看空交叉开空单。
仓位管理
- 看空交叉触发多头平仓。
- 看多交叉触发空头平仓。
- 所有交易使用市场委托执行。
- 策略跟踪成交历史以计算有效入场价,用于在平仓时评估盈亏。
风险管理与仓位控制
- 基础下单量由投资组合权益乘以 MaximumRisk 参数并除以当前收盘价得到。当无法获取权益时,会退回到策略默认手数。
- DecreaseFactor 参数用于连续亏损时削减下单量,削减幅度与亏损次数成比例,从而模拟原始 MQL 的自适应仓位机制。
- 下单量不会为负值;当削减量超过基础手数时,该笔交易会被跳过。
参数
| 名称 | 说明 | 默认值 |
|---|---|---|
MaximumRisk |
每笔交易占用账户权益的比例。 | 0.02 |
DecreaseFactor |
连续亏损后缩小仓位的除数。 | 3 |
MovingPeriod |
计算信号的 SMA 周期。 | 12 |
MovingShift |
将 SMA 向前平移的已完成K线数量。 | 6 |
CandleType |
计算所用的K线类型(时间框架)。 | 5 分钟K线 |
说明
- 平移后的均线通过内部循环缓冲区实现,使策略使用几根K线之前的 SMA 值,行为与 MetaTrader 的 shift 参数保持一致。
- 只有当 SMA 和平移缓冲都完全形成后才会发出订单,避免在热身阶段过早入场。
- 日志会记录入场、出场和交易结果,便于调试与绩效分析。
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;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Moving average crossover strategy with risk-based position sizing and trade streak tracking.
/// </summary>
public class MovingAveragesStrategy : Strategy
{
private readonly StrategyParam<decimal> _maximumRisk;
private readonly StrategyParam<decimal> _decreaseFactor;
private readonly StrategyParam<int> _movingPeriod;
private readonly StrategyParam<int> _movingShift;
private readonly StrategyParam<DataType> _candleType;
private SimpleMovingAverage _sma = null!;
private decimal[] _shiftBuffer = Array.Empty<decimal>();
private int _shiftIndex;
private int _shiftFillCount;
private decimal _avgEntryPrice;
private decimal _entryVolume;
private Sides? _entrySide;
private int _consecutiveLosses;
/// <summary>
/// Maximum risk per trade expressed as portfolio percentage.
/// </summary>
public decimal MaximumRisk
{
get => _maximumRisk.Value;
set => _maximumRisk.Value = value;
}
/// <summary>
/// Factor that reduces position size after consecutive losses.
/// </summary>
public decimal DecreaseFactor
{
get => _decreaseFactor.Value;
set => _decreaseFactor.Value = value;
}
/// <summary>
/// Simple moving average period.
/// </summary>
public int MovingPeriod
{
get => _movingPeriod.Value;
set => _movingPeriod.Value = value;
}
/// <summary>
/// Number of completed bars used to shift the moving average.
/// </summary>
public int MovingShift
{
get => _movingShift.Value;
set => _movingShift.Value = value;
}
/// <summary>
/// Candle type used for calculations.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Initializes a new instance of the <see cref="MovingAveragesStrategy"/>.
/// </summary>
public MovingAveragesStrategy()
{
// Configure risk management parameters.
_maximumRisk = Param(nameof(MaximumRisk), 0.02m)
.SetGreaterThanZero()
.SetDisplay("Maximum Risk", "Fraction of equity risked per trade", "Risk");
_decreaseFactor = Param(nameof(DecreaseFactor), 3m)
.SetGreaterThanZero()
.SetDisplay("Decrease Factor", "Loss streak divisor for position sizing", "Risk");
// Configure indicator settings.
_movingPeriod = Param(nameof(MovingPeriod), 12)
.SetGreaterThanZero()
.SetDisplay("Moving Period", "Simple moving average lookback", "Indicator");
_movingShift = Param(nameof(MovingShift), 6)
.SetNotNegative()
.SetDisplay("Moving Shift", "Bars to shift the moving average", "Indicator");
// Configure candle source for the strategy.
_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
.SetDisplay("Candle Type", "Timeframe used for signals", "Data");
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_shiftBuffer = Array.Empty<decimal>();
_shiftIndex = 0;
_shiftFillCount = 0;
_consecutiveLosses = 0;
ResetEntryState();
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return [(Security, CandleType)];
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
// Initialize indicator and buffers.
_sma = new SMA { Length = MovingPeriod };
_shiftBuffer = new decimal[Math.Max(1, MovingShift + 1)];
_shiftIndex = 0;
_shiftFillCount = 0;
_consecutiveLosses = 0;
ResetEntryState();
// Subscribe to candles and bind indicator values.
var subscription = SubscribeCandles(CandleType);
subscription
.Bind(_sma, ProcessCandle)
.Start();
// Add optional chart visuals.
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawIndicator(area, _sma);
DrawOwnTrades(area);
}
}
private void ProcessCandle(ICandleMessage candle, decimal maValue)
{
// Process only finished candles to match bar-based logic.
if (candle.State != CandleStates.Finished)
return;
// Wait until the moving average has enough data.
if (!_sma.IsFormed)
return;
// Update shifted buffer to emulate MetaTrader style MA shift.
UpdateShiftBuffer(maValue);
if (!IsShiftReady())
return;
var shiftedMa = GetShiftedValue();
var crossDown = candle.OpenPrice > shiftedMa && candle.ClosePrice < shiftedMa;
var crossUp = candle.OpenPrice < shiftedMa && candle.ClosePrice > shiftedMa;
// Manage existing long position before searching for new entries.
if (Position > 0)
{
if (crossDown)
{
CloseLongPosition(candle, shiftedMa);
}
return;
}
// Manage existing short position before searching for new entries.
if (Position < 0)
{
if (crossUp)
{
CloseShortPosition(candle, shiftedMa);
}
return;
}
// No open position, evaluate entry opportunities.
if (crossUp)
{
OpenLongPosition(candle, shiftedMa);
}
else if (crossDown)
{
OpenShortPosition(candle, shiftedMa);
}
}
private void OpenLongPosition(ICandleMessage candle, decimal shiftedMa)
{
var volume = CalculateOrderVolume(candle.ClosePrice);
if (volume <= 0m)
return;
BuyMarket();
LogInfo($"Enter long on bullish cross. Price={candle.ClosePrice}, SMA={shiftedMa}");
}
private void OpenShortPosition(ICandleMessage candle, decimal shiftedMa)
{
var volume = CalculateOrderVolume(candle.ClosePrice);
if (volume <= 0m)
return;
SellMarket();
LogInfo($"Enter short on bearish cross. Price={candle.ClosePrice}, SMA={shiftedMa}");
}
private void CloseLongPosition(ICandleMessage candle, decimal shiftedMa)
{
var volume = Math.Abs(Position);
if (volume <= 0m)
return;
SellMarket();
LogInfo($"Exit long due to bearish cross. Price={candle.ClosePrice}, SMA={shiftedMa}");
}
private void CloseShortPosition(ICandleMessage candle, decimal shiftedMa)
{
var volume = Math.Abs(Position);
if (volume <= 0m)
return;
BuyMarket();
LogInfo($"Exit short due to bullish cross. Price={candle.ClosePrice}, SMA={shiftedMa}");
}
private decimal CalculateOrderVolume(decimal price)
{
if (price <= 0m)
return 0m;
// Base position size uses portfolio value and risk percentage.
var portfolioValue = Portfolio?.CurrentValue ?? 0m;
var baseVolume = Volume > 0m ? Volume : 1m;
if (portfolioValue > 0m && MaximumRisk > 0m)
baseVolume = portfolioValue * MaximumRisk / price;
// Apply loss streak reduction similar to the original MQL logic.
if (DecreaseFactor > 0m && _consecutiveLosses > 0)
{
var reduction = baseVolume * _consecutiveLosses / DecreaseFactor;
baseVolume -= reduction;
}
return baseVolume > 0m ? baseVolume : 0m;
}
private void UpdateShiftBuffer(decimal value)
{
_shiftBuffer[_shiftIndex] = value;
if (_shiftFillCount < _shiftBuffer.Length)
_shiftFillCount++;
_shiftIndex++;
if (_shiftIndex >= _shiftBuffer.Length)
_shiftIndex = 0;
}
private bool IsShiftReady()
{
return _shiftFillCount > MovingShift;
}
private decimal GetShiftedValue()
{
if (_shiftBuffer.Length == 0)
return 0m;
var offset = Math.Min(MovingShift, _shiftFillCount - 1);
var index = _shiftIndex - 1 - offset;
while (index < 0)
index += _shiftBuffer.Length;
return _shiftBuffer[index];
}
/// <inheritdoc />
protected override void OnOwnTradeReceived(MyTrade trade)
{
base.OnOwnTradeReceived(trade);
if (trade?.Order == null)
return;
var tradePrice = trade.Trade.Price;
var tradeVolume = trade.Trade.Volume;
// Track entries and exits to evaluate profit streaks.
if (trade.Order.Side == Sides.Buy)
{
if (Position > 0)
{
RegisterEntry(tradePrice, tradeVolume, Sides.Buy);
}
else if (Position == 0 && _entrySide == Sides.Sell)
{
EvaluateClosedTrade(tradePrice);
}
}
else if (trade.Order.Side == Sides.Sell)
{
if (Position < 0)
{
RegisterEntry(tradePrice, tradeVolume, Sides.Sell);
}
else if (Position == 0 && _entrySide == Sides.Buy)
{
EvaluateClosedTrade(tradePrice);
}
}
}
private void RegisterEntry(decimal price, decimal volume, Sides side)
{
if (volume <= 0m)
return;
var totalVolume = _entryVolume + volume;
if (totalVolume <= 0m)
{
ResetEntryState();
return;
}
_avgEntryPrice = _entryVolume > 0m
? (_avgEntryPrice * _entryVolume + price * volume) / totalVolume
: price;
_entryVolume = totalVolume;
_entrySide = side;
}
private void EvaluateClosedTrade(decimal exitPrice)
{
if (_entrySide == null || _entryVolume <= 0m)
{
ResetEntryState();
return;
}
decimal profit = 0m;
if (_entrySide == Sides.Buy)
profit = exitPrice - _avgEntryPrice;
else if (_entrySide == Sides.Sell)
profit = _avgEntryPrice - exitPrice;
if (profit > 0m)
{
_consecutiveLosses = 0;
}
else if (profit < 0m)
{
_consecutiveLosses++;
}
LogInfo($"Trade closed. Side={_entrySide}, Entry={_avgEntryPrice}, Exit={exitPrice}, Profit={profit}, LossStreak={_consecutiveLosses}");
ResetEntryState();
}
private void ResetEntryState()
{
_avgEntryPrice = 0m;
_entryVolume = 0m;
_entrySide = null;
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Strategies import Strategy
from StockSharp.Algo.Indicators import SimpleMovingAverage
class moving_averages_strategy(Strategy):
"""Moving average crossover strategy with shifted MA and loss streak position sizing."""
def __init__(self):
super(moving_averages_strategy, self).__init__()
self._maximum_risk = self.Param("MaximumRisk", 0.02) \
.SetGreaterThanZero() \
.SetDisplay("Maximum Risk", "Fraction of equity risked per trade", "Risk")
self._decrease_factor = self.Param("DecreaseFactor", 3.0) \
.SetGreaterThanZero() \
.SetDisplay("Decrease Factor", "Loss streak divisor for position sizing", "Risk")
self._moving_period = self.Param("MovingPeriod", 12) \
.SetGreaterThanZero() \
.SetDisplay("Moving Period", "Simple moving average lookback", "Indicator")
self._moving_shift = self.Param("MovingShift", 6) \
.SetDisplay("Moving Shift", "Bars to shift the moving average", "Indicator")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromHours(4))) \
.SetDisplay("Candle Type", "Timeframe used for signals", "Data")
self._shift_buffer = []
self._shift_index = 0
self._shift_fill_count = 0
self._consecutive_losses = 0
@property
def MaximumRisk(self):
return float(self._maximum_risk.Value)
@property
def DecreaseFactor(self):
return float(self._decrease_factor.Value)
@property
def MovingPeriod(self):
return int(self._moving_period.Value)
@property
def MovingShift(self):
return int(self._moving_shift.Value)
@property
def CandleType(self):
return self._candle_type.Value
def OnStarted2(self, time):
super(moving_averages_strategy, self).OnStarted2(time)
buf_size = max(1, self.MovingShift + 1)
self._shift_buffer = [0.0] * buf_size
self._shift_index = 0
self._shift_fill_count = 0
self._consecutive_losses = 0
self._sma = SimpleMovingAverage()
self._sma.Length = self.MovingPeriod
subscription = self.SubscribeCandles(self.CandleType)
subscription.Bind(self._sma, self.process_candle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawIndicator(area, self._sma)
self.DrawOwnTrades(area)
def process_candle(self, candle, ma_value):
if candle.State != CandleStates.Finished:
return
if not self._sma.IsFormed:
return
ma_val = float(ma_value)
# Update shift buffer
self._update_shift_buffer(ma_val)
if not self._is_shift_ready():
return
shifted_ma = self._get_shifted_value()
open_price = float(candle.OpenPrice)
close = float(candle.ClosePrice)
cross_down = open_price > shifted_ma and close < shifted_ma
cross_up = open_price < shifted_ma and close > shifted_ma
# Manage existing long position
if self.Position > 0:
if cross_down:
self.SellMarket()
return
# Manage existing short position
if self.Position < 0:
if cross_up:
self.BuyMarket()
return
# No position - evaluate entries
if cross_up:
self.BuyMarket()
elif cross_down:
self.SellMarket()
def _update_shift_buffer(self, value):
self._shift_buffer[self._shift_index] = value
if self._shift_fill_count < len(self._shift_buffer):
self._shift_fill_count += 1
self._shift_index += 1
if self._shift_index >= len(self._shift_buffer):
self._shift_index = 0
def _is_shift_ready(self):
return self._shift_fill_count > self.MovingShift
def _get_shifted_value(self):
if len(self._shift_buffer) == 0:
return 0.0
offset = min(self.MovingShift, self._shift_fill_count - 1)
index = self._shift_index - 1 - offset
while index < 0:
index += len(self._shift_buffer)
return self._shift_buffer[index]
def OnReseted(self):
super(moving_averages_strategy, self).OnReseted()
self._shift_buffer = []
self._shift_index = 0
self._shift_fill_count = 0
self._consecutive_losses = 0
def CreateClone(self):
return moving_averages_strategy()