Production hardening: kill switch, circuit breaker, trailing stops, log level, holiday calendar
Some checks failed
Build and Test / build (push) Has been cancelled

This commit is contained in:
2026-02-24 15:00:41 -05:00
parent 0e36fe5d23
commit a87152effb
50 changed files with 12849 additions and 752 deletions

View File

@@ -10,4 +10,9 @@
<ProjectReference Include="..\NT8.Core\NT8.Core.csproj" />
</ItemGroup>
</Project>
<ItemGroup>
<Compile Remove="Strategies\**\*.cs" />
<None Include="Strategies\**\*.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,365 @@
using System;
using System.Collections.Generic;
using NT8.Core.OMS;
namespace NT8.Adapters.NinjaTrader
{
/// <summary>
/// Adapter for executing orders through NinjaTrader 8 platform.
/// Bridges SDK order requests to NT8 order submission and handles callbacks.
/// Thread-safe for concurrent NT8 callbacks.
/// </summary>
public class NT8ExecutionAdapter
{
private readonly object _lock = new object();
private readonly Dictionary<string, OrderTrackingInfo> _orderTracking;
private readonly Dictionary<string, string> _nt8ToSdkOrderMap;
/// <summary>
/// Creates a new NT8 execution adapter.
/// </summary>
public NT8ExecutionAdapter()
{
_orderTracking = new Dictionary<string, OrderTrackingInfo>();
_nt8ToSdkOrderMap = new Dictionary<string, string>();
}
/// <summary>
/// Submit an order to NinjaTrader 8.
/// NOTE: This method tracks order state only. Actual NT8 submission is performed by strategy wrapper code.
/// </summary>
/// <param name="request">SDK order request.</param>
/// <param name="sdkOrderId">Unique SDK order ID.</param>
/// <returns>Tracking info for the submitted order.</returns>
/// <exception cref="ArgumentNullException">Thrown when request or sdkOrderId is invalid.</exception>
/// <exception cref="InvalidOperationException">Thrown when the same order ID is submitted twice.</exception>
public OrderTrackingInfo SubmitOrder(OrderRequest request, string sdkOrderId)
{
if (request == null)
{
throw new ArgumentNullException("request");
}
if (string.IsNullOrWhiteSpace(sdkOrderId))
{
throw new ArgumentNullException("sdkOrderId");
}
try
{
lock (_lock)
{
if (_orderTracking.ContainsKey(sdkOrderId))
{
throw new InvalidOperationException(string.Format("Order {0} already exists", sdkOrderId));
}
var trackingInfo = new OrderTrackingInfo();
trackingInfo.SdkOrderId = sdkOrderId;
trackingInfo.Nt8OrderId = null;
trackingInfo.OriginalRequest = request;
trackingInfo.CurrentState = OrderState.Pending;
trackingInfo.FilledQuantity = 0;
trackingInfo.AverageFillPrice = 0.0;
trackingInfo.LastUpdate = DateTime.UtcNow;
trackingInfo.ErrorMessage = null;
_orderTracking.Add(sdkOrderId, trackingInfo);
return trackingInfo;
}
}
catch (Exception)
{
throw;
}
}
/// <summary>
/// Process order update callback from NinjaTrader 8.
/// Called by NT8 strategy wrapper OnOrderUpdate.
/// </summary>
/// <param name="nt8OrderId">NT8 order ID.</param>
/// <param name="sdkOrderId">SDK order ID.</param>
/// <param name="orderState">NT8 order state string.</param>
/// <param name="filled">Filled quantity.</param>
/// <param name="averageFillPrice">Average fill price.</param>
/// <param name="errorCode">Error code if rejected.</param>
/// <param name="errorMessage">Error message if rejected.</param>
public void ProcessOrderUpdate(
string nt8OrderId,
string sdkOrderId,
string orderState,
int filled,
double averageFillPrice,
int errorCode,
string errorMessage)
{
if (string.IsNullOrWhiteSpace(sdkOrderId))
{
return;
}
try
{
lock (_lock)
{
if (!_orderTracking.ContainsKey(sdkOrderId))
{
return;
}
var info = _orderTracking[sdkOrderId];
if (!string.IsNullOrWhiteSpace(nt8OrderId) && info.Nt8OrderId == null)
{
info.Nt8OrderId = nt8OrderId;
_nt8ToSdkOrderMap[nt8OrderId] = sdkOrderId;
}
info.CurrentState = MapNT8OrderState(orderState);
info.FilledQuantity = filled;
info.AverageFillPrice = averageFillPrice;
info.LastUpdate = DateTime.UtcNow;
if (errorCode != 0 && !string.IsNullOrWhiteSpace(errorMessage))
{
info.ErrorMessage = string.Format("[{0}] {1}", errorCode, errorMessage);
info.CurrentState = OrderState.Rejected;
}
}
}
catch (Exception)
{
throw;
}
}
/// <summary>
/// Process execution callback from NinjaTrader 8.
/// Called by NT8 strategy wrapper OnExecutionUpdate.
/// </summary>
/// <param name="nt8OrderId">NT8 order ID.</param>
/// <param name="executionId">Execution identifier.</param>
/// <param name="price">Execution price.</param>
/// <param name="quantity">Execution quantity.</param>
/// <param name="time">Execution time.</param>
public void ProcessExecution(
string nt8OrderId,
string executionId,
double price,
int quantity,
DateTime time)
{
if (string.IsNullOrWhiteSpace(nt8OrderId))
{
return;
}
try
{
lock (_lock)
{
if (!_nt8ToSdkOrderMap.ContainsKey(nt8OrderId))
{
return;
}
var sdkOrderId = _nt8ToSdkOrderMap[nt8OrderId];
if (!_orderTracking.ContainsKey(sdkOrderId))
{
return;
}
var info = _orderTracking[sdkOrderId];
info.LastUpdate = time;
if (info.FilledQuantity >= info.OriginalRequest.Quantity)
{
info.CurrentState = OrderState.Filled;
}
else if (info.FilledQuantity > 0)
{
info.CurrentState = OrderState.PartiallyFilled;
}
}
}
catch (Exception)
{
throw;
}
}
/// <summary>
/// Request to cancel an order.
/// NOTE: Actual cancellation is performed by strategy wrapper code.
/// </summary>
/// <param name="sdkOrderId">SDK order ID to cancel.</param>
/// <returns>True when cancel request is accepted; otherwise false.</returns>
public bool CancelOrder(string sdkOrderId)
{
if (string.IsNullOrWhiteSpace(sdkOrderId))
{
throw new ArgumentNullException("sdkOrderId");
}
try
{
lock (_lock)
{
if (!_orderTracking.ContainsKey(sdkOrderId))
{
return false;
}
var info = _orderTracking[sdkOrderId];
if (info.CurrentState == OrderState.Filled ||
info.CurrentState == OrderState.Cancelled ||
info.CurrentState == OrderState.Rejected)
{
return false;
}
info.LastUpdate = DateTime.UtcNow;
return true;
}
}
catch (Exception)
{
throw;
}
}
/// <summary>
/// Get current status of an order.
/// </summary>
/// <param name="sdkOrderId">SDK order ID.</param>
/// <returns>Order status snapshot; null when not found.</returns>
public OrderStatus GetOrderStatus(string sdkOrderId)
{
if (string.IsNullOrWhiteSpace(sdkOrderId))
{
return null;
}
try
{
lock (_lock)
{
if (!_orderTracking.ContainsKey(sdkOrderId))
{
return null;
}
var info = _orderTracking[sdkOrderId];
var status = new OrderStatus();
status.OrderId = info.SdkOrderId;
status.Symbol = info.OriginalRequest.Symbol;
status.Side = info.OriginalRequest.Side;
status.Quantity = info.OriginalRequest.Quantity;
status.Type = info.OriginalRequest.Type;
status.State = info.CurrentState;
status.FilledQuantity = info.FilledQuantity;
status.AverageFillPrice = info.FilledQuantity > 0 ? (decimal)info.AverageFillPrice : 0m;
status.CreatedTime = info.LastUpdate;
status.FilledTime = info.FilledQuantity > 0 ? (DateTime?)info.LastUpdate : null;
return status;
}
}
catch (Exception)
{
throw;
}
}
/// <summary>
/// Maps NinjaTrader order state string to SDK order state.
/// </summary>
/// <param name="nt8State">NT8 order state string.</param>
/// <returns>Mapped SDK state.</returns>
private OrderState MapNT8OrderState(string nt8State)
{
if (string.IsNullOrWhiteSpace(nt8State))
{
return OrderState.Expired;
}
switch (nt8State.ToUpperInvariant())
{
case "ACCEPTED":
case "WORKING":
return OrderState.Working;
case "FILLED":
return OrderState.Filled;
case "PARTFILLED":
case "PARTIALLYFILLED":
return OrderState.PartiallyFilled;
case "CANCELLED":
case "CANCELED":
return OrderState.Cancelled;
case "REJECTED":
return OrderState.Rejected;
case "PENDINGCANCEL":
return OrderState.Working;
case "PENDINGCHANGE":
case "PENDINGSUBMIT":
return OrderState.Pending;
default:
return OrderState.Expired;
}
}
}
/// <summary>
/// Internal tracking information for orders managed by NT8ExecutionAdapter.
/// </summary>
public class OrderTrackingInfo
{
/// <summary>
/// SDK order identifier.
/// </summary>
public string SdkOrderId { get; set; }
/// <summary>
/// NinjaTrader order identifier.
/// </summary>
public string Nt8OrderId { get; set; }
/// <summary>
/// Original order request.
/// </summary>
public OrderRequest OriginalRequest { get; set; }
/// <summary>
/// Current SDK order state.
/// </summary>
public OrderState CurrentState { get; set; }
/// <summary>
/// Filled quantity.
/// </summary>
public int FilledQuantity { get; set; }
/// <summary>
/// Average fill price.
/// </summary>
public double AverageFillPrice { get; set; }
/// <summary>
/// Last update timestamp.
/// </summary>
public DateTime LastUpdate { get; set; }
/// <summary>
/// Last error message.
/// </summary>
public string ErrorMessage { get; set; }
}
}

