在 GitHub 上查看

烛影比例策略

概述

烛影比例策略 是 MetaTrader 专家顾问 Candle shadow percent 的移植版本。策略会寻找上下影线达到可调比例的 K 线:出现长上影线时开空,出现长下影线时开多。交易方向与原始算法一致,同时保留了风险管理流程。

转换说明

  • 原策略依赖自定义指标。StockSharp 版本直接根据收盘完成的 K 线计算影线与实体比例,无需外部指标。
  • 点值通过 Security.PriceStep 计算。请根据交易品种调整 StopLossPipsTakeProfitPipsMinBodyPips
  • 资金管理按照 MetaTrader 中 CMoneyFixedMargin 的思想实现:使用账户当前权益的一定百分比除以止损距离得到下单数量。

K 线筛选条件

满足以下条件的 K 线才会触发信号:

  1. 绝对实体长度不少于 MinBodyPips * Security.PriceStep
  2. 对应影线长度为正值。
  3. 影线与实体的比例满足阈值逻辑:
    • 上影线(做空):当 TopShadowIsMinimum = true 时要求 (High − max(Open, Close)) / Body * 100 ≥ TopShadowPercent;反之要求该比例小于或等于阈值。
    • 下影线(做多):当 LowerShadowIsMinimum = true 时要求 (min(Open, Close) − Low) / Body * 100 ≥ LowerShadowPercent;反之要求该比例小于或等于阈值。
  4. 如果同一根 K 线同时满足多空条件,策略仅保留比例更大的方向,避免重复下单。

入场规则

  • 空头:当出现有效的上影线信号且当前为空仓或持有多单时执行。若持有多单会自动反手,并立即设置止损止盈。
  • 多头:当出现有效的下影线信号且当前为空仓或持有空单时执行。若持有空单会先平仓再开多。

出场规则

  • 止损:距离入场价 StopLossPips * Security.PriceStep。多单止损位于 entry − stopDistance,空单止损位于 entry + stopDistance
  • 止盈:距离入场价 TakeProfitPips * Security.PriceStep。当 TakeProfitPips = 0 时停用止盈,仅依靠止损或反向信号离场。
  • 策略只在 K 线收盘后评估。如果收盘 K 线触及止损或止盈,持仓会在下一次处理时关闭。

仓位控制

  • 每笔交易风险 = Portfolio.CurrentValue * (RiskPercent / 100)。若账户权益不可用,则回退到策略配置的默认手数。
  • 下单数量 = 风险金额 / 止损距离。若需要反手,会额外加上当前仓位的绝对值,确保完全对冲原仓位,这与原 MQL 实现一致。

参数说明

参数 含义
CandleType 订阅的 K 线数据类型或周期。
StopLossPips 以点/跳动计的止损距离,必须大于 0。
TakeProfitPips 以点/跳动计的止盈距离,0 表示不开启止盈。
RiskPercent 每笔交易承担的账户百分比风险。
MinBodyPips 触发信号所需的最小实体长度(点/跳动)。
EnableTopShadow 是否启用基于上影线的做空信号。
TopShadowPercent 上影线与实体比例的阈值。
TopShadowIsMinimum true 表示比例需大于等于阈值,false 表示比例需小于等于阈值。
EnableLowerShadow 是否启用基于下影线的做多信号。
LowerShadowPercent 下影线与实体比例的阈值。
LowerShadowIsMinimum 控制下影线阈值是最小值条件还是最大值条件。

使用建议

  • 可先使用原 EA 相似的周期(如 5 分钟),再根据品种微调点数参数。
  • 如果噪声过多,可适当提高 MinBodyPips;若希望捕捉更细小的反转,可降低该值。
  • 需要更多过滤条件时,可在 OnStarted 中绑定额外指标。
  • 在真实账户前请先于模拟环境验证跳动值与风险设置是否正确。
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>
/// Candle shadow percent strategy converted from MetaTrader.
/// Trades when a candle shows an extended wick compared to its body.
/// Position size is derived from risk percentage and stop distance.
/// </summary>
public class CandleShadowPercentStrategy : Strategy
{
	private readonly StrategyParam<int> _stopLossPips;
	private readonly StrategyParam<int> _takeProfitPips;
	private readonly StrategyParam<decimal> _riskPercent;
	private readonly StrategyParam<int> _minBodyPips;
	private readonly StrategyParam<bool> _enableTopShadow;
	private readonly StrategyParam<decimal> _topShadowPercent;
	private readonly StrategyParam<bool> _topShadowIsMinimum;
	private readonly StrategyParam<bool> _enableLowerShadow;
	private readonly StrategyParam<decimal> _lowerShadowPercent;
	private readonly StrategyParam<bool> _lowerShadowIsMinimum;
	private readonly StrategyParam<DataType> _candleType;
	
	private decimal? _longStop;
	private decimal? _longTake;
	private decimal? _shortStop;
	private decimal? _shortTake;
	private decimal? _entryPrice;
	
