2245 lines
69 KiB
Markdown
2245 lines
69 KiB
Markdown
# NT8 Institutional SDK - Implementation Guide
|
|
|
|
This guide provides the exact content for all files that need to be created during the implementation of the NT8 Institutional SDK Phase 0.
|
|
|
|
## 1. Configuration Files
|
|
|
|
### 1.1 .gitignore
|
|
Path: `.gitignore`
|
|
|
|
Content:
|
|
```
|
|
# Build results
|
|
[Dd]ebug/
|
|
[Dd]ebugPublic/
|
|
[Rr]elease/
|
|
[Rr]eleases/
|
|
x64/
|
|
x86/
|
|
[Ww][Ii][Nn]32/
|
|
[Aa][Rr][Mm]/
|
|
[Aa][Rr][Mm]64/
|
|
bld/
|
|
[Bb]in/
|
|
[Oo]bj/
|
|
[Oo]ut/
|
|
[Ll]og/
|
|
[Ll]ogs/
|
|
|
|
# Visual Studio / VSCode
|
|
.vs/
|
|
.vscode/settings.json
|
|
.vscode/tasks.json
|
|
.vscode/launch.json
|
|
.vscode/extensions.json
|
|
*.rsuser
|
|
*.suo
|
|
*.user
|
|
*.userosscache
|
|
*.sln.docstates
|
|
|
|
# Test Results
|
|
[Tt]est[Rr]esult*/
|
|
[Bb]uild[Ll]og.*
|
|
*.VisualState.xml
|
|
TestResult.xml
|
|
nunit-*.xml
|
|
*.trx
|
|
*.coverage
|
|
*.coveragexml
|
|
coverage*.json
|
|
coverage*.xml
|
|
coverage*.info
|
|
|
|
# NuGet
|
|
*.nupkg
|
|
*.snupkg
|
|
.nuget/
|
|
packages/
|
|
!packages/build/
|
|
*.nuget.props
|
|
*.nuget.targets
|
|
|
|
# .NET Core
|
|
project.lock.json
|
|
project.fragment.lock.json
|
|
artifacts/
|
|
|
|
# Development containers
|
|
.devcontainer/.env
|
|
|
|
# Local configuration files
|
|
appsettings.local.json
|
|
appsettings.*.local.json
|
|
config/local.json
|
|
|
|
# Temporary files
|
|
*.tmp
|
|
*.temp
|
|
.tmp/
|
|
.temp/
|
|
|
|
# IDE specific
|
|
*.swp
|
|
*.swo
|
|
*~
|
|
|
|
# OS specific
|
|
.DS_Store
|
|
Thumbs.db
|
|
|
|
# NinjaTrader specific
|
|
*.ninjatrader
|
|
*.nt8addon
|
|
|
|
# Custom tools and scripts output
|
|
tools/output/
|
|
market-data/*.csv
|
|
replay-data/
|
|
```
|
|
|
|
### 1.2 Directory.Build.props
|
|
Path: `Directory.Build.props`
|
|
|
|
Content:
|
|
```xml
|
|
<Project>
|
|
<PropertyGroup>
|
|
<TargetFramework>net6.0</TargetFramework>
|
|
<LangVersion>10.0</LangVersion>
|
|
<Nullable>enable</Nullable>
|
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
|
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
|
<Company>NT8 Institutional</Company>
|
|
<Product>NT8 SDK</Product>
|
|
<Copyright>Copyright © 2025</Copyright>
|
|
<Version>0.1.0</Version>
|
|
<AssemblyVersion>0.1.0.0</AssemblyVersion>
|
|
<FileVersion>0.1.0.0</FileVersion>
|
|
|
|
<!-- Code Analysis -->
|
|
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
|
<AnalysisLevel>6.0</AnalysisLevel>
|
|
</PropertyGroup>
|
|
|
|
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
|
|
<DefineConstants>DEBUG;TRACE</DefineConstants>
|
|
<DebugType>portable</DebugType>
|
|
</PropertyGroup>
|
|
|
|
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
|
<DefineConstants>TRACE</DefineConstants>
|
|
<Optimize>true</Optimize>
|
|
<DebugType>pdbonly</DebugType>
|
|
</PropertyGroup>
|
|
|
|
<ItemGroup>
|
|
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="6.0.0">
|
|
<PrivateAssets>all</PrivateAssets>
|
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
|
|
</PackageReference>
|
|
</ItemGroup>
|
|
</Project>
|
|
```
|
|
|
|
### 1.3 .editorconfig
|
|
Path: `.editorconfig`
|
|
|
|
Content:
|
|
```ini
|
|
# EditorConfig is awesome: https://EditorConfig.org
|
|
|
|
# top-most EditorConfig file
|
|
root = true
|
|
|
|
[*]
|
|
indent_style = space
|
|
charset = utf-8
|
|
trim_trailing_whitespace = true
|
|
insert_final_newline = true
|
|
|
|
[*.{cs,csx,vb,vbx}]
|
|
indent_size = 4
|
|
end_of_line = crlf
|
|
|
|
[*.{json,js,yml,yaml,xml}]
|
|
indent_size = 2
|
|
|
|
[*.md]
|
|
trim_trailing_whitespace = false
|
|
|
|
# C# formatting rules
|
|
[*.cs]
|
|
# Organize usings
|
|
dotnet_sort_system_directives_first = true
|
|
dotnet_separate_import_directive_groups = false
|
|
|
|
# this. preferences
|
|
dotnet_style_qualification_for_field = false:suggestion
|
|
dotnet_style_qualification_for_property = false:suggestion
|
|
dotnet_style_qualification_for_method = false:suggestion
|
|
dotnet_style_qualification_for_event = false:suggestion
|
|
|
|
# Language keywords vs BCL types preferences
|
|
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
|
|
dotnet_style_predefined_type_for_member_access = true:suggestion
|
|
|
|
# Modifier preferences
|
|
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
|
|
dotnet_style_readonly_field = true:suggestion
|
|
|
|
# Expression-level preferences
|
|
dotnet_style_object_initializer = true:suggestion
|
|
dotnet_style_collection_initializer = true:suggestion
|
|
dotnet_style_explicit_tuple_names = true:suggestion
|
|
dotnet_style_null_propagation = true:suggestion
|
|
dotnet_style_coalesce_expression = true:suggestion
|
|
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
|
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
|
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
|
|
|
# C# preferences
|
|
csharp_prefer_var_for_built_in_types = false:suggestion
|
|
csharp_prefer_var_when_type_is_apparent = true:suggestion
|
|
csharp_prefer_var_elsewhere = false:suggestion
|
|
```
|
|
|
|
### 1.4 .gitea/workflows/build.yml
|
|
Path: `.gitea/workflows/build.yml`
|
|
|
|
Content:
|
|
```yaml
|
|
name: Build and Test
|
|
|
|
on:
|
|
push:
|
|
branches: [ main, develop ]
|
|
pull_request:
|
|
branches: [ main ]
|
|
|
|
jobs:
|
|
build:
|
|
runs-on: ubuntu-latest
|
|
|
|
steps:
|
|
- uses: actions/checkout@v3
|
|
|
|
- name: Setup .NET
|
|
uses: actions/setup-dotnet@v3
|
|
with:
|
|
dotnet-version: '6.0.x'
|
|
|
|
- name: Restore dependencies
|
|
run: dotnet restore
|
|
|
|
- name: Build
|
|
run: dotnet build --no-restore --configuration Release
|
|
|
|
- name: Test
|
|
run: dotnet test --no-build --configuration Release --verbosity normal --collect:"XPlat Code Coverage"
|
|
|
|
- name: Upload coverage reports
|
|
uses: codecov/codecov-action@v3
|
|
with:
|
|
files: ./coverage.cobertura.xml
|
|
```
|
|
|
|
### 1.5 README.md
|
|
Path: `README.md`
|
|
|
|
Content:
|
|
```markdown
|
|
# NT8 Institutional SDK
|
|
|
|
Professional-grade algorithmic trading SDK for NinjaTrader 8, built for institutional use with comprehensive risk management and deterministic execution.
|
|
|
|
## 🚀 Quick Start
|
|
|
|
### Prerequisites
|
|
- .NET 6.0 SDK
|
|
- Visual Studio Code + Docker Desktop (recommended)
|
|
- Git
|
|
|
|
### Setup (5 minutes)
|
|
```bash
|
|
# Clone repository
|
|
git clone <repository-url>
|
|
cd nt8-institutional-sdk
|
|
|
|
# Verify setup
|
|
dotnet build && dotnet test
|
|
```
|
|
|
|
## 📋 Project Structure
|
|
|
|
```
|
|
src/
|
|
├── NT8.Core/ # Core SDK functionality
|
|
│ ├── Risk/ # Risk management system
|
|
│ ├── Sizing/ # Position sizing algorithms
|
|
│ ├── Logging/ # Structured logging
|
|
│ └── Common/ # Shared interfaces and models
|
|
├── NT8.Strategies/ # Strategy implementations
|
|
├── NT8.Adapters/ # NinjaTrader integration
|
|
└── NT8.Contracts/ # API contracts
|
|
|
|
tests/ # Comprehensive test suite
|
|
tools/ # Development and deployment tools
|
|
docs/ # Technical documentation
|
|
```
|
|
|
|
## 🏗️ Architecture Principles
|
|
|
|
- **Risk First**: All trades pass through risk management before execution
|
|
- **Deterministic**: Identical inputs produce identical outputs for testing
|
|
- **Modular**: Strategies are thin plugins, SDK handles infrastructure
|
|
- **Observable**: Structured logging with correlation IDs throughout
|
|
|
|
## 📊 Current Status: Phase 0 Development
|
|
|
|
### ✅ Completed
|
|
- Development environment and tooling
|
|
- Core interfaces and models
|
|
- Basic project structure
|
|
|
|
### 🚧 In Progress
|
|
- Risk management implementation
|
|
- Position sizing algorithms
|
|
- Basic strategy framework
|
|
- Comprehensive unit testing
|
|
|
|
### 📅 Next (Phase 1)
|
|
- Order management system
|
|
- NinjaTrader integration
|
|
- Market data handling
|
|
- Advanced testing and validation
|
|
|
|
## 📄 License
|
|
|
|
Proprietary - Internal use only
|
|
```
|
|
|
|
## 2. Core SDK Files
|
|
|
|
### 2.1 IStrategy.cs
|
|
Path: `src/NT8.Core/Common/Interfaces/IStrategy.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
using NT8.Core.Common.Models;
|
|
|
|
namespace NT8.Core.Common.Interfaces;
|
|
|
|
/// <summary>
|
|
/// Core strategy interface - strategies implement signal generation only
|
|
/// The SDK handles all risk management, position sizing, and order execution
|
|
/// </summary>
|
|
public interface IStrategy
|
|
{
|
|
/// <summary>
|
|
/// Strategy metadata and configuration
|
|
/// </summary>
|
|
StrategyMetadata Metadata { get; }
|
|
|
|
/// <summary>
|
|
/// Initialize strategy with configuration and dependencies
|
|
/// </summary>
|
|
void Initialize(StrategyConfig config, IMarketDataProvider dataProvider, ILogger logger);
|
|
|
|
/// <summary>
|
|
/// Process new bar data and generate trading intent (if any)
|
|
/// This is the main entry point for strategy logic
|
|
/// </summary>
|
|
StrategyIntent? OnBar(BarData bar, StrategyContext context);
|
|
|
|
/// <summary>
|
|
/// Process tick data for high-frequency strategies (optional)
|
|
/// Most strategies can leave this as default implementation
|
|
/// </summary>
|
|
StrategyIntent? OnTick(TickData tick, StrategyContext context) => null;
|
|
|
|
/// <summary>
|
|
/// Get current strategy parameters for serialization
|
|
/// </summary>
|
|
Dictionary<string, object> GetParameters();
|
|
|
|
/// <summary>
|
|
/// Update strategy parameters from configuration
|
|
/// </summary>
|
|
void SetParameters(Dictionary<string, object> parameters);
|
|
}
|
|
```
|
|
|
|
### 2.2 StrategyMetadata.cs
|
|
Path: `src/NT8.Core/Common/Models/StrategyMetadata.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
namespace NT8.Core.Common.Models;
|
|
|
|
/// <summary>
|
|
/// Strategy metadata - describes strategy capabilities and requirements
|
|
/// </summary>
|
|
public record StrategyMetadata(
|
|
string Name,
|
|
string Description,
|
|
string Version,
|
|
string Author,
|
|
string[] Symbols,
|
|
int RequiredBars
|
|
);
|
|
|
|
/// <summary>
|
|
/// Strategy configuration passed during initialization
|
|
/// </summary>
|
|
public record StrategyConfig(
|
|
string Name,
|
|
string Symbol,
|
|
Dictionary<string, object> Parameters,
|
|
RiskConfig RiskSettings,
|
|
SizingConfig SizingSettings
|
|
);
|
|
```
|
|
|
|
### 2.3 StrategyIntent.cs
|
|
Path: `src/NT8.Core/Common/Models/StrategyIntent.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
namespace NT8.Core.Common.Models;
|
|
|
|
/// <summary>
|
|
/// Strategy trading intent - what the strategy wants to do
|
|
/// This is the output of strategy logic, input to risk management
|
|
/// </summary>
|
|
public record StrategyIntent(
|
|
string Symbol,
|
|
OrderSide Side,
|
|
OrderType EntryType,
|
|
double? LimitPrice,
|
|
int StopTicks,
|
|
int? TargetTicks,
|
|
double Confidence, // 0.0 to 1.0 - strategy confidence level
|
|
string Reason, // Human-readable reason for trade
|
|
Dictionary<string, object> Metadata // Additional strategy-specific data
|
|
)
|
|
{
|
|
/// <summary>
|
|
/// Unique identifier for this intent
|
|
/// </summary>
|
|
public string IntentId { get; init; } = Guid.NewGuid().ToString();
|
|
|
|
/// <summary>
|
|
/// Timestamp when intent was generated
|
|
/// </summary>
|
|
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
|
|
|
|
/// <summary>
|
|
/// Validate intent has required fields
|
|
/// </summary>
|
|
public bool IsValid() =>
|
|
!string.IsNullOrEmpty(Symbol) &&
|
|
StopTicks > 0 &&
|
|
Confidence is >= 0.0 and <= 1.0 &&
|
|
Side != OrderSide.Flat &&
|
|
!string.IsNullOrEmpty(Reason);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Order side enumeration
|
|
/// </summary>
|
|
public enum OrderSide
|
|
{
|
|
Buy = 1,
|
|
Sell = -1,
|
|
Flat = 0 // Close position
|
|
}
|
|
|
|
/// <summary>
|
|
/// Order type enumeration
|
|
/// </summary>
|
|
public enum OrderType
|
|
{
|
|
Market,
|
|
Limit,
|
|
StopMarket,
|
|
StopLimit
|
|
}
|
|
```
|
|
|
|
### 2.4 StrategyContext.cs
|
|
Path: `src/NT8.Core/Common/Models/StrategyContext.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
namespace NT8.Core.Common.Models;
|
|
|
|
/// <summary>
|
|
/// Context information available to strategies
|
|
/// </summary>
|
|
public record StrategyContext(
|
|
string Symbol,
|
|
DateTime CurrentTime,
|
|
Position CurrentPosition,
|
|
AccountInfo Account,
|
|
MarketSession Session,
|
|
Dictionary<string, object> CustomData
|
|
);
|
|
|
|
/// <summary>
|
|
/// Current position information
|
|
/// </summary>
|
|
public record Position(
|
|
string Symbol,
|
|
int Quantity,
|
|
double AveragePrice,
|
|
double UnrealizedPnL,
|
|
double RealizedPnL,
|
|
DateTime LastUpdate
|
|
);
|
|
|
|
/// <summary>
|
|
/// Account information
|
|
/// </summary>
|
|
public record AccountInfo(
|
|
double Equity,
|
|
double BuyingPower,
|
|
double DailyPnL,
|
|
double MaxDrawdown,
|
|
DateTime LastUpdate
|
|
);
|
|
|
|
/// <summary>
|
|
/// Market session information
|
|
/// </summary>
|
|
public record MarketSession(
|
|
DateTime SessionStart,
|
|
DateTime SessionEnd,
|
|
bool IsRth, // Regular Trading Hours
|
|
string SessionName
|
|
);
|
|
```
|
|
|
|
### 2.5 MarketData.cs
|
|
Path: `src/NT8.Core/Common/Models/MarketData.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
namespace NT8.Core.Common.Models;
|
|
|
|
/// <summary>
|
|
/// Bar data model
|
|
/// </summary>
|
|
public record BarData(
|
|
string Symbol,
|
|
DateTime Time,
|
|
double Open,
|
|
double High,
|
|
double Low,
|
|
double Close,
|
|
long Volume,
|
|
TimeSpan BarSize
|
|
);
|
|
|
|
/// <summary>
|
|
/// Tick data model
|
|
/// </summary>
|
|
public record TickData(
|
|
string Symbol,
|
|
DateTime Time,
|
|
double Price,
|
|
int Size,
|
|
TickType Type
|
|
);
|
|
|
|
/// <summary>
|
|
/// Order fill model
|
|
/// </summary>
|
|
public record OrderFill(
|
|
string OrderId,
|
|
string Symbol,
|
|
int Quantity,
|
|
double FillPrice,
|
|
DateTime FillTime,
|
|
double Commission,
|
|
string ExecutionId
|
|
);
|
|
|
|
public enum TickType
|
|
{
|
|
Trade,
|
|
Bid,
|
|
Ask,
|
|
Last
|
|
}
|
|
|
|
/// <summary>
|
|
/// Market data provider interface
|
|
/// </summary>
|
|
public interface IMarketDataProvider
|
|
{
|
|
/// <summary>
|
|
/// Subscribe to bar data
|
|
/// </summary>
|
|
void SubscribeBars(string symbol, TimeSpan barSize, Action<BarData> onBar);
|
|
|
|
/// <summary>
|
|
/// Subscribe to tick data
|
|
/// </summary>
|
|
void SubscribeTicks(string symbol, Action<TickData> onTick);
|
|
|
|
/// <summary>
|
|
/// Get historical bars
|
|
/// </summary>
|
|
Task<List<BarData>> GetHistoricalBars(string symbol, TimeSpan barSize, int count);
|
|
|
|
/// <summary>
|
|
/// Get current market price
|
|
/// </summary>
|
|
double? GetCurrentPrice(string symbol);
|
|
}
|
|
```
|
|
|
|
### 2.6 IRiskManager.cs
|
|
Path: `src/NT8.Core/Risk/IRiskManager.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
using NT8.Core.Common.Models;
|
|
|
|
namespace NT8.Core.Risk;
|
|
|
|
/// <summary>
|
|
/// Risk management interface - validates and potentially modifies trading intents
|
|
/// This is the gatekeeper between strategy signals and order execution
|
|
/// </summary>
|
|
public interface IRiskManager
|
|
{
|
|
/// <summary>
|
|
/// Validate order intent against risk parameters
|
|
/// Returns decision with allow/reject and any modifications
|
|
/// </summary>
|
|
RiskDecision ValidateOrder(StrategyIntent intent, StrategyContext context, RiskConfig config);
|
|
|
|
/// <summary>
|
|
/// Update risk state after order fill
|
|
/// </summary>
|
|
void OnFill(OrderFill fill);
|
|
|
|
/// <summary>
|
|
/// Update risk state with current P&L
|
|
/// </summary>
|
|
void OnPnLUpdate(double netPnL, double dayPnL);
|
|
|
|
/// <summary>
|
|
/// Emergency flatten all positions
|
|
/// </summary>
|
|
Task<bool> EmergencyFlatten(string reason);
|
|
|
|
/// <summary>
|
|
/// Get current risk status for monitoring
|
|
/// </summary>
|
|
RiskStatus GetRiskStatus();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Risk validation result
|
|
/// </summary>
|
|
public record RiskDecision(
|
|
bool Allow,
|
|
string? RejectReason,
|
|
StrategyIntent? ModifiedIntent, // If risk manager modifies size/price
|
|
RiskLevel RiskLevel,
|
|
Dictionary<string, object> RiskMetrics
|
|
);
|
|
|
|
/// <summary>
|
|
/// Current risk system status
|
|
/// </summary>
|
|
public record RiskStatus(
|
|
bool TradingEnabled,
|
|
double DailyPnL,
|
|
double DailyLossLimit,
|
|
double MaxDrawdown,
|
|
int OpenPositions,
|
|
DateTime LastUpdate,
|
|
List<string> ActiveAlerts
|
|
);
|
|
|
|
/// <summary>
|
|
/// Risk level classification
|
|
/// </summary>
|
|
public enum RiskLevel
|
|
{
|
|
Low, // Normal trading
|
|
Medium, // Elevated caution
|
|
High, // Limited trading
|
|
Critical // Trading halted
|
|
}
|
|
|
|
/// <summary>
|
|
/// Risk configuration parameters
|
|
/// </summary>
|
|
public record RiskConfig(
|
|
double DailyLossLimit,
|
|
double MaxTradeRisk,
|
|
int MaxOpenPositions,
|
|
bool EmergencyFlattenEnabled
|
|
);
|
|
```
|
|
|
|
### 2.7 BasicRiskManager.cs
|
|
Path: `src/NT8.Core/Risk/BasicRiskManager.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
using NT8.Core.Common.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace NT8.Core.Risk;
|
|
|
|
/// <summary>
|
|
/// Basic risk manager implementing Tier 1 risk controls
|
|
/// Thread-safe implementation using locks for state consistency
|
|
/// </summary>
|
|
public class BasicRiskManager : IRiskManager
|
|
{
|
|
private readonly ILogger<BasicRiskManager> _logger;
|
|
private readonly object _lock = new();
|
|
|
|
// Risk state - protected by _lock
|
|
private double _dailyPnL;
|
|
private double _maxDrawdown;
|
|
private bool _tradingHalted;
|
|
private DateTime _lastUpdate = DateTime.UtcNow;
|
|
private readonly Dictionary<string, double> _symbolExposure = new();
|
|
|
|
public BasicRiskManager(ILogger<BasicRiskManager> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
public RiskDecision ValidateOrder(StrategyIntent intent, StrategyContext context, RiskConfig config)
|
|
{
|
|
if (intent == null) throw new ArgumentNullException(nameof(intent));
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
if (config == null) throw new ArgumentNullException(nameof(config));
|
|
|
|
lock (_lock)
|
|
{
|
|
// Check if trading is halted
|
|
if (_tradingHalted)
|
|
{
|
|
_logger.LogWarning("Order rejected - trading halted by risk manager");
|
|
return new RiskDecision(
|
|
Allow: false,
|
|
RejectReason: "Trading halted by risk manager",
|
|
ModifiedIntent: null,
|
|
RiskLevel: RiskLevel.Critical,
|
|
RiskMetrics: new() { ["halted"] = true, ["daily_pnl"] = _dailyPnL }
|
|
);
|
|
}
|
|
|
|
// Tier 1: Daily loss cap
|
|
if (_dailyPnL <= -config.DailyLossLimit)
|
|
{
|
|
_tradingHalted = true;
|
|
_logger.LogCritical("Daily loss limit breached: {DailyPnL:C} <= {Limit:C}",
|
|
_dailyPnL, -config.DailyLossLimit);
|
|
|
|
return new RiskDecision(
|
|
Allow: false,
|
|
RejectReason: $"Daily loss limit breached: {_dailyPnL:C}",
|
|
ModifiedIntent: null,
|
|
RiskLevel: RiskLevel.Critical,
|
|
RiskMetrics: new() { ["daily_pnl"] = _dailyPnL, ["limit"] = config.DailyLossLimit }
|
|
);
|
|
}
|
|
|
|
// Tier 1: Per-trade risk limit
|
|
var tradeRisk = CalculateTradeRisk(intent, context);
|
|
if (tradeRisk > config.MaxTradeRisk)
|
|
{
|
|
_logger.LogWarning("Trade risk too high: {Risk:C} > {Limit:C}", tradeRisk, config.MaxTradeRisk);
|
|
|
|
return new RiskDecision(
|
|
Allow: false,
|
|
RejectReason: $"Trade risk too high: {tradeRisk:C}",
|
|
ModifiedIntent: null,
|
|
RiskLevel: RiskLevel.High,
|
|
RiskMetrics: new() { ["trade_risk"] = tradeRisk, ["limit"] = config.MaxTradeRisk }
|
|
);
|
|
}
|
|
|
|
// Tier 1: Position limits
|
|
var currentPositions = GetOpenPositionCount();
|
|
if (currentPositions >= config.MaxOpenPositions && context.CurrentPosition.Quantity == 0)
|
|
{
|
|
_logger.LogWarning("Max open positions exceeded: {Current} >= {Limit}",
|
|
currentPositions, config.MaxOpenPositions);
|
|
|
|
return new RiskDecision(
|
|
Allow: false,
|
|
RejectReason: $"Max open positions exceeded: {currentPositions}",
|
|
ModifiedIntent: null,
|
|
RiskLevel: RiskLevel.Medium,
|
|
RiskMetrics: new() { ["open_positions"] = currentPositions, ["limit"] = config.MaxOpenPositions }
|
|
);
|
|
}
|
|
|
|
// All checks passed - determine risk level
|
|
var riskLevel = DetermineRiskLevel(config);
|
|
|
|
_logger.LogDebug("Order approved: {Symbol} {Side} risk=${Risk:F2} level={Level}",
|
|
intent.Symbol, intent.Side, tradeRisk, riskLevel);
|
|
|
|
return new RiskDecision(
|
|
Allow: true,
|
|
RejectReason: null,
|
|
ModifiedIntent: null,
|
|
RiskLevel: riskLevel,
|
|
RiskMetrics: new() {
|
|
["trade_risk"] = tradeRisk,
|
|
["daily_pnl"] = _dailyPnL,
|
|
["max_drawdown"] = _maxDrawdown,
|
|
["open_positions"] = currentPositions
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
private static double CalculateTradeRisk(StrategyIntent intent, StrategyContext context)
|
|
{
|
|
// Get tick value for symbol - this will be enhanced in later phases
|
|
var tickValue = GetTickValue(intent.Symbol);
|
|
return intent.StopTicks * tickValue;
|
|
}
|
|
|
|
private static double GetTickValue(string symbol)
|
|
{
|
|
// Static tick values for Phase 0 - will be configurable in Phase 1
|
|
return symbol switch
|
|
{
|
|
"ES" => 12.50,
|
|
"MES" => 1.25,
|
|
"NQ" => 5.00,
|
|
"MNQ" => 0.50,
|
|
"CL" => 10.00,
|
|
"GC" => 10.00,
|
|
_ => 12.50 // Default to ES
|
|
};
|
|
}
|
|
|
|
private int GetOpenPositionCount()
|
|
{
|
|
// For Phase 0, return simplified count
|
|
// Will be enhanced with actual position tracking in Phase 1
|
|
return _symbolExposure.Count(kvp => Math.Abs(kvp.Value) > 0.01);
|
|
}
|
|
|
|
private RiskLevel DetermineRiskLevel(RiskConfig config)
|
|
{
|
|
var lossPercent = Math.Abs(_dailyPnL) / config.DailyLossLimit;
|
|
|
|
return lossPercent switch
|
|
{
|
|
>= 0.8 => RiskLevel.High,
|
|
>= 0.5 => RiskLevel.Medium,
|
|
_ => RiskLevel.Low
|
|
};
|
|
}
|
|
|
|
public void OnFill(OrderFill fill)
|
|
{
|
|
if (fill == null) throw new ArgumentNullException(nameof(fill));
|
|
|
|
lock (_lock)
|
|
{
|
|
_lastUpdate = DateTime.UtcNow;
|
|
|
|
// Update symbol exposure
|
|
var fillValue = fill.Quantity * fill.FillPrice;
|
|
if (_symbolExposure.ContainsKey(fill.Symbol))
|
|
{
|
|
_symbolExposure[fill.Symbol] += fillValue;
|
|
}
|
|
else
|
|
{
|
|
_symbolExposure[fill.Symbol] = fillValue;
|
|
}
|
|
|
|
_logger.LogDebug("Fill processed: {Symbol} {Qty} @ {Price:F2}, Exposure: {Exposure:C}",
|
|
fill.Symbol, fill.Quantity, fill.FillPrice, _symbolExposure[fill.Symbol]);
|
|
}
|
|
}
|
|
|
|
public void OnPnLUpdate(double netPnL, double dayPnL)
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var oldDailyPnL = _dailyPnL;
|
|
_dailyPnL = dayPnL;
|
|
_maxDrawdown = Math.Min(_maxDrawdown, dayPnL);
|
|
_lastUpdate = DateTime.UtcNow;
|
|
|
|
if (Math.Abs(dayPnL - oldDailyPnL) > 0.01)
|
|
{
|
|
_logger.LogDebug("P&L Update: Daily={DayPnL:C}, Max DD={MaxDD:C}",
|
|
dayPnL, _maxDrawdown);
|
|
}
|
|
|
|
// Check for emergency conditions
|
|
CheckEmergencyConditions(dayPnL);
|
|
}
|
|
}
|
|
|
|
private void CheckEmergencyConditions(double dayPnL)
|
|
{
|
|
// Emergency halt if daily loss exceeds 90% of limit
|
|
if (dayPnL <= -(_dailyPnL * 0.9) && !_tradingHalted)
|
|
{
|
|
_tradingHalted = true;
|
|
_logger.LogCritical("Emergency halt triggered at 90% of daily loss limit: {DayPnL:C}", dayPnL);
|
|
}
|
|
}
|
|
|
|
public async Task<bool> EmergencyFlatten(string reason)
|
|
{
|
|
if (string.IsNullOrEmpty(reason)) throw new ArgumentException("Reason required", nameof(reason));
|
|
|
|
lock (_lock)
|
|
{
|
|
_tradingHalted = true;
|
|
_logger.LogCritical("Emergency flatten triggered: {Reason}", reason);
|
|
}
|
|
|
|
// In Phase 0, this is a placeholder
|
|
// Phase 1 will implement actual position flattening via OMS
|
|
await Task.Delay(100);
|
|
|
|
_logger.LogInformation("Emergency flatten completed");
|
|
return true;
|
|
}
|
|
|
|
public RiskStatus GetRiskStatus()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
var alerts = new List<string>();
|
|
|
|
if (_tradingHalted)
|
|
alerts.Add("Trading halted");
|
|
|
|
if (_dailyPnL <= -500) // Half of typical daily limit
|
|
alerts.Add($"Significant daily loss: {_dailyPnL:C}");
|
|
|
|
if (_maxDrawdown <= -1000)
|
|
alerts.Add($"Large drawdown: {_maxDrawdown:C}");
|
|
|
|
return new RiskStatus(
|
|
TradingEnabled: !_tradingHalted,
|
|
DailyPnL: _dailyPnL,
|
|
DailyLossLimit: 1000, // Will come from config in Phase 1
|
|
MaxDrawdown: _maxDrawdown,
|
|
OpenPositions: GetOpenPositionCount(),
|
|
LastUpdate: _lastUpdate,
|
|
ActiveAlerts: alerts
|
|
);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reset daily state - typically called at start of new trading day
|
|
/// </summary>
|
|
public void ResetDaily()
|
|
{
|
|
lock (_lock)
|
|
{
|
|
_dailyPnL = 0;
|
|
_maxDrawdown = 0;
|
|
_tradingHalted = false;
|
|
_symbolExposure.Clear();
|
|
_lastUpdate = DateTime.UtcNow;
|
|
|
|
_logger.LogInformation("Daily risk state reset");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.8 IPositionSizer.cs
|
|
Path: `src/NT8.Core/Sizing/IPositionSizer.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
using NT8.Core.Common.Models;
|
|
|
|
namespace NT8.Core.Sizing;
|
|
|
|
/// <summary>
|
|
/// Position sizing interface - determines contract quantity
|
|
/// </summary>
|
|
public interface IPositionSizer
|
|
{
|
|
/// <summary>
|
|
/// Calculate position size for trading intent
|
|
/// </summary>
|
|
SizingResult CalculateSize(StrategyIntent intent, StrategyContext context, SizingConfig config);
|
|
|
|
/// <summary>
|
|
/// Get sizing component metadata
|
|
/// </summary>
|
|
SizingMetadata GetMetadata();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Position sizing result
|
|
/// </summary>
|
|
public record SizingResult(
|
|
int Contracts,
|
|
double RiskAmount,
|
|
SizingMethod Method,
|
|
Dictionary<string, object> Calculations
|
|
);
|
|
|
|
/// <summary>
|
|
/// Sizing component metadata
|
|
/// </summary>
|
|
public record SizingMetadata(
|
|
string Name,
|
|
string Description,
|
|
List<string> RequiredParameters
|
|
);
|
|
|
|
/// <summary>
|
|
/// Position sizing configuration
|
|
/// </summary>
|
|
public record SizingConfig(
|
|
SizingMethod Method,
|
|
int MinContracts,
|
|
int MaxContracts,
|
|
double RiskPerTrade,
|
|
Dictionary<string, object> MethodParameters
|
|
);
|
|
|
|
/// <summary>
|
|
/// Position sizing methods
|
|
/// </summary>
|
|
public enum SizingMethod
|
|
{
|
|
FixedContracts,
|
|
FixedDollarRisk,
|
|
OptimalF, // Will be implemented in Phase 1
|
|
KellyCriterion // Will be implemented in Phase 1
|
|
}
|
|
```
|
|
|
|
### 2.9 BasicPositionSizer.cs
|
|
Path: `src/NT8.Core/Sizing/BasicPositionSizer.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
using NT8.Core.Common.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace NT8.Core.Sizing;
|
|
|
|
/// <summary>
|
|
/// Basic position sizer with fixed contracts and fixed dollar risk methods
|
|
/// Handles contract size calculations with proper rounding and clamping
|
|
/// </summary>
|
|
public class BasicPositionSizer : IPositionSizer
|
|
{
|
|
private readonly ILogger<BasicPositionSizer> _logger;
|
|
|
|
public BasicPositionSizer(ILogger<BasicPositionSizer> logger)
|
|
{
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
public SizingResult CalculateSize(StrategyIntent intent, StrategyContext context, SizingConfig config)
|
|
{
|
|
if (intent == null) throw new ArgumentNullException(nameof(intent));
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
if (config == null) throw new ArgumentNullException(nameof(config));
|
|
|
|
// Validate intent is suitable for sizing
|
|
if (!intent.IsValid())
|
|
{
|
|
_logger.LogWarning("Invalid strategy intent provided for sizing: {Intent}", intent);
|
|
return new SizingResult(0, 0, config.Method, new() { ["error"] = "Invalid intent" });
|
|
}
|
|
|
|
return config.Method switch
|
|
{
|
|
SizingMethod.FixedContracts => CalculateFixedContracts(intent, context, config),
|
|
SizingMethod.FixedDollarRisk => CalculateFixedRisk(intent, context, config),
|
|
_ => throw new NotSupportedException($"Sizing method {config.Method} not supported in Phase 0")
|
|
};
|
|
}
|
|
|
|
private SizingResult CalculateFixedContracts(StrategyIntent intent, StrategyContext context, SizingConfig config)
|
|
{
|
|
// Get target contracts from configuration
|
|
var targetContracts = GetParameterValue<int>(config, "contracts", 1);
|
|
|
|
// Apply min/max clamping
|
|
var contracts = Math.Max(config.MinContracts,
|
|
Math.Min(config.MaxContracts, targetContracts));
|
|
|
|
// Calculate actual risk amount
|
|
var tickValue = GetTickValue(intent.Symbol);
|
|
var riskAmount = contracts * intent.StopTicks * tickValue;
|
|
|
|
_logger.LogDebug("Fixed contracts sizing: {Symbol} {TargetContracts}→{ActualContracts} contracts, ${Risk:F2} risk",
|
|
intent.Symbol, targetContracts, contracts, riskAmount);
|
|
|
|
return new SizingResult(
|
|
Contracts: contracts,
|
|
RiskAmount: riskAmount,
|
|
Method: SizingMethod.FixedContracts,
|
|
Calculations: new()
|
|
{
|
|
["target_contracts"] = targetContracts,
|
|
["clamped_contracts"] = contracts,
|
|
["stop_ticks"] = intent.StopTicks,
|
|
["tick_value"] = tickValue,
|
|
["risk_amount"] = riskAmount,
|
|
["min_contracts"] = config.MinContracts,
|
|
["max_contracts"] = config.MaxContracts
|
|
}
|
|
);
|
|
}
|
|
|
|
private SizingResult CalculateFixedRisk(StrategyIntent intent, StrategyContext context, SizingConfig config)
|
|
{
|
|
var tickValue = GetTickValue(intent.Symbol);
|
|
|
|
// Validate stop ticks
|
|
if (intent.StopTicks <= 0)
|
|
{
|
|
_logger.LogWarning("Invalid stop ticks {StopTicks} for fixed risk sizing on {Symbol}",
|
|
intent.StopTicks, intent.Symbol);
|
|
|
|
return new SizingResult(0, 0, SizingMethod.FixedDollarRisk,
|
|
new() { ["error"] = "Invalid stop ticks", ["stop_ticks"] = intent.StopTicks });
|
|
}
|
|
|
|
// Calculate optimal contracts for target risk
|
|
var targetRisk = config.RiskPerTrade;
|
|
var riskPerContract = intent.StopTicks * tickValue;
|
|
var optimalContracts = targetRisk / riskPerContract;
|
|
|
|
// Round down to whole contracts (conservative approach)
|
|
var contracts = (int)Math.Floor(optimalContracts);
|
|
|
|
// Apply min/max clamping
|
|
contracts = Math.Max(config.MinContracts, Math.Min(config.MaxContracts, contracts));
|
|
|
|
// Calculate actual risk with final contract count
|
|
var actualRisk = contracts * riskPerContract;
|
|
|
|
_logger.LogDebug("Fixed risk sizing: {Symbol} ${TargetRisk:F2}→{OptimalContracts:F2}→{ActualContracts} contracts, ${ActualRisk:F2} actual risk",
|
|
intent.Symbol, targetRisk, optimalContracts, contracts, actualRisk);
|
|
|
|
return new SizingResult(
|
|
Contracts: contracts,
|
|
RiskAmount: actualRisk,
|
|
Method: SizingMethod.FixedDollarRisk,
|
|
Calculations: new()
|
|
{
|
|
["target_risk"] = targetRisk,
|
|
["stop_ticks"] = intent.StopTicks,
|
|
["tick_value"] = tickValue,
|
|
["risk_per_contract"] = riskPerContract,
|
|
["optimal_contracts"] = optimalContracts,
|
|
["clamped_contracts"] = contracts,
|
|
["actual_risk"] = actualRisk,
|
|
["min_contracts"] = config.MinContracts,
|
|
["max_contracts"] = config.MaxContracts
|
|
}
|
|
);
|
|
}
|
|
|
|
private static T GetParameterValue<T>(SizingConfig config, string key, T defaultValue)
|
|
{
|
|
if (config.MethodParameters.TryGetValue(key, out var value))
|
|
{
|
|
try
|
|
{
|
|
return (T)Convert.ChangeType(value, typeof(T));
|
|
}
|
|
catch
|
|
{
|
|
// If conversion fails, return default
|
|
return defaultValue;
|
|
}
|
|
}
|
|
|
|
return defaultValue;
|
|
}
|
|
|
|
private static double GetTickValue(string symbol)
|
|
{
|
|
// Static tick values for Phase 0 - will be configurable in Phase 1
|
|
return symbol switch
|
|
{
|
|
"ES" => 12.50, // E-mini S&P 500
|
|
"MES" => 1.25, // Micro E-mini S&P 500
|
|
"NQ" => 5.00, // E-mini NASDAQ-100
|
|
"MNQ" => 0.50, // Micro E-mini NASDAQ-100
|
|
"CL" => 10.00, // Crude Oil
|
|
"GC" => 10.00, // Gold
|
|
"6E" => 12.50, // Euro FX
|
|
"6A" => 10.00, // Australian Dollar
|
|
_ => 12.50 // Default to ES value
|
|
};
|
|
}
|
|
|
|
public SizingMetadata GetMetadata()
|
|
{
|
|
return new SizingMetadata(
|
|
Name: "Basic Position Sizer",
|
|
Description: "Fixed contracts or fixed dollar risk sizing with contract clamping",
|
|
RequiredParameters: new List<string> { "method", "risk_per_trade", "min_contracts", "max_contracts" }
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate sizing configuration parameters
|
|
/// </summary>
|
|
public static bool ValidateConfig(SizingConfig config, out List<string> errors)
|
|
{
|
|
errors = new List<string>();
|
|
|
|
if (config.MinContracts < 0)
|
|
errors.Add("MinContracts must be >= 0");
|
|
|
|
if (config.MaxContracts <= 0)
|
|
errors.Add("MaxContracts must be > 0");
|
|
|
|
if (config.MinContracts > config.MaxContracts)
|
|
errors.Add("MinContracts must be <= MaxContracts");
|
|
|
|
if (config.RiskPerTrade <= 0)
|
|
errors.Add("RiskPerTrade must be > 0");
|
|
|
|
// Method-specific validation
|
|
switch (config.Method)
|
|
{
|
|
case SizingMethod.FixedContracts:
|
|
if (!config.MethodParameters.ContainsKey("contracts"))
|
|
errors.Add("FixedContracts method requires 'contracts' parameter");
|
|
else if (GetParameterValue<int>(config, "contracts", 0) <= 0)
|
|
errors.Add("Fixed contracts parameter must be > 0");
|
|
break;
|
|
|
|
case SizingMethod.FixedDollarRisk:
|
|
// No additional parameters required for fixed dollar risk
|
|
break;
|
|
|
|
default:
|
|
errors.Add($"Unsupported sizing method: {config.Method}");
|
|
break;
|
|
}
|
|
|
|
return errors.Count == 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get supported symbols with their tick values
|
|
/// </summary>
|
|
public static Dictionary<string, double> GetSupportedSymbols()
|
|
{
|
|
return new Dictionary<string, double>
|
|
{
|
|
["ES"] = 12.50,
|
|
["MES"] = 1.25,
|
|
["NQ"] = 5.00,
|
|
["MNQ"] = 0.50,
|
|
["CL"] = 10.00,
|
|
["GC"] = 10.00,
|
|
["6E"] = 12.50,
|
|
["6A"] = 10.00
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
## 3. Test Files
|
|
|
|
### 3.1 BasicRiskManagerTests.cs
|
|
Path: `tests/NT8.Core.Tests/Risk/BasicRiskManagerTests.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
using NT8.Core.Risk;
|
|
using NT8.Core.Common.Models;
|
|
using NT8.Core.Tests.TestHelpers;
|
|
using Microsoft.Extensions.Logging;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace NT8.Core.Tests.Risk;
|
|
|
|
public class BasicRiskManagerTests : IDisposable
|
|
{
|
|
private readonly ILogger<BasicRiskManager> _logger;
|
|
private readonly BasicRiskManager _riskManager;
|
|
|
|
public BasicRiskManagerTests()
|
|
{
|
|
_logger = NullLogger<BasicRiskManager>.Instance;
|
|
_riskManager = new BasicRiskManager(_logger);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_WithinLimits_ShouldAllow()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(stopTicks: 8);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeTrue();
|
|
result.RejectReason.Should().BeNull();
|
|
result.RiskLevel.Should().Be(RiskLevel.Low);
|
|
result.RiskMetrics.Should().ContainKey("trade_risk");
|
|
result.RiskMetrics.Should().ContainKey("daily_pnl");
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_ExceedsDailyLimit_ShouldReject()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 1000,
|
|
MaxTradeRisk: 500,
|
|
MaxOpenPositions: 5,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
// Simulate daily loss exceeding limit
|
|
_riskManager.OnPnLUpdate(0, -1001);
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Daily loss limit breached");
|
|
result.RiskLevel.Should().Be(RiskLevel.Critical);
|
|
result.RiskMetrics["daily_pnl"].Should().Be(-1001);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_ExceedsTradeRisk_ShouldReject()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(stopTicks: 100); // High risk trade
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 10000,
|
|
MaxTradeRisk: 500, // Lower than calculated trade risk
|
|
MaxOpenPositions: 5,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Trade risk too high");
|
|
result.RiskLevel.Should().Be(RiskLevel.High);
|
|
|
|
// Verify risk calculation
|
|
var expectedRisk = 100 * 12.50; // 100 ticks * ES tick value
|
|
result.RiskMetrics["trade_risk"].Should().Be(expectedRisk);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ES", 8, 100.0)] // ES: 8 ticks * $12.50 = $100
|
|
[InlineData("MES", 8, 10.0)] // MES: 8 ticks * $1.25 = $10
|
|
[InlineData("NQ", 4, 20.0)] // NQ: 4 ticks * $5.00 = $20
|
|
[InlineData("MNQ", 10, 5.0)] // MNQ: 10 ticks * $0.50 = $5
|
|
public void ValidateOrder_RiskCalculation_ShouldBeAccurate(string symbol, int stopTicks, double expectedRisk)
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(symbol: symbol, stopTicks: stopTicks);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeTrue();
|
|
result.RiskMetrics["trade_risk"].Should().Be(expectedRisk);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_MaxPositionsExceeded_ShouldReject()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 10000,
|
|
MaxTradeRisk: 1000,
|
|
MaxOpenPositions: 1, // Very low limit
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
// Simulate existing position by processing a fill
|
|
var fill = new OrderFill(
|
|
OrderId: Guid.NewGuid().ToString(),
|
|
Symbol: "NQ",
|
|
Quantity: 2,
|
|
FillPrice: 15000.0,
|
|
FillTime: DateTime.UtcNow,
|
|
Commission: 5.0,
|
|
ExecutionId: Guid.NewGuid().ToString()
|
|
);
|
|
_riskManager.OnFill(fill);
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Max open positions exceeded");
|
|
result.RiskLevel.Should().Be(RiskLevel.Medium);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EmergencyFlatten_ShouldHaltTrading()
|
|
{
|
|
// Arrange
|
|
var reason = "Test emergency halt";
|
|
|
|
// Act
|
|
var result = await _riskManager.EmergencyFlatten(reason);
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
result.Should().BeTrue();
|
|
status.TradingEnabled.Should().BeFalse();
|
|
status.ActiveAlerts.Should().Contain("Trading halted");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EmergencyFlatten_WithNullReason_ShouldThrow()
|
|
{
|
|
// Act & Assert
|
|
await Assert.ThrowsAsync<ArgumentException>(() => _riskManager.EmergencyFlatten(null));
|
|
await Assert.ThrowsAsync<ArgumentException>(() => _riskManager.EmergencyFlatten(""));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_AfterEmergencyFlatten_ShouldRejectAllOrders()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
|
|
// Trigger emergency flatten
|
|
_riskManager.EmergencyFlatten("Test").Wait();
|
|
|
|
// Act
|
|
var result = _riskManager.ValidateOrder(intent, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Trading halted");
|
|
result.RiskLevel.Should().Be(RiskLevel.Critical);
|
|
}
|
|
|
|
[Fact]
|
|
public void OnPnLUpdate_WithLargeDrawdown_ShouldUpdateStatus()
|
|
{
|
|
// Arrange
|
|
var largeLoss = -1500.0;
|
|
|
|
// Act
|
|
_riskManager.OnPnLUpdate(largeLoss, largeLoss);
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
status.DailyPnL.Should().Be(largeLoss);
|
|
status.MaxDrawdown.Should().Be(largeLoss);
|
|
status.ActiveAlerts.Should().Contain(alert => alert.Contains("drawdown"));
|
|
}
|
|
|
|
[Fact]
|
|
public void OnFill_ShouldUpdateExposure()
|
|
{
|
|
// Arrange
|
|
var fill = new OrderFill(
|
|
OrderId: Guid.NewGuid().ToString(),
|
|
Symbol: "ES",
|
|
Quantity: 2,
|
|
FillPrice: 4200.0,
|
|
FillTime: DateTime.UtcNow,
|
|
Commission: 4.50,
|
|
ExecutionId: Guid.NewGuid().ToString()
|
|
);
|
|
|
|
// Act
|
|
_riskManager.OnFill(fill);
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
status.LastUpdate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateOrder_WithNullParameters_ShouldThrow()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>(() => _riskManager.ValidateOrder(null, context, config));
|
|
Assert.Throws<ArgumentNullException>(() => _riskManager.ValidateOrder(intent, null, config));
|
|
Assert.Throws<ArgumentNullException>(() => _riskManager.ValidateOrder(intent, context, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void RiskLevel_ShouldEscalateWithLosses()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new RiskConfig(100, 500, 5, true); // $1000 daily limit
|
|
|
|
// Act & Assert - Low risk (no losses)
|
|
var result1 = _riskManager.ValidateOrder(intent, context, config);
|
|
result1.RiskLevel.Should().Be(RiskLevel.Low);
|
|
|
|
// Medium risk (50% of daily limit)
|
|
_riskManager.OnPnLUpdate(-500, -500);
|
|
var result2 = _riskManager.ValidateOrder(intent, context, config);
|
|
result2.RiskLevel.Should().Be(RiskLevel.Medium);
|
|
|
|
// High risk (80% of daily limit)
|
|
_riskManager.OnPnLUpdate(-800, -800);
|
|
var result3 = _riskManager.ValidateOrder(intent, context, config);
|
|
result3.RiskLevel.Should().Be(RiskLevel.High);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetDaily_ShouldClearState()
|
|
{
|
|
// Arrange - Set up some risk state
|
|
_riskManager.OnPnLUpdate(-500, -500);
|
|
var fill = new OrderFill("test", "ES", 2, 4200, DateTime.UtcNow, 4.50, "exec1");
|
|
_riskManager.OnFill(fill);
|
|
|
|
// Act
|
|
_riskManager.ResetDaily();
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
status.DailyPnL.Should().Be(0);
|
|
status.MaxDrawdown.Should().Be(0);
|
|
status.TradingEnabled.Should().BeTrue();
|
|
status.OpenPositions.Should().Be(0);
|
|
status.ActiveAlerts.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void GetRiskStatus_ShouldReturnCurrentState()
|
|
{
|
|
// Arrange
|
|
var testPnL = -300.0;
|
|
_riskManager.OnPnLUpdate(testPnL, testPnL);
|
|
|
|
// Act
|
|
var status = _riskManager.GetRiskStatus();
|
|
|
|
// Assert
|
|
status.TradingEnabled.Should().BeTrue();
|
|
status.DailyPnL.Should().Be(testPnL);
|
|
status.MaxDrawdown.Should().Be(testPnL);
|
|
status.LastUpdate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
status.ActiveAlerts.Should().NotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ConcurrentAccess_ShouldBeThreadSafe()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
var tasks = new List<Task>();
|
|
|
|
// Act - Multiple threads accessing simultaneously
|
|
for (int i = 0; i < 10; i++)
|
|
{
|
|
tasks.Add(Task.Run(() =>
|
|
{
|
|
_riskManager.ValidateOrder(intent, context, config);
|
|
_riskManager.OnPnLUpdate(-10 * i, -10 * i);
|
|
}));
|
|
}
|
|
|
|
Task.WaitAll(tasks.ToArray());
|
|
|
|
// Assert - Should not throw and should have consistent state
|
|
var status = _riskManager.GetRiskStatus();
|
|
status.Should().NotBeNull();
|
|
status.LastUpdate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// Cleanup if needed
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3.2 RiskScenarioTests.cs
|
|
Path: `tests/NT8.Core.Tests/Risk/RiskScenarioTests.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
using NT8.Core.Risk;
|
|
using NT8.Core.Common.Models;
|
|
using NT8.Core.Tests.TestHelpers;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace NT8.Core.Tests.Risk;
|
|
|
|
/// <summary>
|
|
/// Comprehensive risk scenario testing
|
|
/// These tests validate the risk manager against real-world trading scenarios
|
|
/// </summary>
|
|
public class RiskScenarioTests
|
|
{
|
|
private readonly BasicRiskManager _riskManager;
|
|
|
|
public RiskScenarioTests()
|
|
{
|
|
_riskManager = new BasicRiskManager(NullLogger<BasicRiskManager>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_TypicalTradingDay_ShouldManageRiskCorrectly()
|
|
{
|
|
// Arrange - Typical day configuration
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 1000,
|
|
MaxTradeRisk: 200,
|
|
MaxOpenPositions: 3,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Act & Assert - Morning trades should be allowed
|
|
var morningTrade1 = TestDataBuilder.CreateValidIntent(stopTicks: 8); // $10 risk
|
|
var result1 = _riskManager.ValidateOrder(morningTrade1, context, config);
|
|
result1.Allow.Should().BeTrue();
|
|
result1.RiskLevel.Should().Be(RiskLevel.Low);
|
|
|
|
// Simulate some losses
|
|
_riskManager.OnPnLUpdate(-150, -150);
|
|
|
|
var morningTrade2 = TestDataBuilder.CreateValidIntent(stopTicks: 12); // $150 risk
|
|
var result2 = _riskManager.ValidateOrder(morningTrade2, context, config);
|
|
result2.Allow.Should().BeTrue();
|
|
result2.RiskLevel.Should().Be(RiskLevel.Low);
|
|
|
|
// More losses - should escalate risk level
|
|
_riskManager.OnPnLUpdate(-600, -600);
|
|
|
|
var afternoonTrade = TestDataBuilder.CreateValidIntent(stopTicks: 8);
|
|
var result3 = _riskManager.ValidateOrder(afternoonTrade, context, config);
|
|
result3.Allow.Should().BeTrue();
|
|
result3.RiskLevel.Should().Be(RiskLevel.Medium); // Should escalate
|
|
|
|
// Near daily limit - high risk
|
|
_riskManager.OnPnLUpdate(-850, -850);
|
|
|
|
var lateTrade = TestDataBuilder.CreateValidIntent(stopTicks: 8);
|
|
var result4 = _riskManager.ValidateOrder(lateTrade, context, config);
|
|
result4.Allow.Should().BeTrue();
|
|
result4.RiskLevel.Should().Be(RiskLevel.High);
|
|
|
|
// Exceed daily limit - should halt
|
|
_riskManager.OnPnLUpdate(-1050, -1050);
|
|
|
|
var deniedTrade = TestDataBuilder.CreateValidIntent(stopTicks: 4);
|
|
var result5 = _riskManager.ValidateOrder(deniedTrade, context, config);
|
|
result5.Allow.Should().BeFalse();
|
|
result5.RiskLevel.Should().Be(RiskLevel.Critical);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_HighRiskTrade_ShouldBeRejected()
|
|
{
|
|
// Arrange - Conservative risk settings
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 2000,
|
|
MaxTradeRisk: 10, // Very conservative
|
|
MaxOpenPositions: 5,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Act - Try to place high-risk trade
|
|
var highRiskTrade = TestDataBuilder.CreateValidIntent(
|
|
symbol: "ES",
|
|
stopTicks: 20 // $250 risk, exceeds $100 limit
|
|
);
|
|
|
|
var result = _riskManager.ValidateOrder(highRiskTrade, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Trade risk too high");
|
|
result.RiskMetrics["trade_risk"].Should().Be(250.0); // 20 * $12.50
|
|
result.RiskMetrics["limit"].Should().Be(100.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_MaxPositions_ShouldLimitNewTrades()
|
|
{
|
|
// Arrange - Low position limit
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 5000,
|
|
MaxTradeRisk: 500,
|
|
MaxOpenPositions: 2,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Fill up position slots
|
|
var fill1 = new OrderFill("order1", "ES", 1, 4200, DateTime.UtcNow, 2.25, "exec1");
|
|
var fill2 = new OrderFill("order2", "NQ", 1, 15000, DateTime.UtcNow, 2.50, "exec2");
|
|
|
|
_riskManager.OnFill(fill1);
|
|
_riskManager.OnFill(fill2);
|
|
|
|
// Act - Try to add another position
|
|
var newTrade = TestDataBuilder.CreateValidIntent(symbol: "CL");
|
|
var result = _riskManager.ValidateOrder(newTrade, context, config);
|
|
|
|
// Assert
|
|
result.Allow.Should().BeFalse();
|
|
result.RejectReason.Should().Contain("Max open positions exceeded");
|
|
result.RiskLevel.Should().Be(RiskLevel.Medium);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_RecoveryAfterReset_ShouldAllowTrading()
|
|
{
|
|
// Arrange - Simulate end of bad trading day
|
|
var config = TestDataBuilder.CreateTestRiskConfig();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Simulate terrible day with emergency halt
|
|
_riskManager.OnPnLUpdate(-1500, -1500);
|
|
_riskManager.EmergencyFlatten("End of day").Wait();
|
|
|
|
var haltedTrade = TestDataBuilder.CreateValidIntent();
|
|
var haltedResult = _riskManager.ValidateOrder(haltedTrade, context, config);
|
|
haltedResult.Allow.Should().BeFalse();
|
|
|
|
// Act - Reset for new day
|
|
_riskManager.ResetDaily();
|
|
|
|
var newDayTrade = TestDataBuilder.CreateValidIntent();
|
|
var newResult = _riskManager.ValidateOrder(newDayTrade, context, config);
|
|
|
|
// Assert - Should be back to normal
|
|
newResult.Allow.Should().BeTrue();
|
|
newResult.RiskLevel.Should().Be(RiskLevel.Low);
|
|
|
|
var status = _riskManager.GetRiskStatus();
|
|
status.TradingEnabled.Should().BeTrue();
|
|
status.DailyPnL.Should().Be(0);
|
|
status.ActiveAlerts.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_VolatileMarket_ShouldHandleMultipleSymbols()
|
|
{
|
|
// Arrange - Multi-symbol trading
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 2000,
|
|
MaxTradeRisk: 300,
|
|
MaxOpenPositions: 4,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
|
|
// Act - Trade multiple symbols
|
|
var esTrade = TestDataBuilder.CreateValidIntent(symbol: "ES", stopTicks: 16); // $200 risk
|
|
var nqTrade = TestDataBuilder.CreateValidIntent(symbol: "NQ", stopTicks: 40); // $200 risk
|
|
var clTrade = TestDataBuilder.CreateValidIntent(symbol: "CL", stopTicks: 20); // $200 risk
|
|
|
|
var esResult = _riskManager.ValidateOrder(esTrade, context, config);
|
|
var nqResult = _riskManager.ValidateOrder(nqTrade, context, config);
|
|
var clResult = _riskManager.ValidateOrder(clTrade, context, config);
|
|
|
|
// Assert - All should be allowed
|
|
esResult.Allow.Should().BeTrue();
|
|
nqResult.Allow.Should().BeTrue();
|
|
clResult.Allow.Should().BeTrue();
|
|
|
|
// Verify risk calculations are symbol-specific
|
|
esResult.RiskMetrics["trade_risk"].Should().Be(200.0); // ES: 16 * $12.50
|
|
nqResult.RiskMetrics["trade_risk"].Should().Be(200.0); // NQ: 40 * $5.00
|
|
clResult.RiskMetrics["trade_risk"].Should().Be(200.0); // CL: 20 * $10.00
|
|
}
|
|
|
|
[Fact]
|
|
public void Scenario_GradualLossEscalation_ShouldShowRiskProgression()
|
|
{
|
|
// Arrange
|
|
var config = new RiskConfig(
|
|
DailyLossLimit: 1000,
|
|
MaxTradeRisk: 200,
|
|
MaxOpenPositions: 5,
|
|
EmergencyFlattenEnabled: true
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var intent = TestDataBuilder.CreateValidIntent(stopTicks: 8);
|
|
|
|
// Act & Assert - Track risk level escalation
|
|
var results = new List<(double loss, RiskLevel level)>();
|
|
|
|
// Start: No losses
|
|
var result0 = _riskManager.ValidateOrder(intent, context, config);
|
|
results.Add((0, result0.RiskLevel));
|
|
|
|
// 30% loss
|
|
_riskManager.OnPnLUpdate(-300, -300);
|
|
var result1 = _riskManager.ValidateOrder(intent, context, config);
|
|
results.Add((-300, result1.RiskLevel));
|
|
|
|
// 50% loss
|
|
_riskManager.OnPnLUpdate(-500, -500);
|
|
var result2 = _riskManager.ValidateOrder(intent, context, config);
|
|
results.Add((-500, result2.RiskLevel));
|
|
|
|
// 80% loss
|
|
_riskManager.OnPnLUpdate(-800, -800);
|
|
var result3 = _riskManager.ValidateOrder(intent, context, config);
|
|
results.Add((-800, result3.RiskLevel));
|
|
|
|
// Assert escalation pattern
|
|
results[0].level.Should().Be(RiskLevel.Low); // 0% loss
|
|
results[1].level.Should().Be(RiskLevel.Low); // 30% loss
|
|
results[2].level.Should().Be(RiskLevel.Medium); // 50% loss
|
|
results[3].level.Should().Be(RiskLevel.High); // 80% loss
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3.3 BasicPositionSizerTests.cs
|
|
Path: `tests/NT8.Core.Tests/Sizing/BasicPositionSizerTests.cs`
|
|
|
|
Content:
|
|
```csharp
|
|
using NT8.Core.Sizing;
|
|
using NT8.Core.Common.Models;
|
|
using NT8.Core.Tests.TestHelpers;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using FluentAssertions;
|
|
using Xunit;
|
|
|
|
namespace NT8.Core.Tests.Sizing;
|
|
|
|
public class BasicPositionSizerTests : IDisposable
|
|
{
|
|
private readonly ILogger<BasicPositionSizer> _logger;
|
|
private readonly BasicPositionSizer _sizer;
|
|
|
|
public BasicPositionSizerTests()
|
|
{
|
|
_logger = NullLogger<BasicPositionSizer>.Instance;
|
|
_sizer = new BasicPositionSizer(_logger);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_FixedContracts_ShouldReturnCorrectSize()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(symbol: "ES", stopTicks: 8);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.FixedContracts,
|
|
MinContracts: 1,
|
|
MaxContracts: 10,
|
|
RiskPerTrade: 200,
|
|
MethodParameters: new() { ["contracts"] = 3 }
|
|
);
|
|
|
|
// Act
|
|
var result = _sizer.CalculateSize(intent, context, config);
|
|
|
|
// Assert
|
|
result.Contracts.Should().Be(3);
|
|
result.Method.Should().Be(SizingMethod.FixedContracts);
|
|
result.RiskAmount.Should().Be(300.0); // 3 contracts * 8 ticks * $12.50
|
|
result.Calculations.Should().ContainKey("target_contracts");
|
|
result.Calculations.Should().ContainKey("clamped_contracts");
|
|
result.Calculations["target_contracts"].Should().Be(3);
|
|
result.Calculations["clamped_contracts"].Should().Be(3);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_FixedContractsWithClamping_ShouldApplyLimits()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(symbol: "ES", stopTicks: 10);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.FixedContracts,
|
|
MinContracts: 2,
|
|
MaxContracts: 5,
|
|
RiskPerTrade: 200,
|
|
MethodParameters: new() { ["contracts"] = 8 } // Exceeds max
|
|
);
|
|
|
|
// Act
|
|
var result = _sizer.CalculateSize(intent, context, config);
|
|
|
|
// Assert
|
|
result.Contracts.Should().Be(5); // Clamped to max
|
|
result.Calculations["target_contracts"].Should().Be(8);
|
|
result.Calculations["clamped_contracts"].Should().Be(5);
|
|
result.RiskAmount.Should().Be(625.0); // 5 * 10 * $12.50
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_FixedDollarRisk_ShouldCalculateCorrectly()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(symbol: "ES", stopTicks: 10);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.FixedDollarRisk,
|
|
MinContracts: 1,
|
|
MaxContracts: 10,
|
|
RiskPerTrade: 250.0, // Target $250 risk
|
|
MethodParameters: new()
|
|
);
|
|
|
|
// Act
|
|
var result = _sizer.CalculateSize(intent, context, config);
|
|
|
|
// Assert
|
|
// $250 target / (10 ticks * $12.50) = 2 contracts
|
|
result.Contracts.Should().Be(2);
|
|
result.Method.Should().Be(SizingMethod.FixedDollarRisk);
|
|
result.RiskAmount.Should().Be(250.0); // 2 * 10 * $12.50
|
|
result.Calculations["target_risk"].Should().Be(250.0);
|
|
result.Calculations["optimal_contracts"].Should().Be(2.0);
|
|
result.Calculations["actual_risk"].Should().Be(250.0);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ES", 8, 200.0, 2, 200.0)] // ES: $200 / (8 * $12.50) = 2.0 → 2 contracts
|
|
[InlineData("MES", 8, 20.0, 2, 20.0)] // MES: $20 / (8 * $1.25) = 2.0 → 2 contracts
|
|
[InlineData("NQ", 10, 100.0, 2, 100.0)] // NQ: $100 / (10 * $5.00) = 2.0 → 2 contracts
|
|
[InlineData("CL", 5, 75.0, 1, 50.0)] // CL: $75 / (5 * $10.00) = 1.5 → 1 contract (floor)
|
|
public void CalculateSize_FixedRiskVariousSymbols_ShouldCalculateCorrectly(
|
|
string symbol, int stopTicks, double targetRisk, int expectedContracts, double expectedActualRisk)
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(symbol: symbol, stopTicks: stopTicks);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.FixedDollarRisk,
|
|
MinContracts: 1,
|
|
MaxContracts: 10,
|
|
RiskPerTrade: targetRisk,
|
|
MethodParameters: new()
|
|
);
|
|
|
|
// Act
|
|
var result = _sizer.CalculateSize(intent, context, config);
|
|
|
|
// Assert
|
|
result.Contracts.Should().Be(expectedContracts);
|
|
result.RiskAmount.Should().Be(expectedActualRisk);
|
|
result.Method.Should().Be(SizingMethod.FixedDollarRisk);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_FixedRiskWithMinClamp_ShouldApplyMinimum()
|
|
{
|
|
// Arrange - Very small risk that would calculate to 0 contracts
|
|
var intent = TestDataBuilder.CreateValidIntent(symbol: "ES", stopTicks: 20);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.FixedDollarRisk,
|
|
MinContracts: 2, // Force minimum
|
|
MaxContracts: 10,
|
|
RiskPerTrade: 100.0, // Only enough for 0.4 contracts
|
|
MethodParameters: new()
|
|
);
|
|
|
|
// Act
|
|
var result = _sizer.CalculateSize(intent, context, config);
|
|
|
|
// Assert
|
|
result.Contracts.Should().Be(2); // Applied minimum
|
|
result.RiskAmount.Should().Be(500.0); // 2 * 20 * $12.50
|
|
result.Calculations["optimal_contracts"].Should().Be(0.4);
|
|
result.Calculations["clamped_contracts"].Should().Be(2);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_FixedRiskWithMaxClamp_ShouldApplyMaximum()
|
|
{
|
|
// Arrange - Large risk that would calculate to many contracts
|
|
var intent = TestDataBuilder.CreateValidIntent(symbol: "ES", stopTicks: 5);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.FixedDollarRisk,
|
|
MinContracts: 1,
|
|
MaxContracts: 3, // Limit maximum
|
|
RiskPerTrade: 1000.0, // Enough for 16 contracts
|
|
MethodParameters: new()
|
|
);
|
|
|
|
// Act
|
|
var result = _sizer.CalculateSize(intent, context, config);
|
|
|
|
// Assert
|
|
result.Contracts.Should().Be(3); // Applied maximum
|
|
result.RiskAmount.Should().Be(187.5); // 3 * 5 * $12.50
|
|
result.Calculations["optimal_contracts"].Should().Be(16.0);
|
|
result.Calculations["clamped_contracts"].Should().Be(3);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_ZeroStopTicks_ShouldReturnZeroContracts()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(stopTicks: 0); // Invalid
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestSizingConfig(SizingMethod.FixedDollarRisk);
|
|
|
|
// Act
|
|
var result = _sizer.CalculateSize(intent, context, config);
|
|
|
|
// Assert
|
|
result.Contracts.Should().Be(0);
|
|
result.RiskAmount.Should().Be(0);
|
|
result.Calculations.Should().ContainKey("error");
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_InvalidIntent_ShouldReturnZeroContracts()
|
|
{
|
|
// Arrange - Create invalid intent
|
|
var intent = new StrategyIntent(
|
|
Symbol: "", // Invalid empty symbol
|
|
Side: OrderSide.Buy,
|
|
EntryType: OrderType.Market,
|
|
LimitPrice: null,
|
|
StopTicks: 10,
|
|
TargetTicks: 20,
|
|
Confidence: 0.8,
|
|
Reason: "Test",
|
|
Metadata: new()
|
|
);
|
|
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestSizingConfig();
|
|
|
|
// Act
|
|
var result = _sizer.CalculateSize(intent, context, config);
|
|
|
|
// Assert
|
|
result.Contracts.Should().Be(0);
|
|
result.RiskAmount.Should().Be(0);
|
|
result.Calculations.Should().ContainKey("error");
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_WithNullParameters_ShouldThrow()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = TestDataBuilder.CreateTestSizingConfig();
|
|
|
|
// Act & Assert
|
|
Assert.Throws<ArgumentNullException>(() => _sizer.CalculateSize(null, context, config));
|
|
Assert.Throws<ArgumentNullException>(() => _sizer.CalculateSize(intent, null, config));
|
|
Assert.Throws<ArgumentNullException>(() => _sizer.CalculateSize(intent, context, null));
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_UnsupportedMethod_ShouldThrow()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent();
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.OptimalF, // Not supported in Phase 0
|
|
MinContracts: 1,
|
|
MaxContracts: 10,
|
|
RiskPerTrade: 200,
|
|
MethodParameters: new()
|
|
);
|
|
|
|
// Act & Assert
|
|
Assert.Throws<NotSupportedException>(() => _sizer.CalculateSize(intent, context, config));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetMetadata_ShouldReturnCorrectInformation()
|
|
{
|
|
// Act
|
|
var metadata = _sizer.GetMetadata();
|
|
|
|
// Assert
|
|
metadata.Name.Should().Be("Basic Position Sizer");
|
|
metadata.Description.Should().Contain("Fixed contracts");
|
|
metadata.Description.Should().Contain("fixed dollar risk");
|
|
metadata.RequiredParameters.Should().Contain("method");
|
|
metadata.RequiredParameters.Should().Contain("risk_per_trade");
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateConfig_ValidConfiguration_ShouldReturnTrue()
|
|
{
|
|
// Arrange
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.FixedContracts,
|
|
MinContracts: 1,
|
|
MaxContracts: 10,
|
|
RiskPerTrade: 200,
|
|
MethodParameters: new() { ["contracts"] = 2 }
|
|
);
|
|
|
|
// Act
|
|
var isValid = BasicPositionSizer.ValidateConfig(config, out var errors);
|
|
|
|
// Assert
|
|
isValid.Should().BeTrue();
|
|
errors.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateConfig_InvalidConfiguration_ShouldReturnErrors()
|
|
{
|
|
// Arrange
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.FixedContracts,
|
|
MinContracts: 5,
|
|
MaxContracts: 2, // Invalid: min > max
|
|
RiskPerTrade: -100, // Invalid: negative risk
|
|
MethodParameters: new() // Missing required parameter
|
|
);
|
|
|
|
// Act
|
|
var isValid = BasicPositionSizer.ValidateConfig(config, out var errors);
|
|
|
|
// Assert
|
|
isValid.Should().BeFalse();
|
|
errors.Should().Contain("MinContracts must be <= MaxContracts");
|
|
errors.Should().Contain("RiskPerTrade must be > 0");
|
|
errors.Should().Contain("FixedContracts method requires 'contracts' parameter");
|
|
}
|
|
|
|
[Fact]
|
|
public void GetSupportedSymbols_ShouldReturnAllSymbolsWithTickValues()
|
|
{
|
|
// Act
|
|
var symbols = BasicPositionSizer.GetSupportedSymbols();
|
|
|
|
// Assert
|
|
symbols.Should().ContainKey("ES").WhoseValue.Should().Be(12.50);
|
|
symbols.Should().ContainKey("MES").WhoseValue.Should().Be(1.25);
|
|
symbols.Should().ContainKey("NQ").WhoseValue.Should().Be(5.00);
|
|
symbols.Should().ContainKey("MNQ").WhoseValue.Should().Be(0.50);
|
|
symbols.Should().ContainKey("CL").WhoseValue.Should().Be(10.00);
|
|
symbols.Should().ContainKey("GC").WhoseValue.Should().Be(10.00);
|
|
symbols.Count.Should().BeGreaterOrEqualTo(6);
|
|
}
|
|
|
|
[Fact]
|
|
public void CalculateSize_ConsistentResults_ShouldBeDeterministic()
|
|
{
|
|
// Arrange
|
|
var intent = TestDataBuilder.CreateValidIntent(symbol: "ES", stopTicks: 12);
|
|
var context = TestDataBuilder.CreateTestContext();
|
|
var config = new SizingConfig(
|
|
Method: SizingMethod.FixedDollarRisk,
|
|
MinContracts: 1,
|
|
MaxContracts: 10,
|
|
RiskPerTrade: 300,
|
|
MethodParameters: new()
|
|
);
|
|
|
|
// Act - Calculate multiple times
|
|
var results = new List<SizingResult>();
|
|
for (int i = 0; i < 5; i++)
|
|
{
|
|
results.Add(_sizer.CalculateSize(intent, context, config));
|
|
}
|
|
|
|
// Assert - All results should be identical
|
|
var firstResult = results[0];
|
|
foreach (var result in results.Skip(1))
|
|
{
|
|
result.Contracts.Should().Be(firstResult.Contracts);
|
|
result.RiskAmount.Should().Be(firstResult.RiskAmount);
|
|
result.Method.Should().Be(firstResult.Method);
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
// Cleanup if needed
|
|
}
|
|
}
|
|
```
|
|
|
|
## 4. Implementation Summary
|
|
|
|
This implementation guide provides the exact content for all files required to implement the NT8 Institutional SDK Phase 0. When switching to Code mode, these files should be created in the specified paths with the provided content.
|
|
|
|
The implementation follows these key principles:
|
|
1. **Risk First**: All trades pass through risk management before execution
|
|
2. **Deterministic**: Identical inputs produce identical outputs for testing
|
|
3. **Modular**: Strategies are thin plugins, SDK handles infrastructure
|
|
4. **Observable**: Structured logging with correlation IDs throughout
|
|
5. **Test-Driven**: Comprehensive unit tests with >90% coverage
|
|
|
|
## 5. Next Steps
|
|
|
|
After implementing all files:
|
|
1. Create the solution and projects using dotnet CLI commands
|
|
2. Add required NuGet packages
|
|
3. Run the complete validation script
|
|
4. Verify all success criteria are met
|
|
5. Document SDK foundation and usage guidelines |