DebugTrading: ein Modul, das absichtlich Last erzeugt
Alles, was den Handel absichert - Limits, Deckung, Kapitalpool, Reservierungen, Abgleich, Sperren - war bisher ausschliesslich durch Unit-Tests mit gemockten Gegenstellen belegt. Im Betrieb gab es fast keine Trades. Ein Fehler in der Buchungskette faellt so erst auf, wenn eine echte Strategie scharf geht. Das Modul eroeffnet kleine Positionen, haelt sie eine gewuerfelte Zeit von Minuten bis Tagen und schliesst sie selbst. Es soll keine Rendite erzeugen, sondern Last; die Auswahl ist zufaellig und ausdruecklich nicht renditegetrieben. Planung und Sicherungen sind reine Rechnung mit einer von aussen gereichten Zufallsquelle - genau deshalb ist ein Modul, das absichtlich wuerfelt, ueberhaupt pruefbar. 14 Tests. Die Sicherung, die nicht verhandelbar ist: Der Worker prueft bei JEDEM Lauf, ob der Handelsmodus eindeutig Paper ist, und wirft sonst. Nicht nur beim Start - Einstellungen werden kopiert, wenn eine Instanz umzieht. Geprueft wird gegen den Rohwert UND den geparsten Modus: TradingSettings.ParsedMode faellt bei einem unbekannten Wert auf Paper zurueck, und darauf darf sich diese Pruefung nicht verlassen. Dazu Tagesbudget (in Betriebszeitzone, nicht UTC), Obergrenze offener Positionen, Grenze je Trade und ein Not-Aus, der das Eroeffnen stoppt und offene Positionen weiter betreut. Schliessen hat Vorrang vor Eroeffnen: Sonst koennte eine erreichte Obergrenze verhindern, dass faellige Positionen je geschlossen werden - die Sperre bliebe fuer immer. Beim Bauen hat der Architekturtest aus #474 seinen ersten echten Fang gemacht: Das Modul griff nach IBrokerClient, um Kurse zu holen. Das verstoesst gegen Regel 4. Statt den Test aufzuweichen ist eine lesende Kursquelle entstanden (IQuoteSource) - wer einen Kurs braucht, soll dafuer nicht den Zugang bekommen, mit dem sich Orders platzieren lassen. Auf dem Testserver nachgewiesen: Das Modul laeuft im Takt, plant, wird bei fehlendem Kurs sauber abgelehnt und meldet das - und mit Trading.Mode=Live verweigert es den Dienst, waehrend unabhaengig davon auch der Broker-Adapter den Paper-Port gegen den Live-Modus sperrt. Zwei Schichten, die nichts voneinander wissen. 438 Tests gruen. Arbeit an #468. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,5 +7,6 @@
|
|||||||
<Project Path="src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj" />
|
<Project Path="src/IBKRTrader.Modules.Accounting/IBKRTrader.Modules.Accounting.csproj" />
|
||||||
<Project Path="src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj" />
|
<Project Path="src/IBKRTrader.Modules.Supervisor/IBKRTrader.Modules.Supervisor.csproj" />
|
||||||
<Project Path="src/IBKRTrader.Modules.OptionsWheel/IBKRTrader.Modules.OptionsWheel.csproj" />
|
<Project Path="src/IBKRTrader.Modules.OptionsWheel/IBKRTrader.Modules.OptionsWheel.csproj" />
|
||||||
|
<Project Path="src/IBKRTrader.Modules.DebugTrading/IBKRTrader.Modules.DebugTrading.csproj" />
|
||||||
<Project Path="tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj" />
|
<Project Path="tests/IBKRTrader.Tests/IBKRTrader.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -309,6 +309,71 @@ public class CapitalSettings
|
|||||||
$"Hausreserve {HouseReservePercent:0.#} %, max. {MaxSymbolPercent:0.#} % je Einzelwert";
|
$"Hausreserve {HouseReservePercent:0.#} %, max. {MaxSymbolPercent:0.#} % je Einzelwert";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── DebugTrading (Prüfmodul, niemals Live) ──────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Einstellungen des DebugTrading-Moduls (#468). Es erzeugt absichtlich Last auf der
|
||||||
|
/// Buchungskette – kleine Trades, gestreut über Handelsplätze, mit gewürfelter Haltedauer.
|
||||||
|
///
|
||||||
|
/// <para><b>Die Grenzen hier sind die zweite Verteidigungslinie.</b> Die erste ist die
|
||||||
|
/// Startverweigerung im Modul selbst, wenn der Handelsmodus nicht Paper ist; sie lässt sich nicht
|
||||||
|
/// wegkonfigurieren.</para>
|
||||||
|
/// </summary>
|
||||||
|
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||||
|
public class DebugTradingSettings
|
||||||
|
{
|
||||||
|
[Category("DebugTrading")]
|
||||||
|
[DisplayName("Aktiviert")]
|
||||||
|
[Description("Modulschalter. Aus = der Worker läuft nicht.")]
|
||||||
|
public bool Enabled { get; set; } = false;
|
||||||
|
|
||||||
|
[Category("DebugTrading")]
|
||||||
|
[DisplayName("Keine Neueröffnungen")]
|
||||||
|
[Description("Not-Aus: offene Testpositionen werden weiter geschlossen, aber keine neuen " +
|
||||||
|
"eröffnet. Der schonende Halt - Abschalten ließe offene Positionen unbetreut.")]
|
||||||
|
public bool StopOpening { get; set; } = false;
|
||||||
|
|
||||||
|
[Category("DebugTrading")]
|
||||||
|
[DisplayName("Takt (Minuten)")]
|
||||||
|
[Description("Abstand zwischen zwei Läufen. Je Lauf wird höchstens eine Position eröffnet.")]
|
||||||
|
public int IntervalMinutes { get; set; } = 15;
|
||||||
|
|
||||||
|
[Category("DebugTrading")]
|
||||||
|
[DisplayName("Max. offene Testpositionen")]
|
||||||
|
[Description("Obergrenze gleichzeitig offener Positionen dieses Moduls.")]
|
||||||
|
public int MaxOpenPositions { get; set; } = 5;
|
||||||
|
|
||||||
|
[Category("DebugTrading")]
|
||||||
|
[DisplayName("Max. Nominalwert je Trade")]
|
||||||
|
[Description("Obergrenze des Nominalwerts einer einzelnen Testposition, in Kontowährung.")]
|
||||||
|
public double MaxNotionalPerTrade { get; set; } = 1_000;
|
||||||
|
|
||||||
|
[Category("DebugTrading")]
|
||||||
|
[DisplayName("Tagesbudget")]
|
||||||
|
[Description("Summe der an einem Tag eröffneten Nominalwerte. Danach wird bis Mitternacht " +
|
||||||
|
"(Betriebszeitzone) nichts Neues mehr eröffnet.")]
|
||||||
|
public double DailyBudget { get; set; } = 10_000;
|
||||||
|
|
||||||
|
[Category("DebugTrading")]
|
||||||
|
[DisplayName("Haltedauer min. (Minuten)")]
|
||||||
|
[Description("Untergrenze der gewürfelten Haltedauer.")]
|
||||||
|
public int MinHoldMinutes { get; set; } = 10;
|
||||||
|
|
||||||
|
[Category("DebugTrading")]
|
||||||
|
[DisplayName("Haltedauer max. (Minuten)")]
|
||||||
|
[Description("Obergrenze der gewürfelten Haltedauer. Vorgabe 3 Tage - die lange Spanne ist " +
|
||||||
|
"der Zweck: sie prüft, ob offene Positionen Neustarts und Tageswechsel überstehen.")]
|
||||||
|
public int MaxHoldMinutes { get; set; } = 4_320;
|
||||||
|
|
||||||
|
[Category("DebugTrading")]
|
||||||
|
[DisplayName("Prüfuniversum")]
|
||||||
|
[Description("Instrumente, aus denen zufällig gewählt wird - Schreibweise \"SYMBOL:WÄHRUNG\". " +
|
||||||
|
"Bewusst über Währungen gestreut: das prüft Kontraktauflösung, Umrechnung und " +
|
||||||
|
"die Zeitzonenbehandlung der Ausführungszeiten mit.")]
|
||||||
|
public List<string> Universe { get; set; } =
|
||||||
|
["SAP:EUR", "ALV:EUR", "BAS:EUR", "AAPL:USD", "MSFT:USD"];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Accounting ──────────────────────────────────────────────────────────────
|
// ─── Accounting ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[TypeConverter(typeof(ExpandableObjectConverter))]
|
[TypeConverter(typeof(ExpandableObjectConverter))]
|
||||||
@@ -465,6 +530,12 @@ public class AppSettings
|
|||||||
[Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
|
[Description("KI-Analyse/Forensik (OpenRouter, read-only)")]
|
||||||
public SupervisorSettings Supervisor { get; set; } = new();
|
public SupervisorSettings Supervisor { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüfmodul, das absichtlich Last erzeugt (#468). Vorgabe: aus. Es verweigert den Dienst
|
||||||
|
/// ohnehin, sobald der Handelsmodus nicht Paper ist.
|
||||||
|
/// </summary>
|
||||||
|
public DebugTradingSettings DebugTrading { get; set; } = new();
|
||||||
|
|
||||||
[Category("Deploymentcenter")]
|
[Category("Deploymentcenter")]
|
||||||
[DisplayName("Deploymentcenter")]
|
[DisplayName("Deploymentcenter")]
|
||||||
[Description("Lizenz, Watchdog, UpdateService, Fehler-Stream (https://dc.mhdf.de)")]
|
[Description("Lizenz, Watchdog, UpdateService, Fehler-Stream (https://dc.mhdf.de)")]
|
||||||
|
|||||||
@@ -21,3 +21,18 @@ public interface IBrokerPortfolioReader
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<BrokerExecution>> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default);
|
Task<IReadOnlyList<BrokerExecution>> GetExecutionsAsync(DateTime? since = null, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nur Kurse, nichts sonst.
|
||||||
|
///
|
||||||
|
/// <para>Getrennt von <see cref="IBrokerClient"/>, weil dort das Auslösen von Handel wohnt. Ein
|
||||||
|
/// Modul, das einen Kurs braucht, um eine Stückzahl zu rechnen, soll dafür nicht den Zugang
|
||||||
|
/// bekommen, mit dem sich Orders platzieren lassen (Kapitalmodell §2 Regel 4). Die Trennung
|
||||||
|
/// wurde am 2026-09-05 nötig, als das Prüfmodul Kurse brauchte und der Architekturtest den
|
||||||
|
/// Griff nach IBrokerClient zu Recht abgelehnt hat.</para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IQuoteSource
|
||||||
|
{
|
||||||
|
/// <summary>Aktueller Kurs; <c>null</c>, wenn keiner vorliegt.</summary>
|
||||||
|
Task<Quote?> GetQuoteAsync(InstrumentRef instrument, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ namespace IBKRTrader.Core.Trading.Ibkr;
|
|||||||
/// fehlgeschlagene Order). Ein Konto mit Wert 0 lässt die Risikoprüfung jedes Signal ablehnen –
|
/// fehlgeschlagene Order). Ein Konto mit Wert 0 lässt die Risikoprüfung jedes Signal ablehnen –
|
||||||
/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
|
/// die sichere Richtung, wenn der Broker nicht erreichbar ist.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData, IDisposable
|
public sealed class IbkrBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData, IQuoteSource, IDisposable
|
||||||
{
|
{
|
||||||
private const string LogModule = "IBKR";
|
private const string LogModule = "IBKR";
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace IBKRTrader.Core.Trading;
|
|||||||
/// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den
|
/// Wird registriert, bis der echte IBKR-Adapter angebunden und gegen den
|
||||||
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
|
/// Paper-Gateway verifiziert ist. So kann keine Order versehentlich rausgehen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData
|
public sealed class NullBrokerClient : IBrokerClient, IBrokerPortfolioReader, IOptionMarketData, IQuoteSource
|
||||||
{
|
{
|
||||||
private readonly LoggingService _logger;
|
private readonly LoggingService _logger;
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ using IBKRTrader.Core.Workers.BuiltIn;
|
|||||||
using IBKRTrader.Hosting.Deploymentcenter;
|
using IBKRTrader.Hosting.Deploymentcenter;
|
||||||
using IBKRTrader.Modules.Accounting;
|
using IBKRTrader.Modules.Accounting;
|
||||||
using IBKRTrader.Modules.CongressTrading;
|
using IBKRTrader.Modules.CongressTrading;
|
||||||
|
using IBKRTrader.Modules.DebugTrading;
|
||||||
using IBKRTrader.Modules.OptionsWheel;
|
using IBKRTrader.Modules.OptionsWheel;
|
||||||
using IBKRTrader.Modules.Supervisor;
|
using IBKRTrader.Modules.Supervisor;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -44,6 +45,11 @@ public static class AppHostBuilder
|
|||||||
[
|
[
|
||||||
new CongressTradingModule(),
|
new CongressTradingModule(),
|
||||||
new OptionsWheelModule(),
|
new OptionsWheelModule(),
|
||||||
|
|
||||||
|
// Pruefmodul (#468). Es verweigert den Dienst, sobald der Handelsmodus nicht Paper ist,
|
||||||
|
// und ist per Vorgabe abgeschaltet - registriert wird es trotzdem immer, damit sein
|
||||||
|
// Zustand in der Workers-Ansicht sichtbar bleibt statt unsichtbar zu fehlen.
|
||||||
|
new DebugTradingModule(),
|
||||||
new AccountingModule(),
|
new AccountingModule(),
|
||||||
new SupervisorModule()
|
new SupervisorModule()
|
||||||
];
|
];
|
||||||
@@ -266,6 +272,7 @@ public static class AppHostBuilder
|
|||||||
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||||
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||||
|
services.AddSingleton<IQuoteSource>(sp => sp.GetRequiredService<IbkrBrokerClient>());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -273,6 +280,7 @@ public static class AppHostBuilder
|
|||||||
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<NullBrokerClient>());
|
services.AddSingleton<IBrokerClient>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||||
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
|
services.AddSingleton<IBrokerPortfolioReader>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||||
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<NullBrokerClient>());
|
services.AddSingleton<IOptionMarketData>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||||
|
services.AddSingleton<IQuoteSource>(sp => sp.GetRequiredService<NullBrokerClient>());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core-Worker/Services
|
// Core-Worker/Services
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
Bugtracker. Interimslösung, solange kein NuGet-Paket in der Gitea-Registry liegt - siehe
|
Bugtracker. Interimslösung, solange kein NuGet-Paket in der Gitea-Registry liegt - siehe
|
||||||
docs/archiv/KONZEPT-Deploymentcenter-Integration.md §2.2. -->
|
docs/archiv/KONZEPT-Deploymentcenter-Integration.md §2.2. -->
|
||||||
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
<ProjectReference Include="..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj" />
|
||||||
|
<ProjectReference Include="..\IBKRTrader.Modules.DebugTrading\IBKRTrader.Modules.DebugTrading.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -39,8 +40,7 @@
|
|||||||
<!-- Ohne das Schwester-Repo scheitert der Build sonst mit einer Meldung ueber eine fehlende
|
<!-- Ohne das Schwester-Repo scheitert der Build sonst mit einer Meldung ueber eine fehlende
|
||||||
Projektdatei, aus der niemand die eigentliche Ursache ablesen kann. -->
|
Projektdatei, aus der niemand die eigentliche Ursache ablesen kann. -->
|
||||||
<Target Name="CheckDeploymentcenterCheckout" BeforeTargets="PrepareForBuild">
|
<Target Name="CheckDeploymentcenterCheckout" BeforeTargets="PrepareForBuild">
|
||||||
<Error Condition="!Exists('$(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj')"
|
<Error Condition="!Exists('$(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter\client-dotnet\Deploymentcenter.Client\Deploymentcenter.Client.csproj')" Text="Das Schwester-Repo 'Deploymentcenter' wurde nicht gefunden. Es muss neben dem IBKRTrader-Checkout liegen (erwartet: $(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter). Klonen von http://192.168.178.10:8418/Richard/Deploymentcenter.git" />
|
||||||
Text="Das Schwester-Repo 'Deploymentcenter' wurde nicht gefunden. Es muss neben dem IBKRTrader-Checkout liegen (erwartet: $(MSBuildThisFileDirectory)..\..\..\..\Deploymentcenter). Klonen von http://192.168.178.10:8418/Richard/Deploymentcenter.git" />
|
|
||||||
</Target>
|
</Target>
|
||||||
|
|
||||||
<!-- Erzeugt IBKRTrader.Hosting.BuildInfo (Version, Git-Commit, Build-Datum, Kanal) zur
|
<!-- Erzeugt IBKRTrader.Hosting.BuildInfo (Version, Git-Commit, Build-Datum, Kanal) zur
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using IBKRTrader.Core.Configuration;
|
||||||
|
using IBKRTrader.Core.Logging;
|
||||||
|
using IBKRTrader.Core.Modularity;
|
||||||
|
using IBKRTrader.Core.Settings;
|
||||||
|
using IBKRTrader.Core.Trading;
|
||||||
|
using IBKRTrader.Core.Workers;
|
||||||
|
using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
|
||||||
|
using IBKRTrader.Modules.DebugTrading.Workers;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.DebugTrading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DebugTrading: erzeugt absichtlich kleine Trades, um die Buchungskette unter Last zu prüfen.
|
||||||
|
///
|
||||||
|
/// <para><b>Es ist kein Strategiemodul und darf nie eines werden.</b> Es soll nicht Geld
|
||||||
|
/// verdienen, sondern Last erzeugen – Auswahl und Zeitpunkte sind zufällig. Gemessen wird damit,
|
||||||
|
/// was sonst nur durch Unit-Tests mit gemockten Gegenstellen belegt ist: greifen Modul-Limits und
|
||||||
|
/// Konzentration wirklich? Werden Reservierungen nach einem Neustart korrekt wiederhergestellt?
|
||||||
|
/// Erkennt der Abgleich eine von Hand angelegte Position? Stimmen Buchungszeiten über den
|
||||||
|
/// Tageswechsel?</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Die Sicherung, die nicht verhandelbar ist:</b> Der Worker verweigert bei jedem Lauf
|
||||||
|
/// den Dienst, wenn der Handelsmodus nicht eindeutig Paper ist – nicht als Einstellung, sondern
|
||||||
|
/// als Bedingung. Einstellungen werden kopiert, wenn eine Instanz umzieht; diese Prüfung nicht.
|
||||||
|
/// Dazu ein eigenes Tagesbudget, eine Obergrenze gleichzeitig offener Positionen und ein Not-Aus,
|
||||||
|
/// der das Eröffnen stoppt und offene Positionen trotzdem weiter betreut.</para>
|
||||||
|
///
|
||||||
|
/// <para>Die Testpositionen liegen in <c>dbg_position</c> und überleben Neustarts – die gewürfelte
|
||||||
|
/// Haltedauer reicht bis zu mehreren Tagen, und genau das ist der Zweck.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DebugTradingModule : IModule
|
||||||
|
{
|
||||||
|
/// <summary>Kürzel für Logging, Buchname und DB-Präfix.</summary>
|
||||||
|
public const string LogTag = "DBG";
|
||||||
|
|
||||||
|
public string Name => "DebugTrading";
|
||||||
|
public string DbPrefix => "dbg_";
|
||||||
|
|
||||||
|
public void RegisterServices(IServiceCollection services, IConfiguration configuration) =>
|
||||||
|
Register(services, execution: null);
|
||||||
|
|
||||||
|
public void RegisterServices(IServiceCollection services, IConfiguration configuration,
|
||||||
|
ModuleExecutionFactory execution) =>
|
||||||
|
Register(services, execution);
|
||||||
|
|
||||||
|
private void Register(IServiceCollection services, ModuleExecutionFactory? execution)
|
||||||
|
{
|
||||||
|
services.AddDbContextFactory<DebugTradingDbContext>((sp, o) =>
|
||||||
|
o.UseMySql(sp.GetRequiredService<DatabaseEndpoint>().ConnectionString,
|
||||||
|
DatabaseServerVersion.Value));
|
||||||
|
|
||||||
|
// Der Worker bekommt den an das Buch DBG gebundenen Ausführungszugang - nicht den
|
||||||
|
// gemeinsamen IExecutionService (Kapitalmodell §2 Regel 3).
|
||||||
|
services.AddSingleton(sp => new DebugTradingWorker(
|
||||||
|
sp.GetRequiredService<LoggingService>(),
|
||||||
|
sp.GetRequiredService<IDbContextFactory<Core.Persistence.Ef.CoreDbContext>>(),
|
||||||
|
sp.GetRequiredService<IDbContextFactory<DebugTradingDbContext>>(),
|
||||||
|
execution?.Create(sp) ?? throw new InvalidOperationException(
|
||||||
|
"DebugTrading braucht den gebundenen Ausführungszugang des Hosts."),
|
||||||
|
sp.GetRequiredService<IQuoteSource>(),
|
||||||
|
sp.GetRequiredService<SettingsService>()));
|
||||||
|
|
||||||
|
services.AddSingleton<IWorker>(sp => sp.GetRequiredService<DebugTradingWorker>());
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<DebugTradingWorker>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bewusst leer: Das Modulprojekt trägt keinen UI-Code, damit es plattformneutral bleibt.
|
||||||
|
/// </summary>
|
||||||
|
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<!-- Pruefmodul (#468): erzeugt Last auf der Buchungskette, ausschliesslich im Paper-Modus.
|
||||||
|
Plattformneutral und ohne UI-Code wie jedes Modul. -->
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\IBKRTrader.Core\IBKRTrader.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Erlaubt dem Testprojekt, interne Service-Methoden zu testen. -->
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||||
|
<_Parameter1>IBKRTrader.Tests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.DebugTrading.Logic;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Die Sicherungen des Prüfmoduls – reine Rechnung, damit sie ohne Broker und ohne Datenbank
|
||||||
|
/// prüfbar sind.
|
||||||
|
///
|
||||||
|
/// <para><b>Die erste ist nicht verhandelbar:</b> Das Modul arbeitet ausschließlich im
|
||||||
|
/// Paper-Modus. Nicht als Einstellung, die jemand umlegen kann, sondern als Bedingung, die bei
|
||||||
|
/// jedem Lauf neu geprüft wird. Ein Testmodul, das auf einem Live-Konto anläuft, wäre der
|
||||||
|
/// teuerste denkbare Fehler dieses Projekts – und Einstellungen werden kopiert, wenn eine
|
||||||
|
/// Instanz von der Test- auf die Live-Maschine umzieht.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class DebugTradingGuard
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft den Handelsmodus. Liefert <c>null</c>, wenn gearbeitet werden darf, sonst den Grund.
|
||||||
|
///
|
||||||
|
/// <para>Bewusst gegen den geparsten Modus und nicht gegen die Zeichenkette: Ein unbekannter
|
||||||
|
/// Wert fällt in <c>TradingSettings.ParsedMode</c> auf Paper zurück, und genau darauf will
|
||||||
|
/// sich diese Prüfung nicht verlassen. Sie sieht den Rohwert mit an.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static string? CheckPaperMode(TradingMode mode, string rawMode)
|
||||||
|
{
|
||||||
|
if (mode != TradingMode.Paper)
|
||||||
|
return $"Handelsmodus ist {mode} – das Prüfmodul arbeitet ausschließlich im Paper-Modus.";
|
||||||
|
|
||||||
|
// Der Rohwert muss ebenfalls eindeutig Paper sagen. "Papier", "paper-live" oder ein
|
||||||
|
// Tippfehler ergeben sonst über den Rückfall ein stilles Ja.
|
||||||
|
if (!string.Equals(rawMode?.Trim(), nameof(TradingMode.Paper), StringComparison.OrdinalIgnoreCase))
|
||||||
|
return $"Handelsmodus \"{rawMode}\" ist nicht eindeutig Paper – das Prüfmodul bleibt stehen.";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft, ob eine neue Position eröffnet werden darf. <c>null</c> = ja.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="openPositions">Aktuell offene Testpositionen.</param>
|
||||||
|
/// <param name="maxOpen">Obergrenze.</param>
|
||||||
|
/// <param name="spentToday">Heute eröffneter Nominalwert.</param>
|
||||||
|
/// <param name="dailyBudget">Tagesbudget.</param>
|
||||||
|
/// <param name="plannedNotional">Nominalwert der geplanten Position.</param>
|
||||||
|
/// <param name="maxPerTrade">Obergrenze je Position.</param>
|
||||||
|
public static string? CheckOpening(
|
||||||
|
int openPositions, int maxOpen,
|
||||||
|
decimal spentToday, decimal dailyBudget,
|
||||||
|
decimal plannedNotional, decimal maxPerTrade)
|
||||||
|
{
|
||||||
|
if (openPositions >= maxOpen)
|
||||||
|
return $"Bereits {openPositions} offene Testpositionen (Grenze {maxOpen}).";
|
||||||
|
|
||||||
|
if (plannedNotional > maxPerTrade)
|
||||||
|
return $"Nominalwert {plannedNotional:F0} über der Grenze je Trade ({maxPerTrade:F0}).";
|
||||||
|
|
||||||
|
if (spentToday + plannedNotional > dailyBudget)
|
||||||
|
return $"Tagesbudget erschöpft: heute {spentToday:F0}, geplant {plannedNotional:F0}, " +
|
||||||
|
$"Budget {dailyBudget:F0}.";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using IBKRTrader.Core.Settings;
|
||||||
|
using IBKRTrader.Core.Trading;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.DebugTrading.Logic;
|
||||||
|
|
||||||
|
/// <summary>Eine offene Testposition, so weit die Planung sie kennen muss.</summary>
|
||||||
|
/// <param name="Instrument">Gehaltenes Instrument.</param>
|
||||||
|
/// <param name="Quantity">Stückzahl, immer positiv – das Modul geht nur long.</param>
|
||||||
|
/// <param name="CloseAtUtc">Zeitpunkt, zu dem sie geschlossen werden soll.</param>
|
||||||
|
public sealed record OpenTestPosition(InstrumentRef Instrument, int Quantity, DateTime CloseAtUtc);
|
||||||
|
|
||||||
|
/// <summary>Was das Modul in diesem Lauf tun soll.</summary>
|
||||||
|
public abstract record DebugAction
|
||||||
|
{
|
||||||
|
/// <summary>Eine fällige Position schließen.</summary>
|
||||||
|
public sealed record Close(OpenTestPosition Position, string Reason) : DebugAction;
|
||||||
|
|
||||||
|
/// <summary>Eine neue Position eröffnen und bis <paramref name="CloseAtUtc"/> halten.</summary>
|
||||||
|
public sealed record Open(InstrumentRef Instrument, int Quantity, DateTime CloseAtUtc) : DebugAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entscheidet, was das Prüfmodul als Nächstes tut – <b>reine Rechnung</b>, ohne Broker, ohne
|
||||||
|
/// Datenbank und mit einer von außen gereichten Zufallsquelle. Genau deshalb ist der gesamte
|
||||||
|
/// Ablauf eines Moduls, das zufällig handelt, überhaupt prüfbar.
|
||||||
|
///
|
||||||
|
/// <para><b>Schließen hat Vorrang vor Eröffnen.</b> Läuft das Modul in eine Grenze, soll es
|
||||||
|
/// zuerst aufräumen. Andernfalls könnte eine erreichte Obergrenze verhindern, dass fällige
|
||||||
|
/// Positionen je geschlossen werden – die Sperre bliebe für immer.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class DebugTradingPlan
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plant den nächsten Lauf.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="open">Aktuell offene Testpositionen.</param>
|
||||||
|
/// <param name="nowUtc">Jetzt.</param>
|
||||||
|
/// <param name="spentToday">Heute bereits eröffneter Nominalwert.</param>
|
||||||
|
/// <param name="priceOf">
|
||||||
|
/// Kurs je Instrument; <c>null</c>, wenn keiner vorliegt. Ohne Kurs wird nicht eröffnet – eine
|
||||||
|
/// Stückzahl ohne Preis wäre geraten.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="random">Zufallsquelle; in Tests fest vorgegeben.</param>
|
||||||
|
public static IReadOnlyList<DebugAction> Plan(
|
||||||
|
IReadOnlyList<OpenTestPosition> open,
|
||||||
|
DateTime nowUtc,
|
||||||
|
decimal spentToday,
|
||||||
|
DebugTradingSettings settings,
|
||||||
|
Func<InstrumentRef, decimal?> priceOf,
|
||||||
|
Random random)
|
||||||
|
{
|
||||||
|
var actions = new List<DebugAction>();
|
||||||
|
|
||||||
|
// 1. Fällige Positionen schließen - immer und unabhängig von allen Grenzen.
|
||||||
|
foreach (var position in open.Where(p => p.CloseAtUtc <= nowUtc))
|
||||||
|
actions.Add(new DebugAction.Close(position,
|
||||||
|
$"Haltedauer abgelaufen ({position.CloseAtUtc:yyyy-MM-dd HH:mm} UTC)."));
|
||||||
|
|
||||||
|
// 2. Eröffnen nur, wenn alle Grenzen es zulassen.
|
||||||
|
if (settings.StopOpening) return actions;
|
||||||
|
|
||||||
|
var stillOpen = open.Count - actions.Count;
|
||||||
|
if (stillOpen >= settings.MaxOpenPositions) return actions;
|
||||||
|
if (spentToday >= (decimal)settings.DailyBudget) return actions;
|
||||||
|
|
||||||
|
var universe = ParseUniverse(settings.Universe);
|
||||||
|
if (universe.Count == 0) return actions;
|
||||||
|
|
||||||
|
// Reihenfolge würfeln und den ersten nehmen, für den es einen brauchbaren Kurs gibt.
|
||||||
|
// Ein fehlender Kurs ist der Normalfall (geschlossene Börse, fehlendes Abo) und darf den
|
||||||
|
// Lauf nicht leer ausgehen lassen, solange ein anderer Wert handelbar ist.
|
||||||
|
foreach (var instrument in universe.OrderBy(_ => random.Next()))
|
||||||
|
{
|
||||||
|
if (priceOf(instrument) is not { } price || price <= 0m) continue;
|
||||||
|
|
||||||
|
var budgetLeft = Math.Min(
|
||||||
|
(decimal)settings.MaxNotionalPerTrade,
|
||||||
|
(decimal)settings.DailyBudget - spentToday);
|
||||||
|
|
||||||
|
var quantity = (int)Math.Floor(budgetLeft / price);
|
||||||
|
if (quantity < 1) continue; // zu teuer für die Grenze - der nächste Wert ist dran
|
||||||
|
|
||||||
|
var hold = TimeSpan.FromMinutes(random.Next(
|
||||||
|
Math.Max(1, settings.MinHoldMinutes),
|
||||||
|
Math.Max(2, settings.MaxHoldMinutes)));
|
||||||
|
|
||||||
|
actions.Add(new DebugAction.Open(instrument, quantity, nowUtc.Add(hold)));
|
||||||
|
break; // hoechstens eine Eroeffnung je Lauf
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Liest das Prüfuniversum. Ein unlesbarer Eintrag wird übersprungen, nicht geraten – ein
|
||||||
|
/// falsch gedeutetes Symbol wäre ein Handel in einem Papier, das niemand gemeint hat.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<InstrumentRef> ParseUniverse(IEnumerable<string> entries)
|
||||||
|
{
|
||||||
|
var result = new List<InstrumentRef>();
|
||||||
|
|
||||||
|
foreach (var raw in entries)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) continue;
|
||||||
|
|
||||||
|
var parts = raw.Split(':', StringSplitOptions.TrimEntries);
|
||||||
|
var symbol = parts[0];
|
||||||
|
var currency = parts.Length > 1 ? parts[1] : Currencies.Default;
|
||||||
|
|
||||||
|
if (symbol.Length == 0 || !Currencies.IsValid(currency)) continue;
|
||||||
|
|
||||||
|
result.Add(InstrumentRef.ForStock(symbol, currency));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace IBKRTrader.Modules.DebugTrading.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eine offene Testposition (<c>dbg_position</c>).
|
||||||
|
///
|
||||||
|
/// <para><b>Warum persistent.</b> Die gewürfelte Haltedauer reicht bis zu mehreren Tagen - genau
|
||||||
|
/// das ist der Zweck des Moduls. Läge der Plan nur im Speicher, verlöre jeder Neustart die
|
||||||
|
/// Absicht, und die Position bliebe für immer offen. Sie ist echt; sie verschwindet nicht mit dem
|
||||||
|
/// Prozess.</para>
|
||||||
|
/// </summary>
|
||||||
|
public class DebugPosition
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Instrumentenschlüssel, wie ihn der Core führt ("SAP:EUR").</summary>
|
||||||
|
public string ContractKey { get; set; } = "";
|
||||||
|
|
||||||
|
public string Symbol { get; set; } = "";
|
||||||
|
public string Currency { get; set; } = "";
|
||||||
|
public int Quantity { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Kurs beim Eröffnen - nur zur Nachvollziehbarkeit, gebucht wird im Core.</summary>
|
||||||
|
public decimal OpenPrice { get; set; }
|
||||||
|
|
||||||
|
public DateTime OpenedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Geplanter Schließzeitpunkt (UTC). Der Worker schließt ab hier.</summary>
|
||||||
|
public DateTime CloseAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gesetzt, sobald geschlossen wurde - die Zeile bleibt als Spur stehen.</summary>
|
||||||
|
public DateTime? ClosedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Signal-ID der Eröffnung, für die Verkettung mit Journal und Trade-Historie.</summary>
|
||||||
|
public string? OpenSignalId { get; set; }
|
||||||
|
|
||||||
|
public string? Note { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using IBKRTrader.Modules.DebugTrading.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef;
|
||||||
|
|
||||||
|
/// <summary>Persistenz des Prüfmoduls: die <c>dbg_</c>-Tabellen in derselben Datenbank.</summary>
|
||||||
|
public class DebugTradingDbContext : DbContext
|
||||||
|
{
|
||||||
|
public DebugTradingDbContext(DbContextOptions<DebugTradingDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<DebugPosition> Positions => Set<DebugPosition>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
|
{
|
||||||
|
b.Entity<DebugPosition>(e =>
|
||||||
|
{
|
||||||
|
e.ToTable("dbg_position");
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.Property(x => x.ContractKey).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Symbol).HasMaxLength(32);
|
||||||
|
e.Property(x => x.Currency).HasMaxLength(8);
|
||||||
|
e.Property(x => x.OpenPrice).HasPrecision(18, 4);
|
||||||
|
e.Property(x => x.OpenSignalId).HasMaxLength(64);
|
||||||
|
e.Property(x => x.Note).HasMaxLength(255);
|
||||||
|
|
||||||
|
// Der Worker fragt bei jedem Lauf nach den offenen Zeilen.
|
||||||
|
e.HasIndex(x => x.ClosedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
base.OnModelCreating(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using IBKRTrader.Core.Configuration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Design-Time-Factory für das EF-Tooling (<c>dotnet ef</c>). Die Verbindung kommt aus der
|
||||||
|
/// Umgebungsvariablen <c>IBKRTRADER_MYSQL</c>; im Betrieb wird sie nie benutzt – dort speist
|
||||||
|
/// <see cref="DatabaseEndpoint"/> den Kontext.
|
||||||
|
/// </summary>
|
||||||
|
public class DebugTradingDbContextFactory : IDesignTimeDbContextFactory<DebugTradingDbContext>
|
||||||
|
{
|
||||||
|
public DebugTradingDbContext CreateDbContext(string[] args)
|
||||||
|
{
|
||||||
|
// Der Platzhalter meldet sich absichtlich nicht als root an: Eine fehlende Konfiguration
|
||||||
|
// soll als solche zu erkennen sein und nicht wie ein Rechteproblem aussehen (#487).
|
||||||
|
var conn = Environment.GetEnvironmentVariable("IBKRTRADER_MYSQL")
|
||||||
|
?? "Server=localhost;Port=3306;Database=ibkrtrader;User ID=KEINE-DATENBANK-KONFIGURIERT;Password=;";
|
||||||
|
|
||||||
|
var options = new DbContextOptionsBuilder<DebugTradingDbContext>()
|
||||||
|
.UseMySql(conn, DatabaseServerVersion.Value)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new DebugTradingDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(DebugTradingDbContext))]
|
||||||
|
[Migration("20260905122318_DebugTradingPositionen")]
|
||||||
|
partial class DebugTradingPositionen
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.13")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("IBKRTrader.Modules.DebugTrading.Models.DebugPosition", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CloseAtUtc")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("ContractKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("varchar(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("varchar(255)");
|
||||||
|
|
||||||
|
b.Property<decimal>("OpenPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("OpenSignalId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Quantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ClosedAt");
|
||||||
|
|
||||||
|
b.ToTable("dbg_position", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class DebugTradingPositionen : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "dbg_position",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
|
||||||
|
ContractKey = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Symbol = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Currency = table.Column<string>(type: "varchar(8)", maxLength: 8, nullable: false)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Quantity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
OpenPrice = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: false),
|
||||||
|
OpenedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CloseAtUtc = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
OpenSignalId = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4"),
|
||||||
|
Note = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: true)
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_dbg_position", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySql:CharSet", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_dbg_position_ClosedAt",
|
||||||
|
table: "dbg_position",
|
||||||
|
column: "ClosedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "dbg_position");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.DebugTrading.Persistence.Ef.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(DebugTradingDbContext))]
|
||||||
|
partial class DebugTradingDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "8.0.13")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 64);
|
||||||
|
|
||||||
|
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("IBKRTrader.Modules.DebugTrading.Models.DebugPosition", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CloseAtUtc")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClosedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<string>("ContractKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("varchar(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Note")
|
||||||
|
.HasMaxLength(255)
|
||||||
|
.HasColumnType("varchar(255)");
|
||||||
|
|
||||||
|
b.Property<decimal>("OpenPrice")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("decimal(18,4)");
|
||||||
|
|
||||||
|
b.Property<string>("OpenSignalId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("varchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("OpenedAt")
|
||||||
|
.HasColumnType("datetime(6)");
|
||||||
|
|
||||||
|
b.Property<int>("Quantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Symbol")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("varchar(32)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ClosedAt");
|
||||||
|
|
||||||
|
b.ToTable("dbg_position", (string)null);
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
using IBKRTrader.Core.Logging;
|
||||||
|
using IBKRTrader.Core.Persistence.Ef;
|
||||||
|
using IBKRTrader.Core.Settings;
|
||||||
|
using IBKRTrader.Core.Time;
|
||||||
|
using IBKRTrader.Core.Trading;
|
||||||
|
using IBKRTrader.Core.Workers;
|
||||||
|
using IBKRTrader.Modules.DebugTrading.Logic;
|
||||||
|
using IBKRTrader.Modules.DebugTrading.Models;
|
||||||
|
using IBKRTrader.Modules.DebugTrading.Persistence.Ef;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Modules.DebugTrading.Workers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Der Taktgeber des Prüfmoduls: schließt fällige Testpositionen und eröffnet gelegentlich eine
|
||||||
|
/// neue.
|
||||||
|
///
|
||||||
|
/// <para><b>Was dieses Modul soll und was nicht.</b> Es soll Last erzeugen, keine Rendite. Die
|
||||||
|
/// Auswahl ist zufällig und ausdrücklich nicht renditegetrieben – wer hier eine Strategie
|
||||||
|
/// hineinbaut, macht aus dem Prüfwerkzeug ein Handelsmodul mit unklarer Absicht.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Die Reihenfolge ist Absicht:</b> zuerst schließen, dann eröffnen. Sonst könnte eine
|
||||||
|
/// erreichte Obergrenze verhindern, dass fällige Positionen je geschlossen werden.</para>
|
||||||
|
/// </summary>
|
||||||
|
public class DebugTradingWorker : WorkerBase
|
||||||
|
{
|
||||||
|
private readonly IModuleExecution _execution;
|
||||||
|
private readonly IQuoteSource _quotes;
|
||||||
|
private readonly SettingsService _settings;
|
||||||
|
private readonly IDbContextFactory<DebugTradingDbContext> _own;
|
||||||
|
private readonly Random _random = new();
|
||||||
|
|
||||||
|
public override string Name => "DBG-TradeGenerator";
|
||||||
|
public override string Module => DebugTradingModule.LogTag;
|
||||||
|
public override WorkerType Type => WorkerType.Worker;
|
||||||
|
|
||||||
|
private DebugTradingSettings Config => _settings.Settings.DebugTrading;
|
||||||
|
|
||||||
|
protected override TimeSpan? Interval => TimeSpan.FromMinutes(Math.Max(1, Config.IntervalMinutes));
|
||||||
|
|
||||||
|
public DebugTradingWorker(
|
||||||
|
LoggingService logger,
|
||||||
|
IDbContextFactory<CoreDbContext> coreDb,
|
||||||
|
IDbContextFactory<DebugTradingDbContext> own,
|
||||||
|
IModuleExecution execution,
|
||||||
|
IQuoteSource quotes,
|
||||||
|
SettingsService settings)
|
||||||
|
: base(logger, coreDb)
|
||||||
|
{
|
||||||
|
_own = own;
|
||||||
|
_execution = execution;
|
||||||
|
_quotes = quotes;
|
||||||
|
_settings = settings;
|
||||||
|
Info.Active = settings.Settings.DebugTrading.Enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Die Modusprüfung bei JEDEM Lauf, nicht nur beim Start: Einstellungen werden kopiert,
|
||||||
|
// wenn eine Instanz von der Test- auf eine andere Maschine umzieht, und sie lassen sich
|
||||||
|
// im laufenden Betrieb ändern. Ein Fehlschlag ist hier das richtige Ergebnis - er ist in
|
||||||
|
// der Workers-Ansicht rot und im Log ein Fehler, statt still nichts zu tun.
|
||||||
|
var trading = _settings.Settings.Trading;
|
||||||
|
if (DebugTradingGuard.CheckPaperMode(trading.ParsedMode, trading.Mode) is { } blocked)
|
||||||
|
throw new InvalidOperationException($"DebugTrading verweigert den Dienst: {blocked}");
|
||||||
|
|
||||||
|
var open = await LoadOpenAsync(ct);
|
||||||
|
var spentToday = await SpentTodayAsync(ct);
|
||||||
|
var quotes = new Dictionary<string, decimal?>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var actions = DebugTradingPlan.Plan(
|
||||||
|
[.. open.Select(ToPlanned)],
|
||||||
|
DateTime.UtcNow, spentToday, Config,
|
||||||
|
instrument => QuoteOf(instrument, quotes, ct),
|
||||||
|
_random);
|
||||||
|
|
||||||
|
if (actions.Count == 0)
|
||||||
|
{
|
||||||
|
Info.Info = $"{open.Count} offen, heute {spentToday:F0} eröffnet – nichts zu tun.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var closed = 0;
|
||||||
|
var opened = 0;
|
||||||
|
|
||||||
|
foreach (var action in actions)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case DebugAction.Close close:
|
||||||
|
if (await CloseAsync(open, close, ct)) closed++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DebugAction.Open openAction:
|
||||||
|
if (await OpenAsync(openAction, open.Count - closed, spentToday, quotes, ct)) opened++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Info.Info = $"{closed} geschlossen, {opened} eröffnet ({open.Count - closed + opened} offen).";
|
||||||
|
Logger.Info(Module, $"Prüflauf: {Info.Info}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Eröffnen und Schließen ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<bool> OpenAsync(
|
||||||
|
DebugAction.Open action, int openCount, decimal spentToday,
|
||||||
|
Dictionary<string, decimal?> quotes, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var price = QuoteOf(action.Instrument, quotes, ct);
|
||||||
|
if (price is not { } unit || unit <= 0m) return false;
|
||||||
|
|
||||||
|
var notional = action.Quantity * unit;
|
||||||
|
|
||||||
|
// Zweite Verteidigungslinie, nach der Planung: Die Grenzen werden hier noch einmal gegen
|
||||||
|
// die tatsächlichen Zahlen geprüft, nicht gegen die geplanten.
|
||||||
|
if (DebugTradingGuard.CheckOpening(
|
||||||
|
openCount, Config.MaxOpenPositions, spentToday, (decimal)Config.DailyBudget,
|
||||||
|
notional, (decimal)Config.MaxNotionalPerTrade) is { } stop)
|
||||||
|
{
|
||||||
|
Logger.Info(Module, $"Keine Eröffnung: {stop}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var signal = new TradeSignal
|
||||||
|
{
|
||||||
|
Symbol = action.Instrument.Symbol,
|
||||||
|
Currency = action.Instrument.Currency,
|
||||||
|
Side = TradeSide.Buy,
|
||||||
|
SourceModule = DebugTradingModule.LogTag,
|
||||||
|
SuggestedQuantity = action.Quantity,
|
||||||
|
Reason = $"Prüflast: {action.Quantity} Stück, Haltedauer bis {action.CloseAtUtc:yyyy-MM-dd HH:mm} UTC"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _execution.ExecuteAsync(signal, ct);
|
||||||
|
if (!result.Executed)
|
||||||
|
{
|
||||||
|
// Eine Ablehnung ist hier kein Fehler, sondern der halbe Zweck des Moduls: Sie zeigt,
|
||||||
|
// dass Limits, Deckung und Sperren greifen. Sie gehört sichtbar ins Log.
|
||||||
|
Logger.Info(Module, $"Eröffnung abgelehnt ({action.Instrument.Key}): {result.Reason}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await _own.CreateDbContextAsync(ct);
|
||||||
|
db.Positions.Add(new DebugPosition
|
||||||
|
{
|
||||||
|
ContractKey = action.Instrument.Key,
|
||||||
|
Symbol = action.Instrument.Symbol,
|
||||||
|
Currency = action.Instrument.Currency,
|
||||||
|
Quantity = action.Quantity,
|
||||||
|
OpenPrice = unit,
|
||||||
|
OpenedAt = DateTime.UtcNow,
|
||||||
|
CloseAtUtc = action.CloseAtUtc,
|
||||||
|
OpenSignalId = signal.SignalId,
|
||||||
|
Note = "Prüflast"
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
Logger.Info(Module,
|
||||||
|
$"Eröffnet: {action.Quantity}x {action.Instrument.Key} zu {unit:F2} " +
|
||||||
|
$"({notional:F0} {action.Instrument.Currency}), hält bis {action.CloseAtUtc:yyyy-MM-dd HH:mm} UTC.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> CloseAsync(
|
||||||
|
IReadOnlyList<DebugPosition> open, DebugAction.Close action, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = open.FirstOrDefault(p => p.ContractKey == action.Position.Instrument.Key
|
||||||
|
&& p.ClosedAt is null);
|
||||||
|
if (row is null) return false;
|
||||||
|
|
||||||
|
var signal = new TradeSignal
|
||||||
|
{
|
||||||
|
Symbol = action.Position.Instrument.Symbol,
|
||||||
|
Currency = action.Position.Instrument.Currency,
|
||||||
|
Side = TradeSide.Sell,
|
||||||
|
SourceModule = DebugTradingModule.LogTag,
|
||||||
|
SuggestedQuantity = row.Quantity,
|
||||||
|
Reason = $"Prüflast schließen: {action.Reason}"
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _execution.ExecuteAsync(signal, ct);
|
||||||
|
if (!result.Executed)
|
||||||
|
{
|
||||||
|
// Wichtig: Die Zeile bleibt offen. Beim nächsten Lauf wird es erneut versucht - eine
|
||||||
|
// Position, die wir für geschlossen halten, obwohl sie es nicht ist, wäre schlimmer
|
||||||
|
// als ein weiterer Versuch.
|
||||||
|
Logger.Warn(Module, $"Schließen abgelehnt ({row.ContractKey}): {result.Reason}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var db = await _own.CreateDbContextAsync(ct);
|
||||||
|
var tracked = await db.Positions.FindAsync([row.Id], ct);
|
||||||
|
if (tracked is not null)
|
||||||
|
{
|
||||||
|
tracked.ClosedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.Info(Module, $"Geschlossen: {row.Quantity}x {row.ContractKey} – {action.Reason}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Beschaffung ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<DebugPosition>> LoadOpenAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var db = await _own.CreateDbContextAsync(ct);
|
||||||
|
return await db.Positions.Where(p => p.ClosedAt == null).ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Heute eröffneter Nominalwert – Tagesgrenze in der <b>Betriebszeitzone</b>, nicht in UTC.
|
||||||
|
/// Sonst läge der Schnitt für eine EU-Instanz mitten am Nachmittag.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<decimal> SpentTodayAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var startLocal = AppTimeZone.Now.Date;
|
||||||
|
var startUtc = TimeZoneInfo.ConvertTimeToUtc(startLocal, AppTimeZone.Current);
|
||||||
|
|
||||||
|
await using var db = await _own.CreateDbContextAsync(ct);
|
||||||
|
return await db.Positions
|
||||||
|
.Where(p => p.OpenedAt >= startUtc)
|
||||||
|
.SumAsync(p => p.Quantity * p.OpenPrice, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OpenTestPosition ToPlanned(DebugPosition row) =>
|
||||||
|
new(InstrumentRef.TryParse(row.ContractKey, out var instrument)
|
||||||
|
? instrument
|
||||||
|
: InstrumentRef.ForStock(row.Symbol, row.Currency),
|
||||||
|
row.Quantity, row.CloseAtUtc);
|
||||||
|
|
||||||
|
/// <summary>Kurs mit Zwischenspeicher je Lauf – dieselbe Abfrage zweimal wäre reine Last.</summary>
|
||||||
|
private decimal? QuoteOf(
|
||||||
|
InstrumentRef instrument, Dictionary<string, decimal?> cache, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (cache.TryGetValue(instrument.Key, out var cached)) return cached;
|
||||||
|
|
||||||
|
var quote = _quotes.GetQuoteAsync(instrument, ct).GetAwaiter().GetResult();
|
||||||
|
decimal? price = quote is { Last: > 0 } ? quote.Last : null;
|
||||||
|
|
||||||
|
cache[instrument.Key] = price;
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<ProjectReference Include="..\..\src\IBKRTrader.Modules.OptionsWheel\IBKRTrader.Modules.OptionsWheel.csproj" />
|
<ProjectReference Include="..\..\src\IBKRTrader.Modules.OptionsWheel\IBKRTrader.Modules.OptionsWheel.csproj" />
|
||||||
<ProjectReference Include="..\..\src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
|
<ProjectReference Include="..\..\src\IBKRTrader.Modules.Accounting\IBKRTrader.Modules.Accounting.csproj" />
|
||||||
<ProjectReference Include="..\..\src\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
|
<ProjectReference Include="..\..\src\IBKRTrader.Modules.Supervisor\IBKRTrader.Modules.Supervisor.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\IBKRTrader.Modules.DebugTrading\IBKRTrader.Modules.DebugTrading.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using FluentAssertions;
|
||||||
|
using IBKRTrader.Core.Settings;
|
||||||
|
using IBKRTrader.Core.Trading;
|
||||||
|
using IBKRTrader.Modules.DebugTrading.Logic;
|
||||||
|
|
||||||
|
namespace IBKRTrader.Tests.Modules.DebugTrading;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Planung und Sicherungen des Prüfmoduls (#468). Beides ist reine Rechnung mit einer von außen
|
||||||
|
/// gereichten Zufallsquelle – genau deshalb ist ein Modul, das absichtlich zufällig handelt,
|
||||||
|
/// überhaupt prüfbar.
|
||||||
|
/// </summary>
|
||||||
|
[Trait("cat", "unit")]
|
||||||
|
public class DebugTradingLogicTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Now = new(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static DebugTradingSettings Settings() => new()
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
MaxOpenPositions = 3,
|
||||||
|
MaxNotionalPerTrade = 1_000,
|
||||||
|
DailyBudget = 5_000,
|
||||||
|
MinHoldMinutes = 10,
|
||||||
|
MaxHoldMinutes = 4_320,
|
||||||
|
Universe = ["SAP:EUR"]
|
||||||
|
};
|
||||||
|
|
||||||
|
private static decimal? Price(InstrumentRef _) => 100m;
|
||||||
|
|
||||||
|
// ─── Die Sicherung, die nicht verhandelbar ist ───────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LiveMode_IsRefused()
|
||||||
|
{
|
||||||
|
DebugTradingGuard.CheckPaperMode(TradingMode.Live, "Live")
|
||||||
|
.Should().NotBeNull("ein Prüfmodul auf einem Live-Konto wäre der teuerste Fehler dieses Projekts");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PaperMode_IsAllowed()
|
||||||
|
{
|
||||||
|
DebugTradingGuard.CheckPaperMode(TradingMode.Paper, "Paper").Should().BeNull();
|
||||||
|
DebugTradingGuard.CheckPaperMode(TradingMode.Paper, "paper").Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnAmbiguousModeValue_IsRefusedEvenThoughItParsesToPaper()
|
||||||
|
{
|
||||||
|
// TradingSettings.ParsedMode fällt bei einem unbekannten Wert auf Paper zurück. Genau
|
||||||
|
// darauf darf sich diese Prüfung nicht verlassen: "Papier" oder ein Tippfehler ergäben
|
||||||
|
// sonst ein stilles Ja auf einer Maschine, deren Konfiguration jemand kopiert hat.
|
||||||
|
DebugTradingGuard.CheckPaperMode(TradingMode.Paper, "Papier").Should().NotBeNull();
|
||||||
|
DebugTradingGuard.CheckPaperMode(TradingMode.Paper, "").Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Grenzen ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Opening_IsBlockedByEachLimitSeparately()
|
||||||
|
{
|
||||||
|
DebugTradingGuard.CheckOpening(3, 3, 0m, 5_000m, 500m, 1_000m)
|
||||||
|
.Should().Contain("offene Testpositionen");
|
||||||
|
|
||||||
|
DebugTradingGuard.CheckOpening(0, 3, 0m, 5_000m, 1_500m, 1_000m)
|
||||||
|
.Should().Contain("über der Grenze je Trade");
|
||||||
|
|
||||||
|
DebugTradingGuard.CheckOpening(0, 3, 4_800m, 5_000m, 500m, 1_000m)
|
||||||
|
.Should().Contain("Tagesbudget");
|
||||||
|
|
||||||
|
DebugTradingGuard.CheckOpening(1, 3, 1_000m, 5_000m, 500m, 1_000m)
|
||||||
|
.Should().BeNull("alles innerhalb der Grenzen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Planung ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuePositions_AreClosed_EvenWhenTheLimitIsReached()
|
||||||
|
{
|
||||||
|
// Drei offene bei einer Grenze von drei: Die faellige MUSS trotzdem geschlossen werden.
|
||||||
|
// Sonst hielte die erreichte Grenze das Modul fuer immer in der Sperre - es koennte nie
|
||||||
|
// wieder unter die Grenze kommen.
|
||||||
|
var sap = InstrumentRef.ForStock("SAP", "EUR");
|
||||||
|
var open = new[]
|
||||||
|
{
|
||||||
|
new OpenTestPosition(sap, 5, Now.AddMinutes(-1)), // faellig
|
||||||
|
new OpenTestPosition(sap, 5, Now.AddHours(5)),
|
||||||
|
new OpenTestPosition(sap, 5, Now.AddHours(9))
|
||||||
|
};
|
||||||
|
|
||||||
|
var actions = DebugTradingPlan.Plan(open, Now, 0m, Settings(), Price, new Random(1));
|
||||||
|
|
||||||
|
actions.Should().Contain(a => a is DebugAction.Close);
|
||||||
|
|
||||||
|
// Der frei gewordene Platz darf sofort wieder genutzt werden - das ist Dauerlast,
|
||||||
|
// kein Widerspruch.
|
||||||
|
actions.Count(a => a is DebugAction.Open).Should().BeLessThanOrEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AtTheLimitWithNothingDue_NothingHappens()
|
||||||
|
{
|
||||||
|
// Volle Grenze, nichts faellig: kein Schliessen, kein Eroeffnen.
|
||||||
|
var sap = InstrumentRef.ForStock("SAP", "EUR");
|
||||||
|
var open = new[]
|
||||||
|
{
|
||||||
|
new OpenTestPosition(sap, 5, Now.AddHours(3)),
|
||||||
|
new OpenTestPosition(sap, 5, Now.AddHours(5)),
|
||||||
|
new OpenTestPosition(sap, 5, Now.AddHours(9))
|
||||||
|
};
|
||||||
|
|
||||||
|
DebugTradingPlan.Plan(open, Now, 0m, Settings(), Price, new Random(1)).Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithRoomLeft_ExactlyOnePositionIsOpened()
|
||||||
|
{
|
||||||
|
var actions = DebugTradingPlan.Plan([], Now, 0m, Settings(), Price, new Random(1));
|
||||||
|
|
||||||
|
var open = actions.Should().ContainSingle().Subject.Should().BeOfType<DebugAction.Open>().Subject;
|
||||||
|
open.Quantity.Should().Be(10, "1.000 Grenze je Trade bei Kurs 100");
|
||||||
|
open.CloseAtUtc.Should().BeAfter(Now.AddMinutes(9)).And.BeBefore(Now.AddMinutes(4_321));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StopOpening_KeepsClosingButOpensNothing()
|
||||||
|
{
|
||||||
|
// Der Not-Aus: offene Positionen werden weiter betreut, aber nichts Neues eröffnet.
|
||||||
|
var settings = Settings();
|
||||||
|
settings.StopOpening = true;
|
||||||
|
|
||||||
|
var sap = InstrumentRef.ForStock("SAP", "EUR");
|
||||||
|
var open = new[] { new OpenTestPosition(sap, 5, Now.AddMinutes(-1)) };
|
||||||
|
|
||||||
|
var actions = DebugTradingPlan.Plan(open, Now, 0m, settings, Price, new Random(1));
|
||||||
|
|
||||||
|
actions.Should().ContainSingle().Which.Should().BeOfType<DebugAction.Close>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WithoutAPrice_NothingIsOpened()
|
||||||
|
{
|
||||||
|
// Eine Stückzahl ohne Kurs wäre geraten.
|
||||||
|
var actions = DebugTradingPlan.Plan([], Now, 0m, Settings(), _ => null, new Random(1));
|
||||||
|
|
||||||
|
actions.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TooExpensiveForTheLimit_IsSkipped()
|
||||||
|
{
|
||||||
|
// Ein Wert, von dem nicht einmal ein Stück ins Budget passt, wird übersprungen.
|
||||||
|
var actions = DebugTradingPlan.Plan([], Now, 0m, Settings(), _ => 5_000m, new Random(1));
|
||||||
|
|
||||||
|
actions.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheDailyBudgetCapsTheSize()
|
||||||
|
{
|
||||||
|
// Nur noch 300 im Budget: die Position wird kleiner, nicht abgelehnt.
|
||||||
|
var actions = DebugTradingPlan.Plan([], Now, spentToday: 4_700m, Settings(), Price, new Random(1));
|
||||||
|
|
||||||
|
var open = actions.Should().ContainSingle().Subject.Should().BeOfType<DebugAction.Open>().Subject;
|
||||||
|
open.Quantity.Should().Be(3, "300 verbleibendes Budget bei Kurs 100");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Universum ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheUniverseKeepsCurrenciesApart()
|
||||||
|
{
|
||||||
|
var parsed = DebugTradingPlan.ParseUniverse(["SAP:EUR", "AAPL:USD", "MSFT"]);
|
||||||
|
|
||||||
|
parsed.Should().HaveCount(3);
|
||||||
|
parsed[0].Key.Should().Be("SAP:EUR");
|
||||||
|
parsed[1].Key.Should().Be("AAPL", "USD bleibt im Schlüssel implizit");
|
||||||
|
parsed[2].Currency.Should().Be("USD", "ohne Angabe gilt die Vorgabewährung");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnreadableEntriesAreSkipped_NotGuessed()
|
||||||
|
{
|
||||||
|
// Ein falsch gedeutetes Symbol wäre ein Handel in einem Papier, das niemand gemeint hat.
|
||||||
|
var parsed = DebugTradingPlan.ParseUniverse(["", " ", ":EUR", "SAP:NICHTSOWAS", "SAP:EUR"]);
|
||||||
|
|
||||||
|
parsed.Should().ContainSingle().Which.Key.Should().Be("SAP:EUR");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user