GitHub で見る

Premarket Gap MomoTrader

ロング専用の足の勢いを使う例で、ニューヨークの暦日ごとに1回だけ入ります。「ギャップ」は前回終値から現在終値への上昇を指し、夜間の始値ギャップやEMAクロスは不要です。

エントリーと数量

確定足のみを使い、既定は5分足です。最初の足は前回終値と出来高を保存します。ポジションなしからのエントリーは、厳密な陽線、正のhigh-low幅、総出来高 > MinVolume、正の前回終値、100 * (close - previousClose) / previousClose >= MinGainPctを要求します。

bodyPercent = 100 * (close - open) / (high - low):90%以上は戦略Volumeの100%、85%以上90%未満は50%、75%以上85%未満は25%、75%未満は入りません。下限を含みます。

銘柄に正のVolumeStepがあれば数量をその刻みに切り下げます。ゼロ数量や指定された銘柄の最小・最大数量範囲外は拒否します。既存ポジションを増やしません。

UseSession=trueでは、注文時刻がニューヨーク時間の[SessionStart, SessionEnd)にある必要があります。既定は04:00–09:30で夏時間を反映します。時間フィルターを無効にしても日次制限は有効です。エントリー注文の送信でその日の機会を消費し、後から失敗しても戻しません。

保有と決済

最初の陰線(close < open)またはvolume <= previousVolumeの足で実際のロング全量を決済します。陽線かつ出来高増加なら保有し、同時線も出来高増加なら保有します。決済はエントリー時間帯の外でも判定し、上昇率・実体比率・最低出来高の条件に依存しません。決済した足で再エントリーしません。未完了注文は重複を防ぎますが、前回終値と出来高は毎確定足で更新します。

指標、ショートのエントリー、6時間待機、個別のストップロスや利確はありません。次のエントリーには次の条件を満たすニューヨーク日付が必要です。

既定値

パラメーター 既定値 意味
MinGainPct 5 上昇率の下限百分率。境界を含み、非負
MinVolume 15000 厳密な出来高下限。非負でデータの単位を使う
UseSession true エントリー時刻フィルター。C#プロパティIsUseSession
SessionStart / SessionEnd 04:00 / 09:30 同じニューヨーク日の順序付き時刻。開始を含み、終了を含まない
CandleType 5分 確定シグナル足

既存BTC/TON履歴を使い、各注文と実際のポジション、3段階の数量、ロット切り下げ、日次制限、2種類の決済、保有、春のニューヨーク夏時間移行をテストします。公開既定値5%/15000にはTONで適合足があり、BTCの動作検証はしきい値を明示的に変更します。収益性は証明しません。

namespace StockSharp.Samples.Strategies;

using System;
using System.Collections.Generic;

using Ecng.Common;

using StockSharp.Algo;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;

/// <summary>
/// Opens one long momentum trade per New York day, sizes it by candle body,
/// and exits on a red candle or non-increasing candle volume.
/// </summary>
public class PremarketGapMomoTraderStrategy : Strategy
{
	private readonly StrategyParam<decimal> _minGainPct;
	private readonly StrategyParam<decimal> _minVolume;
	private readonly StrategyParam<bool> _useSession;
	private readonly StrategyParam<TimeSpan> _sessionStart;
	private readonly StrategyParam<TimeSpan> _sessionEnd;
	private readonly StrategyParam<DataType> _candleType;
	private decimal _previousClose;
	private decimal _previousVolume;
	private bool _isInitialized;
	private DateTime? _lastEntryDate;
	private Order _pendingOrder;

	/// <summary>Initializes gain 5%, minimum candle volume 15000 and the 04:00–09:30 New York entry session.</summary>
	public PremarketGapMomoTraderStrategy()
	{
		_minGainPct = Param(nameof(MinGainPct), 5m).SetRange(0m, decimal.MaxValue)
			.SetDisplay("Minimum Gain Percent", "Inclusive close gain over the preceding finished candle", "Entry");
		_minVolume = Param(nameof(MinVolume), 15000m).SetRange(0m, decimal.MaxValue)
			.SetDisplay("Minimum Candle Volume", "Strict lower bound for candle volume", "Entry");
		_useSession = Param("UseSession", true)
			.SetDisplay("Use Session", "Restrict entries to the New York premarket session", "Session");
		_sessionStart = Param(nameof(SessionStart), TimeSpan.FromHours(4))
			.SetDisplay("Session Start", "Inclusive New York entry time", "Session");
		_sessionEnd = Param(nameof(SessionEnd), TimeSpan.FromMinutes(570))
			.SetDisplay("Session End", "Exclusive New York entry time", "Session");
		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
			.SetDisplay("Candle Type", "Finished candles supplying price, body and volume", "General");
		OrderRegistering += order => _pendingOrder = order;
	}