View File

@@ -0,0 +1,59 @@
// File: MinimalTestStrategy.cs
using System;
using NinjaTrader.Cbi;
using NinjaTrader.Data;
using NinjaTrader.NinjaScript;
using NinjaTrader.NinjaScript.Strategies;
namespace NinjaTrader.NinjaScript.Strategies
{
/// <summary>
/// Minimal test strategy to validate NT8 integration and compilation.
/// </summary>
public class MinimalTestStrategy : Strategy
{
private int _barCount;
protected override void OnStateChange()
{
if (State == State.SetDefaults)
{
Name = "Minimal Test";
Description = "Simple test strategy - logs bars only";
Calculate = Calculate.OnBarClose;
BarsRequiredToTrade = 1;
}
else if (State == State.DataLoaded)
{
_barCount = 0;
Print("[MinimalTest] Strategy initialized");
}
else if (State == State.Terminated)
{
Print(string.Format("[MinimalTest] Strategy terminated. Processed {0} bars", _barCount));
}
}
protected override void OnBarUpdate()
{
if (CurrentBar < BarsRequiredToTrade)
return;
_barCount++;
if (_barCount % 10 == 0)
{
Print(string.Format(
"[MinimalTest] Bar {0}: {1} O={2:F2} H={3:F2} L={4:F2} C={5:F2} V={6}",
CurrentBar,
Time[0].ToString("HH:mm:ss"),
Open[0],
High[0],
Low[0],
Close[0],
Volume[0]));
}
}
}
}