	/// <summary>
	/// Stop loss in pips.
	/// </summary>
	public int StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}
	
	/// <summary>
	/// Take profit in pips.
	/// </summary>
	public int TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}
	
	/// <summary>
	/// Risk percentage per trade.
	/// </summary>
	public decimal RiskPercent
	{
		get => _riskPercent.Value;
		set => _riskPercent.Value = value;
	}
	
	/// <summary>
	/// Minimum body size in pips to evaluate shadows.
	/// </summary>
	public int MinBodyPips
	{
		get => _minBodyPips.Value;
		set => _minBodyPips.Value = value;
	}
	
	/// <summary>
	/// Enables signals based on the top shadow.
	/// </summary>
	public bool EnableTopShadow
	{
		get => _enableTopShadow.Value;
		set => _enableTopShadow.Value = value;
	}
	
	/// <summary>
	/// Threshold for the top shadow as a percentage of the body.
	/// </summary>
	public decimal TopShadowPercent
	{
		get => _topShadowPercent.Value;
		set => _topShadowPercent.Value = value;
	}
	
	/// <summary>
	/// If true the top shadow percentage acts as a minimum threshold.
	/// </summary>
	public bool TopShadowIsMinimum
	{
		get => _topShadowIsMinimum.Value;
		set => _topShadowIsMinimum.Value = value;
	}
	
	/// <summary>
	/// Enables signals based on the lower shadow.
	/// </summary>
	public bool EnableLowerShadow
	{
		get => _enableLowerShadow.Value;
		set => _enableLowerShadow.Value = value;
	}
	
	/// <summary>
	/// Threshold for the lower shadow as a percentage of the body.
	/// </summary>
	public decimal LowerShadowPercent
	{
		get => _lowerShadowPercent.Value;
		set => _lowerShadowPercent.Value = value;
	}
	
	/// <summary>
	/// If true the lower shadow percentage acts as a minimum threshold.
	/// </summary>
	public bool LowerShadowIsMinimum
	{
		get => _lowerShadowIsMinimum.Value;
		set => _lowerShadowIsMinimum.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="CandleShadowPercentStrategy"/>.
	/// </summary>
	public CandleShadowPercentStrategy()
	{
		_stopLossPips = Param(nameof(StopLossPips), 50)
			.SetGreaterThanZero()
			.SetDisplay("Stop Loss", "Stop loss distance in pips", "Risk")
			;
		
		_takeProfitPips = Param(nameof(TakeProfitPips), 50)
			.SetNotNegative()
			.SetDisplay("Take Profit", "Take profit distance in pips", "Risk")
			;
		
		_riskPercent = Param(nameof(RiskPercent), 5m)
			.SetGreaterThanZero()
			.SetDisplay("Risk %", "Risk percentage per trade", "Risk")
			;
		
		_minBodyPips = Param(nameof(MinBodyPips), 300)
			.SetGreaterThanZero()
			.SetDisplay("Minimum Body", "Minimum candle body size in pips", "Pattern")
			;
		
		_enableTopShadow = Param(nameof(EnableTopShadow), true)
			.SetDisplay("Use Top Shadow", "Enable sell signals from upper wicks", "Pattern");
		
		_topShadowPercent = Param(nameof(TopShadowPercent), 30m)
			.SetNotNegative()
			.SetDisplay("Top Shadow %", "Upper wick percentage threshold", "Pattern")
			;
		
		_topShadowIsMinimum = Param(nameof(TopShadowIsMinimum), true)
			.SetDisplay("Top Shadow Uses Min", "If true the threshold is treated as a minimum", "Pattern");
		
		_enableLowerShadow = Param(nameof(EnableLowerShadow), true)
			.SetDisplay("Use Lower Shadow", "Enable buy signals from lower wicks", "Pattern");
		
		_lowerShadowPercent = Param(nameof(LowerShadowPercent), 80m)
			.SetNotNegative()
			.SetDisplay("Lower Shadow %", "Lower wick percentage threshold", "Pattern")
			;
		
		_lowerShadowIsMinimum = Param(nameof(LowerShadowIsMinimum), true)
			.SetDisplay("Lower Shadow Uses Min", "If true the threshold is treated as a minimum", "Pattern");
		
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe used for pattern detection", "Data");
	}
	
	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		return [(Security, CandleType)];
	}
	
	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();
		
		_longStop = null;
		_longTake = null;
		_shortStop = null;
		_shortTake = null;
		_entryPrice = null;
	}
	
	/// <inheritdoc />
	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);
		
		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(ProcessCandle)
			.Start();
		
		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawOwnTrades(area);
		}
	}
	
	private void ProcessCandle(ICandleMessage candle)
	{
		if (candle.State != CandleStates.Finished)
			return;
		
		ManageOpenPosition(candle);
		
		var pipSize = GetPipSize();
		var minBody = MinBodyPips * pipSize;
		
		var body = Math.Abs(candle.ClosePrice - candle.OpenPrice);
		if (body < minBody || body <= 0m)
			return;
		
		var upperShadow = candle.HighPrice - Math.Max(candle.OpenPrice, candle.ClosePrice);
		var lowerShadow = Math.Min(candle.OpenPrice, candle.ClosePrice) - candle.LowPrice;
		
		var topRatio = body > 0m ? upperShadow / body * 100m : 0m;
		var lowerRatio = body > 0m ? lowerShadow / body * 100m : 0m;
		
		var topSignal = EnableTopShadow && upperShadow > 0m && CheckThreshold(topRatio, TopShadowPercent, TopShadowIsMinimum);
		var lowerSignal = EnableLowerShadow && lowerShadow > 0m && CheckThreshold(lowerRatio, LowerShadowPercent, LowerShadowIsMinimum);
		
		if (topSignal && lowerSignal)
		{
			if (topRatio > lowerRatio)
				lowerSignal = false;
			else
				topSignal = false;
		}
		
		if (topSignal && Position <= 0)
		{
			EnterShort(candle, pipSize);
		}
		else if (lowerSignal && Position >= 0)
		{
			EnterLong(candle, pipSize);
		}
	}
	
	private void ManageOpenPosition(ICandleMessage candle)
	{
		if (Position > 0)
		{
			var stopHit = _longStop.HasValue && candle.LowPrice <= _longStop.Value;
			var takeHit = _longTake.HasValue && candle.HighPrice >= _longTake.Value;
			
			if (stopHit || takeHit)
			{
				SellMarket();
				this.LogInfo($"Closing long at {candle.ClosePrice}. Stop hit: {stopHit}, Take hit: {takeHit}");
				_longStop = null;
				_longTake = null;
				_entryPrice = null;
			}
		}
		else if (Position < 0)
		{
			var stopHit = _shortStop.HasValue && candle.HighPrice >= _shortStop.Value;
			var takeHit = _shortTake.HasValue && candle.LowPrice <= _shortTake.Value;
			
			if (stopHit || takeHit)
			{
				BuyMarket();
				this.LogInfo($"Closing short at {candle.ClosePrice}. Stop hit: {stopHit}, Take hit: {takeHit}");
				_shortStop = null;
				_shortTake = null;
				_entryPrice = null;
			}
		}
	}
	
	private void EnterLong(ICandleMessage candle, decimal pipSize)
	{
		var stopDistance = StopLossPips * pipSize;
		if (stopDistance <= 0m)
			return;

		var takeDistance = TakeProfitPips * pipSize;

		var entryPrice = candle.ClosePrice;
		var stopPrice = entryPrice - stopDistance;
		var takePrice = takeDistance > 0m ? entryPrice + takeDistance : (decimal?)null;

		BuyMarket();

		_longStop = stopPrice;
		_longTake = takePrice;
		_shortStop = null;
		_shortTake = null;
		_entryPrice = entryPrice;

		this.LogInfo($"Entered long at {entryPrice}. Stop {stopPrice}, Take {(takePrice.HasValue ? takePrice.Value.ToString() : "n/a")}");
	}
	
	private void EnterShort(ICandleMessage candle, decimal pipSize)
	{
		var stopDistance = StopLossPips * pipSize;
		if (stopDistance <= 0m)
			return;

		var takeDistance = TakeProfitPips * pipSize;

		var entryPrice = candle.ClosePrice;
		var stopPrice = entryPrice + stopDistance;
		var takePrice = takeDistance > 0m ? entryPrice - takeDistance : (decimal?)null;

		SellMarket();

		_shortStop = stopPrice;
		_shortTake = takePrice;
		_longStop = null;
		_longTake = null;
		_entryPrice = entryPrice;

		this.LogInfo($"Entered short at {entryPrice}. Stop {stopPrice}, Take {(takePrice.HasValue ? takePrice.Value.ToString() : "n/a")}");
	}
	
	private decimal CalculatePositionSize(decimal stopDistance)
	{
		var defaultVolume = Volume > 0m ? Volume : 1m;
		
		var portfolioValue = Portfolio?.CurrentValue ?? Portfolio?.BeginValue ?? 0m;
		if (portfolioValue <= 0m)
			return defaultVolume;

		var riskAmount = portfolioValue * (RiskPercent / 100m);
		if (riskAmount <= 0m || stopDistance <= 0m)
			return defaultVolume;
		
		var size = riskAmount / stopDistance;
		return size > 0m ? size : defaultVolume;
	}
	
	private static bool CheckThreshold(decimal ratio, decimal threshold, bool isMinimum)
	{
		return isMinimum ? ratio >= threshold : ratio <= threshold;
	}
	
	private decimal GetPipSize()
	{
		return Security?.PriceStep ?? 1m;
	}
}