	/// <summary>Inclusive minimum close-to-previous-close gain in percent.</summary>
	public decimal MinGainPct { get => _minGainPct.Value; set => _minGainPct.Value = value; }

	/// <summary>Strict minimum finished-candle volume.</summary>
	public decimal MinVolume { get => _minVolume.Value; set => _minVolume.Value = value; }

	/// <summary>Whether entry times are restricted; the published parameter key is UseSession.</summary>
	public bool IsUseSession { get => _useSession.Value; set => _useSession.Value = value; }

	/// <summary>Inclusive New York entry time, before SessionEnd.</summary>
	public TimeSpan SessionStart { get => _sessionStart.Value; set => _sessionStart.Value = value; }

	/// <summary>Exclusive New York entry time within the same day.</summary>
	public TimeSpan SessionEnd { get => _sessionEnd.Value; set => _sessionEnd.Value = value; }

	/// <summary>Finished candle type; five minutes by default.</summary>
	public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
		=> [(Security, CandleType)];

	private void ResetState()
	{
		_previousClose = _previousVolume = 0m;
		_isInitialized = false;
		_lastEntryDate = null;
		_pendingOrder = null;
	}

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();
		ResetState();
	}

	/// <inheritdoc />
	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);
		if (MinGainPct < 0m || MinVolume < 0m || SessionStart < TimeSpan.Zero || SessionEnd > TimeSpan.FromDays(1)
			|| SessionStart >= SessionEnd)
			throw new ArgumentException("Gain and volume thresholds cannot be negative; session times must be ordered within one New York day.");
		ResetState();
		var subscription = SubscribeCandles(CandleType);
		subscription.Bind(ProcessCandle).Start();
		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawOwnTrades(area);
		}
	}

	private void Submit(Sides side, decimal volume)
		=> RegisterOrder(new Order
		{
			Security = Security,
			Portfolio = Portfolio,
			Type = OrderTypes.Market,
			Side = side,
			Volume = volume,
			Comment = side == Sides.Buy ? "Premarket body momentum" : "Premarket momentum exit",
		});

	private void ProcessCandle(ICandleMessage candle)
	{
		if (candle.State != CandleStates.Finished)
			return;
		var previousClose = _previousClose;
		var previousVolume = _previousVolume;
		_previousClose = candle.ClosePrice;
		_previousVolume = candle.TotalVolume;
		if (!_isInitialized)
		{
			_isInitialized = true;
			return;
		}
		if (!IsFormedAndOnlineAndAllowTrading()
			|| _pendingOrder is not null && _pendingOrder.State is not (OrderStates.Done or OrderStates.Failed))
			return;
		if (Position > 0m)
		{
			if (candle.ClosePrice < candle.OpenPrice || candle.TotalVolume <= previousVolume)
				Submit(Sides.Sell, Position);
			return;
		}
		if (Position != 0m || previousClose <= 0m || candle.ClosePrice <= candle.OpenPrice
			|| candle.TotalVolume <= MinVolume || candle.HighPrice <= candle.LowPrice
			|| 100m * (candle.ClosePrice - previousClose) < MinGainPct * previousClose)
			return;
		var bodyPercent = 100m * (candle.ClosePrice - candle.OpenPrice) / (candle.HighPrice - candle.LowPrice);
		var fraction = bodyPercent >= 90m ? 1m : bodyPercent >= 85m ? 0.5m : bodyPercent >= 75m ? 0.25m : 0m;
		if (fraction == 0m)
			return;
		var local = TimeZoneInfo.ConvertTimeFromUtc(CurrentTime, TimeHelper.Est);
		if (_lastEntryDate == local.Date || IsUseSession && (local.TimeOfDay < SessionStart || local.TimeOfDay >= SessionEnd))
			return;
		var volume = Volume * fraction;
		if (Security.VolumeStep is decimal step && step > 0m)
			volume = volume.Floor(step);
		if (volume <= 0m || Security.MinVolume is decimal min && volume < min
			|| Security.MaxVolume is decimal max && max > 0m && volume > max)
			return;
		_lastEntryDate = local.Date;
		Submit(Sides.Buy, volume);
	}
}