View File

@@ -0,0 +1,545 @@
// File: NT8StrategyBase.cs
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using NinjaTrader.Cbi;
using NinjaTrader.Data;
using NinjaTrader.Gui;
using NinjaTrader.Gui.Chart;
using NinjaTrader.Gui.Tools;
using NinjaTrader.NinjaScript;
using NinjaTrader.NinjaScript.Indicators;
using NinjaTrader.NinjaScript.Strategies;
using Microsoft.Extensions.Logging.Abstractions;
using NT8.Adapters.NinjaTrader;
using NT8.Core.Common.Interfaces;
using NT8.Core.Common.Models;
using NT8.Core.Execution;
using NT8.Core.Logging;
using NT8.Core.Risk;
using NT8.Core.Sizing;
using SdkPosition = NT8.Core.Common.Models.Position;
using SdkOrderSide = NT8.Core.Common.Models.OrderSide;
using SdkOrderType = NT8.Core.Common.Models.OrderType;
using OmsOrderRequest = NT8.Core.OMS.OrderRequest;
using OmsOrderSide = NT8.Core.OMS.OrderSide;
using OmsOrderType = NT8.Core.OMS.OrderType;
using OmsOrderState = NT8.Core.OMS.OrderState;
using OmsOrderStatus = NT8.Core.OMS.OrderStatus;
namespace NinjaTrader.NinjaScript.Strategies
{
/// <summary>
/// Base class for strategies that integrate NT8 SDK components.
/// </summary>
public abstract class NT8StrategyBase : Strategy
{
private readonly object _lock = new object();
protected IStrategy _sdkStrategy;
protected IRiskManager _riskManager;
protected IPositionSizer _positionSizer;
protected NT8ExecutionAdapter _executionAdapter;
protected ILogger _logger;
protected StrategyConfig _strategyConfig;
protected RiskConfig _riskConfig;
protected SizingConfig _sizingConfig;
private bool _sdkInitialized;
private AccountInfo _lastAccountInfo;
private SdkPosition _lastPosition;
private MarketSession _currentSession;
private int _ordersSubmittedToday;
private DateTime _lastBarTime;
private bool _killSwitchTriggered;
private ExecutionCircuitBreaker _circuitBreaker;
#region User-Configurable Properties
[NinjaScriptProperty]
[Display(Name = "Enable SDK", GroupName = "SDK", Order = 1)]
public bool EnableSDK { get; set; }
[NinjaScriptProperty]
[Display(Name = "Daily Loss Limit", GroupName = "Risk", Order = 1)]
public double DailyLossLimit { get; set; }
[NinjaScriptProperty]
[Display(Name = "Max Trade Risk", GroupName = "Risk", Order = 2)]
public double MaxTradeRisk { get; set; }
[NinjaScriptProperty]
[Display(Name = "Max Positions", GroupName = "Risk", Order = 3)]
public int MaxOpenPositions { get; set; }
[NinjaScriptProperty]
[Display(Name = "Risk Per Trade", GroupName = "Sizing", Order = 1)]
public double RiskPerTrade { get; set; }
[NinjaScriptProperty]
[Display(Name = "Min Contracts", GroupName = "Sizing", Order = 2)]
public int MinContracts { get; set; }
[NinjaScriptProperty]
[Display(Name = "Max Contracts", GroupName = "Sizing", Order = 3)]
public int MaxContracts { get; set; }
[NinjaScriptProperty]
[Display(Name = "Kill Switch (Flatten + Stop)", GroupName = "Emergency Controls", Order = 1)]
public bool EnableKillSwitch { get; set; }
[NinjaScriptProperty]
[Display(Name = "Verbose Logging", GroupName = "Debug", Order = 1)]
public bool EnableVerboseLogging { get; set; }
#endregion
/// <summary>
/// Create the SDK strategy instance.
/// </summary>
protected abstract IStrategy CreateSdkStrategy();
/// <summary>
/// Configure strategy-specific values after initialization.
/// </summary>
protected abstract void ConfigureStrategyParameters();
protected override void OnStateChange()
{
if (State == State.SetDefaults)
{
Description = "SDK-integrated strategy base";
// Name intentionally not set - this is an abstract base class
Calculate = Calculate.OnBarClose;
EntriesPerDirection = 1;
EntryHandling = EntryHandling.AllEntries;
IsExitOnSessionCloseStrategy = true;
ExitOnSessionCloseSeconds = 30;
IsFillLimitOnTouch = false;
MaximumBarsLookBack = MaximumBarsLookBack.TwoHundredFiftySix;
OrderFillResolution = OrderFillResolution.Standard;
Slippage = 0;
StartBehavior = StartBehavior.WaitUntilFlat;
TimeInForce = TimeInForce.Gtc;
TraceOrders = false;
RealtimeErrorHandling = RealtimeErrorHandling.StopCancelClose;
StopTargetHandling = StopTargetHandling.PerEntryExecution;
BarsRequiredToTrade = 20;
EnableSDK = true;
DailyLossLimit = 1000.0;
MaxTradeRisk = 200.0;
MaxOpenPositions = 3;
RiskPerTrade = 100.0;
MinContracts = 1;
MaxContracts = 10;
EnableKillSwitch = false;
EnableVerboseLogging = false;
_killSwitchTriggered = false;
}
else if (State == State.DataLoaded)
{
if (EnableSDK)
{
try
{
InitializeSdkComponents();
_sdkInitialized = true;
Print(string.Format("[SDK] {0} initialized successfully", Name));
}
catch (Exception ex)
{
Print(string.Format("[SDK ERROR] Initialization failed: {0}", ex.Message));
Log(string.Format("[SDK ERROR] {0}", ex.ToString()), LogLevel.Error);
_sdkInitialized = false;
}
}
}
}
protected override void OnBarUpdate()
{
// Kill switch check — must be first
if (EnableKillSwitch)
{
if (!_killSwitchTriggered)
{
_killSwitchTriggered = true;
Print(string.Format("[SDK] KILL SWITCH ACTIVATED at {0} — flattening all positions.", Time[0]));
try
{
ExitLong("KillSwitch");
ExitShort("KillSwitch");
}
catch (Exception ex)
{
Print(string.Format("[SDK] Kill switch flatten error: {0}", ex.Message));
}
}
return;
}
if (!_sdkInitialized || _sdkStrategy == null)
{
if (CurrentBar == 0)
Print(string.Format("[SDK] Not initialized: sdkInit={0}, strategy={1}", _sdkInitialized, _sdkStrategy != null));
return;
}
if (CurrentBar < BarsRequiredToTrade)
{
if (CurrentBar == 0)
Print(string.Format("[SDK] Waiting for bars: current={0}, required={1}", CurrentBar, BarsRequiredToTrade));
return;
}
if (Time[0] == _lastBarTime)
return;
_lastBarTime = Time[0];
// Log first processable bar and every 100th bar.
if (CurrentBar == BarsRequiredToTrade || CurrentBar % 100 == 0)
{
Print(string.Format("[SDK] Processing bar {0}: {1} O={2:F2} H={3:F2} L={4:F2} C={5:F2}",
CurrentBar,
Time[0].ToString("yyyy-MM-dd HH:mm"),
Open[0],
High[0],
Low[0],
Close[0]));
}
try
{
var barData = ConvertCurrentBar();
var context = BuildStrategyContext();
StrategyIntent intent;
lock (_lock)
{
intent = _sdkStrategy.OnBar(barData, context);
}
if (intent != null)
{
Print(string.Format("[SDK] Intent generated: {0} {1} @ {2}", intent.Side, intent.Symbol, intent.EntryType));
ProcessStrategyIntent(intent, context);
}
}
catch (Exception ex)
{
if (_logger != null)
_logger.LogError("OnBarUpdate failed: {0}", ex.Message);
Print(string.Format("[SDK ERROR] OnBarUpdate: {0}", ex.Message));
Log(string.Format("[SDK ERROR] {0}", ex.ToString()), LogLevel.Error);
}
}
protected override void OnOrderUpdate(
Order order,
double limitPrice,
double stopPrice,
int quantity,
int filled,
double averageFillPrice,
NinjaTrader.Cbi.OrderState orderState,
DateTime time,
ErrorCode errorCode,
string nativeError)
{
if (!_sdkInitialized || _executionAdapter == null || order == null)
return;
if (string.IsNullOrEmpty(order.Name) || !order.Name.StartsWith("SDK_"))
return;
// Record NT8 rejections in circuit breaker
if (orderState == NinjaTrader.Cbi.OrderState.Rejected && _circuitBreaker != null)
{
var reason = string.Format("{0} {1}", errorCode, nativeError ?? string.Empty);
_circuitBreaker.RecordOrderRejection(reason);
Print(string.Format("[SDK] Order rejected by NT8: {0}", reason));
}
_executionAdapter.ProcessOrderUpdate(
order.OrderId,
order.Name,
orderState.ToString(),
filled,
averageFillPrice,
(int)errorCode,
nativeError);
}
protected override void OnExecutionUpdate(
Execution execution,
string executionId,
double price,
int quantity,
MarketPosition marketPosition,
string orderId,
DateTime time)
{
if (!_sdkInitialized || _executionAdapter == null || execution == null || execution.Order == null)
return;
if (string.IsNullOrEmpty(execution.Order.Name) || !execution.Order.Name.StartsWith("SDK_"))
return;
_executionAdapter.ProcessExecution(orderId, executionId, price, quantity, time);
}
private void InitializeSdkComponents()
{
_logger = new BasicLogger(Name);
Print(string.Format("[SDK] Initializing with: DailyLoss={0:C}, TradeRisk={1:C}, MaxPos={2}",
DailyLossLimit,
MaxTradeRisk,
MaxOpenPositions));
_riskConfig = new RiskConfig(DailyLossLimit, MaxTradeRisk, MaxOpenPositions, true);
_sizingConfig = new SizingConfig(
SizingMethod.FixedDollarRisk,
MinContracts,
MaxContracts,
RiskPerTrade,
new Dictionary<string, object>());
_strategyConfig = new StrategyConfig(
Name,
Instrument.MasterInstrument.Name,
new Dictionary<string, object>(),
_riskConfig,
_sizingConfig);
_riskManager = new BasicRiskManager(_logger);
_positionSizer = new BasicPositionSizer(_logger);
_circuitBreaker = new ExecutionCircuitBreaker(
NullLogger<ExecutionCircuitBreaker>.Instance,
failureThreshold: 3,
timeout: TimeSpan.FromSeconds(30));
_executionAdapter = new NT8ExecutionAdapter();
_sdkStrategy = CreateSdkStrategy();
if (_sdkStrategy == null)
throw new InvalidOperationException("CreateSdkStrategy returned null");
_sdkStrategy.Initialize(_strategyConfig, null, _logger);
ConfigureStrategyParameters();
_ordersSubmittedToday = 0;
_lastBarTime = DateTime.MinValue;
_lastAccountInfo = null;
_lastPosition = null;
_currentSession = null;
}
private BarData ConvertCurrentBar()
{
return NT8DataConverter.ConvertBar(
Instrument.MasterInstrument.Name,
Time[0],
Open[0],
High[0],
Low[0],
Close[0],
(long)Volume[0],
(int)BarsPeriod.Value);
}
private StrategyContext BuildStrategyContext()
{
var customData = new Dictionary<string, object>();
customData.Add("CurrentBar", CurrentBar);
customData.Add("BarsRequiredToTrade", BarsRequiredToTrade);
customData.Add("OrdersToday", _ordersSubmittedToday);
return NT8DataConverter.ConvertContext(
Instrument.MasterInstrument.Name,
Time[0],
BuildPositionInfo(),
BuildAccountInfo(),
BuildSessionInfo(),
customData);
}
private AccountInfo BuildAccountInfo()
{
var accountInfo = NT8DataConverter.ConvertAccount(100000.0, 250000.0, 0.0, 0.0, DateTime.UtcNow);
_lastAccountInfo = accountInfo;
return accountInfo;
}
private SdkPosition BuildPositionInfo()
{
var p = NT8DataConverter.ConvertPosition(
Instrument.MasterInstrument.Name,
Position.Quantity,
Position.AveragePrice,
0.0,
0.0,
DateTime.UtcNow);
_lastPosition = p;
return p;
}
private MarketSession BuildSessionInfo()
{
if (_currentSession != null && _currentSession.SessionStart.Date == Time[0].Date)
return _currentSession;
var sessionStart = Time[0].Date.AddHours(9).AddMinutes(30);
var sessionEnd = Time[0].Date.AddHours(16);
var isRth = Time[0].Hour >= 9 && Time[0].Hour < 16;
var sessionName = isRth ? "RTH" : "ETH";
_currentSession = NT8DataConverter.ConvertSession(sessionStart, sessionEnd, isRth, sessionName);
return _currentSession;
}
private void ProcessStrategyIntent(StrategyIntent intent, StrategyContext context)
{
if (EnableVerboseLogging)
Print(string.Format("[SDK] Validating intent: {0} {1}", intent.Side, intent.Symbol));
var riskDecision = _riskManager.ValidateOrder(intent, context, _riskConfig);
if (!riskDecision.Allow)
{
if (EnableVerboseLogging)
Print(string.Format("[SDK] Risk REJECTED: {0}", riskDecision.RejectReason));
if (_logger != null)
_logger.LogWarning("Intent rejected by risk manager: {0}", riskDecision.RejectReason);
return;
}
if (EnableVerboseLogging)
Print(string.Format("[SDK] Risk approved"));
var sizingResult = _positionSizer.CalculateSize(intent, context, _sizingConfig);
if (EnableVerboseLogging)
{
Print(string.Format("[SDK] Position size: {0} contracts (min={1}, max={2})",
sizingResult.Contracts,
MinContracts,
MaxContracts));
}
if (sizingResult.Contracts < MinContracts)
{
if (EnableVerboseLogging)
Print(string.Format("[SDK] Size too small: {0} < {1}", sizingResult.Contracts, MinContracts));
return;
}
var request = new OmsOrderRequest();
request.Symbol = intent.Symbol;
request.Side = MapOrderSide(intent.Side);
request.Type = MapOrderType(intent.EntryType);
request.Quantity = sizingResult.Contracts;
request.LimitPrice = intent.LimitPrice.HasValue ? (decimal?)intent.LimitPrice.Value : null;
request.StopPrice = null;
if (EnableVerboseLogging)
{
Print(string.Format("[SDK] Submitting order: {0} {1} {2} @ {3}",
request.Side,
request.Quantity,
request.Symbol,
request.Type));
}
SubmitOrderToNT8(request, intent);
_ordersSubmittedToday++;
}
private void SubmitOrderToNT8(OmsOrderRequest request, StrategyIntent intent)
{
// Circuit breaker gate
if (_circuitBreaker != null && !_circuitBreaker.ShouldAllowOrder())
{
var state = _circuitBreaker.GetState();
Print(string.Format("[SDK] Circuit breaker OPEN — order blocked: {0}", state.Reason));
if (_logger != null)
_logger.LogWarning("Circuit breaker blocked order: {0}", state.Reason);
return;
}
try
{
var orderName = string.Format("SDK_{0}_{1}", intent.Symbol, DateTime.Now.Ticks);
_executionAdapter.SubmitOrder(request, orderName);
if (request.Side == OmsOrderSide.Buy)
{
if (request.Type == OmsOrderType.Market)
EnterLong(request.Quantity, orderName);
else if (request.Type == OmsOrderType.Limit && request.LimitPrice.HasValue)
EnterLongLimit(request.Quantity, (double)request.LimitPrice.Value, orderName);
else if (request.Type == OmsOrderType.StopMarket && request.StopPrice.HasValue)
EnterLongStopMarket(request.Quantity, (double)request.StopPrice.Value, orderName);
}
else if (request.Side == OmsOrderSide.Sell)
{
if (request.Type == OmsOrderType.Market)
EnterShort(request.Quantity, orderName);
else if (request.Type == OmsOrderType.Limit && request.LimitPrice.HasValue)
EnterShortLimit(request.Quantity, (double)request.LimitPrice.Value, orderName);
else if (request.Type == OmsOrderType.StopMarket && request.StopPrice.HasValue)
EnterShortStopMarket(request.Quantity, (double)request.StopPrice.Value, orderName);
}
if (intent.StopTicks > 0)
SetStopLoss(orderName, CalculationMode.Ticks, (int)intent.StopTicks, false);
if (intent.TargetTicks.HasValue && intent.TargetTicks.Value > 0)
SetProfitTarget(orderName, CalculationMode.Ticks, (int)intent.TargetTicks.Value);
if (_circuitBreaker != null)
_circuitBreaker.OnSuccess();
}
catch (Exception ex)
{
if (_circuitBreaker != null)
_circuitBreaker.OnFailure();
Print(string.Format("[SDK] SubmitOrderToNT8 failed: {0}", ex.Message));
if (_logger != null)
_logger.LogError("SubmitOrderToNT8 failed: {0}", ex.Message);
throw;
}
}
private static OmsOrderSide MapOrderSide(SdkOrderSide side)
{
if (side == SdkOrderSide.Buy)
return OmsOrderSide.Buy;
return OmsOrderSide.Sell;
}
private static OmsOrderType MapOrderType(SdkOrderType type)
{
if (type == SdkOrderType.Market)
return OmsOrderType.Market;
if (type == SdkOrderType.Limit)
return OmsOrderType.Limit;
if (type == SdkOrderType.StopLimit)
return OmsOrderType.StopLimit;
return OmsOrderType.StopMarket;
}
protected OmsOrderStatus GetSdkOrderStatus(string orderName)
{
if (_executionAdapter == null)
return null;
return _executionAdapter.GetOrderStatus(orderName);
}
}
}

View File

@@ -0,0 +1,112 @@
// File: SimpleORBNT8.cs
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using NinjaTrader.Cbi;
using NinjaTrader.Data;
using NinjaTrader.Gui;
using NinjaTrader.Gui.Chart;
using NinjaTrader.Gui.Tools;
using NinjaTrader.NinjaScript;
using NinjaTrader.NinjaScript.Indicators;
using NinjaTrader.NinjaScript.Strategies;
using NT8.Core.Common.Interfaces;
using NT8.Strategies.Examples;
using SdkSimpleORB = NT8.Strategies.Examples.SimpleORBStrategy;
namespace NinjaTrader.NinjaScript.Strategies
{
/// <summary>
/// Simple Opening Range Breakout strategy integrated with NT8 SDK.
/// </summary>
public class SimpleORBNT8 : NT8StrategyBase
{
[NinjaScriptProperty]
[Display(Name = "Opening Range Minutes", GroupName = "ORB Strategy", Order = 1)]
[Range(5, 120)]
public int OpeningRangeMinutes { get; set; }
[NinjaScriptProperty]
[Display(Name = "Std Dev Multiplier", GroupName = "ORB Strategy", Order = 2)]
[Range(0.5, 3.0)]
public double StdDevMultiplier { get; set; }
[NinjaScriptProperty]
[Display(Name = "Stop Loss Ticks", GroupName = "ORB Risk", Order = 1)]
[Range(1, 50)]
public int StopTicks { get; set; }
[NinjaScriptProperty]
[Display(Name = "Profit Target Ticks", GroupName = "ORB Risk", Order = 2)]
[Range(1, 100)]
public int TargetTicks { get; set; }
protected override void OnStateChange()
{
if (State == State.SetDefaults)
{
Name = "Simple ORB NT8";
Description = "Opening Range Breakout with NT8 SDK integration";
OpeningRangeMinutes = 30;
StdDevMultiplier = 1.0;
StopTicks = 8;
TargetTicks = 16;
DailyLossLimit = 1000.0;
MaxTradeRisk = 200.0;
MaxOpenPositions = 1;
RiskPerTrade = 100.0;
MinContracts = 1;
MaxContracts = 3;
Calculate = Calculate.OnBarClose;
BarsRequiredToTrade = 50;
}
base.OnStateChange();
}
protected override IStrategy CreateSdkStrategy()
{
return new SdkSimpleORB(OpeningRangeMinutes, StdDevMultiplier);
}
protected override void ConfigureStrategyParameters()
{
_strategyConfig.RiskSettings.DailyLossLimit = DailyLossLimit;
_strategyConfig.RiskSettings.MaxTradeRisk = MaxTradeRisk;
_strategyConfig.RiskSettings.MaxOpenPositions = MaxOpenPositions;
// Guard: Instrument may be null during strategy list loading
if (Instrument != null && Instrument.MasterInstrument != null)
{
var pointValue = Instrument.MasterInstrument.PointValue;
var tickSize = Instrument.MasterInstrument.TickSize;
var dollarRisk = StopTicks * tickSize * pointValue;
if (dollarRisk > _strategyConfig.RiskSettings.MaxTradeRisk)
_strategyConfig.RiskSettings.MaxTradeRisk = dollarRisk;
}
_strategyConfig.SizingSettings.RiskPerTrade = RiskPerTrade;
_strategyConfig.SizingSettings.MinContracts = MinContracts;
_strategyConfig.SizingSettings.MaxContracts = MaxContracts;
_strategyConfig.Parameters["StopTicks"] = StopTicks;
_strategyConfig.Parameters["TargetTicks"] = TargetTicks;
_strategyConfig.Parameters["OpeningRangeMinutes"] = OpeningRangeMinutes;
if (_logger != null)
{
_logger.LogInformation(
"Simple ORB configured: OR={0}min, Stop={1}ticks, Target={2}ticks",
OpeningRangeMinutes,
StopTicks,
TargetTicks);
}
}
}
}

View File

@@ -98,7 +98,7 @@ namespace NT8.Core.Execution
return null;
}
var newStopPrice = CalculateNewStopPrice(trailingStop.Config.Type, trailingStop.Position, currentPrice);
var newStopPrice = CalculateNewStopPrice(trailingStop.Config.Type, trailingStop.Position, currentPrice, trailingStop.Config);
// Only update if the stop has improved (moved in favorable direction)
var shouldUpdate = false;
@@ -149,55 +149,73 @@ namespace NT8.Core.Execution
/// <param name="type">Type of trailing stop</param>
/// <param name="position">Position information</param>
/// <param name="marketPrice">Current market price</param>
/// <param name="config">Trailing stop configuration</param>
/// <returns>Calculated stop price</returns>
public decimal CalculateNewStopPrice(StopType type, OMS.OrderStatus position, decimal marketPrice)
public decimal CalculateNewStopPrice(StopType type, OMS.OrderStatus position, decimal marketPrice, TrailingStopConfig config = null)
{
if (position == null)
throw new ArgumentNullException("position");
try
{
if (config == null)
{
config = new TrailingStopConfig(StopType.FixedTrailing, 8, 2m, true);
}
switch (type)
{
case StopType.FixedTrailing:
// Fixed trailing: trail by fixed number of ticks from high/low
if (position.Side == OMS.OrderSide.Buy)
{
// Long position: stop trails below highest high
return marketPrice - (position.AverageFillPrice - position.AverageFillPrice); // Simplified
}
else
{
// Short position: stop trails above lowest low
return marketPrice + (position.AverageFillPrice - position.AverageFillPrice); // Simplified
// Tick size is fixed here as a temporary default (ES/NQ standard).
// TODO: provide symbol-specific tick size via configuration.
var tickSize = 0.25m;
var trailingTicks = config.TrailingAmountTicks > 0 ? config.TrailingAmountTicks : 8;
var distance = trailingTicks * tickSize;
return position.Side == OMS.OrderSide.Buy
? marketPrice - distance
: marketPrice + distance;
}
case StopType.ATRTrailing:
// ATR trailing: trail by ATR multiple
return position.Side == OMS.OrderSide.Buy ?
marketPrice - (position.AverageFillPrice * 0.01m) : // Placeholder for ATR calculation
marketPrice + (position.AverageFillPrice * 0.01m); // Placeholder for ATR calculation
{
// ATR is approximated here until live ATR is provided in config/context.
var atrMultiplier = config.AtrMultiplier > 0 ? config.AtrMultiplier : 2.0m;
var estimatedAtr = position.AverageFillPrice * 0.005m;
var distance = atrMultiplier * estimatedAtr;
return position.Side == OMS.OrderSide.Buy
? marketPrice - distance
: marketPrice + distance;
}
case StopType.Chandelier:
// Chandelier: trail from highest high minus ATR * multiplier
return position.Side == OMS.OrderSide.Buy ?
marketPrice - (position.AverageFillPrice * 0.01m) : // Placeholder for chandelier calculation
marketPrice + (position.AverageFillPrice * 0.01m); // Placeholder for chandelier calculation
{
// Chandelier approximation uses the same ATR proxy until bar history is wired in.
var chanMultiplier = config.AtrMultiplier > 0 ? config.AtrMultiplier : 3.0m;
var estimatedAtr = position.AverageFillPrice * 0.005m;
var distance = chanMultiplier * estimatedAtr;
return position.Side == OMS.OrderSide.Buy
? marketPrice - distance
: marketPrice + distance;
}
case StopType.PercentageTrailing:
// Percentage trailing: trail by percentage of current price
var pctTrail = 0.02m; // Default 2% - in real impl this would come from config
var pctTrail = 0.02m;
return position.Side == OMS.OrderSide.Buy ?
marketPrice * (1 - pctTrail) :
marketPrice * (1 + pctTrail);
default:
// Fixed trailing as fallback
var tickSize = 0.25m; // Default tick size - should be configurable
var ticks = 8; // Default trailing ticks - should come from config
var tickSizeFallback = 0.25m;
var ticks = config.TrailingAmountTicks > 0 ? config.TrailingAmountTicks : 8;
return position.Side == OMS.OrderSide.Buy ?
marketPrice - (ticks * tickSize) :
marketPrice + (ticks * tickSize);
marketPrice - (ticks * tickSizeFallback) :
marketPrice + (ticks * tickSizeFallback);
}
}
catch (Exception ex)

View File

@@ -2,6 +2,18 @@ using System;
namespace NT8.Core.Logging
{
/// <summary>
/// Log severity levels.
/// </summary>
public enum LogLevel
{
Debug = 0,
Information = 1,
Warning = 2,
Error = 3,
Critical = 4
}
/// <summary>
/// Basic console logger implementation for .NET Framework 4.8
/// </summary>
@@ -9,43 +21,53 @@ namespace NT8.Core.Logging
{
private readonly string _categoryName;
/// <summary>
/// Minimum log level to write. Messages below this level are suppressed.
/// Default is Information.
/// </summary>
public LogLevel MinimumLevel { get; set; }
public BasicLogger(string categoryName = "")
{
_categoryName = categoryName;
MinimumLevel = LogLevel.Information;
}
public void LogDebug(string message, params object[] args)
{
WriteLog("DEBUG", message, args);
WriteLog(LogLevel.Debug, "DEBUG", message, args);
}
public void LogInformation(string message, params object[] args)
{
WriteLog("INFO", message, args);
WriteLog(LogLevel.Information, "INFO", message, args);
}
public void LogWarning(string message, params object[] args)
{
WriteLog("WARN", message, args);
WriteLog(LogLevel.Warning, "WARN", message, args);
}
public void LogError(string message, params object[] args)
{
WriteLog("ERROR", message, args);
WriteLog(LogLevel.Error, "ERROR", message, args);
}
public void LogCritical(string message, params object[] args)
{
WriteLog("CRITICAL", message, args);
WriteLog(LogLevel.Critical, "CRITICAL", message, args);
}
private void WriteLog(string level, string message, params object[] args)
private void WriteLog(LogLevel level, string levelLabel, string message, params object[] args)
{
if (level < MinimumLevel)
return;
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
var formattedMessage = args.Length > 0 ? String.Format(message, args) : message;
var category = !String.IsNullOrEmpty(_categoryName) ? String.Format("[{0}] ", _categoryName) : "";
Console.WriteLine(String.Format("{0} [{1}] {2}{3}", timestamp, level, category, formattedMessage));
Console.WriteLine(String.Format("{0} [{1}] {2}{3}", timestamp, levelLabel, category, formattedMessage));
}
}
}
}

View File

@@ -16,6 +16,35 @@ namespace NT8.Core.MarketData
private readonly Dictionary<string, SessionInfo> _sessionCache;
private readonly Dictionary<string, ContractRollInfo> _contractRollCache;
// CME US Futures holidays — markets closed all day on these dates.
// Update annually. Dates are in Eastern Time calendar dates.
private static readonly HashSet<DateTime> _cmeHolidays = new HashSet<DateTime>
{
// 2025 holidays
new DateTime(2025, 1, 1),
new DateTime(2025, 1, 20),
new DateTime(2025, 2, 17),
new DateTime(2025, 4, 18),
new DateTime(2025, 5, 26),
new DateTime(2025, 6, 19),
new DateTime(2025, 7, 4),
new DateTime(2025, 9, 1),
new DateTime(2025, 11, 27),
new DateTime(2025, 12, 25),
// 2026 holidays
new DateTime(2026, 1, 1),
new DateTime(2026, 1, 19),
new DateTime(2026, 2, 16),
new DateTime(2026, 4, 3),
new DateTime(2026, 5, 25),
new DateTime(2026, 6, 19),
new DateTime(2026, 7, 4),
new DateTime(2026, 9, 7),
new DateTime(2026, 11, 26),
new DateTime(2026, 12, 25)
};
// Helper class to store session times
private class SessionTimes
{
@@ -224,6 +253,13 @@ namespace NT8.Core.MarketData
try
{
// Markets are fully closed on CME holidays
if (IsCmeHoliday(time))
{
_logger.LogInformation("Holiday detected for {Symbol} on {Date} - market closed.", symbol, time.Date);
return false;
}
var sessionInfo = GetCurrentSession(symbol, time);
return sessionInfo.IsRegularHours;
}
@@ -234,6 +270,25 @@ namespace NT8.Core.MarketData
}
}
/// <summary>
/// Returns true if the given UTC date is a CME holiday (market closed all day).
/// </summary>
/// <param name="utcTime">UTC timestamp to evaluate</param>
/// <returns>True if the Eastern date is a known CME holiday, false otherwise</returns>
private static bool IsCmeHoliday(DateTime utcTime)
{
try
{
var eastern = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");
var estTime = TimeZoneInfo.ConvertTimeFromUtc(utcTime, eastern);
return _cmeHolidays.Contains(estTime.Date);
}
catch (Exception)
{
return false;
}
}
/// <summary>
/// Checks if a contract is in its roll period
/// </summary>