Positionsverwaltung: Herkunft sichtbar, Zuordnung aenderbar, Sperren einstellbar

Erster Teil von #485. Der PositionManager im Core beantwortet drei Fragen, die
sich beim Debuggen sofort stellen: Wem gehoert eine Position, was sagt der Broker
dazu, und was ist gesperrt.

Drei Zugaenge auf denselben Dienst - das Fenster der Shell, --positions und
--reassign im kopflosen Betrieb, GET /api/positions. Der Testbetrieb laeuft als
systemd-Dienst; ein Werkzeug, das nur in der Oberflaeche existiert, fehlt genau
dann, wenn man es braucht.

Entscheidungen, die im Code begruendet stehen:

- BrokerQuantity ist nullable und heisst "nicht bekannt", nicht "null Stueck".
  Ohne Verbindung sieht ein leergeraeumtes Konto genauso aus wie eine Stoerung;
  aus dem einen darf nicht auf das andere geschlossen werden.
- Umbuchen aendert die Zuordnung, nie die Menge. Eine Umbuchung, die eine
  Stueckzahl bewegt, waere ein Handel ohne Order und stuende in keiner Historie.
- Gegen eine entgegengesetzte Position wird abgelehnt: Das waere eine
  Glattstellung und gehoert ueber eine Order gemacht, mit Preis und Historie.
- Das Quellbuch ist Pflicht - Schutz gegen die falsche Zeile -, ebenso eine
  Begruendung, sonst ist der Eingriff spaeter nicht mehr erklaerbar.
- Das Cap des Zielbuchs sperrt NICHT. Die Position existiert bereits; sie zu
  benennen erzeugt kein neues Risiko. Wuerde die Grenze das verhindern, bliebe
  jede zu grosse Position fuer immer in UNASSIGNED.
- Der REST-Endpunkt ist lesend. Der Listener kennt keine Anmeldung; ein
  schreibender Zugriff waere ein Eingriff in die Buecher, den niemand belegen kann.

Beim ersten Testlauf gefunden: (Module, ContractKey) ist der Primaerschluessel
von core_position, das Buch laesst sich in der Zeile nicht ueberschreiben.
Umgebucht wird deshalb durch Loeschen und Neuanlegen.

471 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-06 12:41:06 +02:00
co-authored by Claude Opus 5
parent c87b0195fd
commit 8fd99ee6fa
10 changed files with 1116 additions and 4 deletions
+7
View File
@@ -32,6 +32,13 @@ public static class CoreViews
CreateView = () => new WorkersWindow(host, sp.GetRequiredService<WorkerEngine>())
});
host.RegisterView(new ModuleView
{
Id = "core.positions", Title = "Positionen", Group = "Core", Order = 15,
CreateView = () => new PositionsWindow(
host, sp.GetRequiredService<Core.Positions.PositionManager>())
});
host.RegisterView(new ModuleView
{
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
+13
View File
@@ -22,6 +22,19 @@ public sealed record LogRow(string Text, IBrush Color);
/// <summary>Eine offene Position im Modul-Fenster.</summary>
public sealed record PositionRow(string Symbol, int Quantity, decimal AvgPrice, decimal Notional);
/// <summary>
/// Eine Zeile der Positionsverwaltung (#485). Zahlen kommen als fertiger Text: Die Formatierung
/// gehoert an eine Stelle, und „Broker“ muss zwischen „null Stück“ und „keine Auskunft“
/// unterscheiden koennen - ein Zahlentyp kann das nicht.
/// </summary>
public sealed record ManagedPositionRow(
string Book, string Origin, string Instrument, string Quantity, string AvgPrice,
string Bound, string Broker, string Restrictions);
/// <summary>Bestand, den der Broker fuehrt und den kein Buch beansprucht.</summary>
public sealed record UnknownPositionRow(
string Symbol, string SecType, string Currency, string Quantity, string AvgCost, string Note);
/// <summary>Eine Zeile der Supervisor-Berichtsliste. Tokens als fertiger Text (Prompt / Completion).</summary>
public sealed record SupervisorReportRow(
DateTime CreatedAt, string Profile, string Model, string Question, int ToolCallCount, string Tokens);
@@ -0,0 +1,78 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dg="clr-namespace:Avalonia.Controls;assembly=Avalonia.Controls.DataGrid"
xmlns:vm="clr-namespace:IBKRTrader.App.ViewModels"
x:Class="IBKRTrader.App.Views.PositionsWindow"
Title="Positionen"
Width="1280" Height="720"
MinWidth="900" MinHeight="480"
WindowStartupLocation="CenterScreen">
<DockPanel>
<Menu x:Name="WindowMenuBar" DockPanel.Dock="Top" />
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="RefreshButton" Content="Aktualisieren"
ToolTip.Tip="Liest Buchführung und Bestand des Brokers neu ein." />
<CheckBox x:Name="IncludeBrokerCheck" Content="Broker abfragen" IsChecked="True"
ToolTip.Tip="Ohne Häkchen wird nur die eigene Buchführung gezeigt – das geht sofort." />
<Rectangle Width="1" Fill="#40808080" Margin="6,2" />
<TextBlock Text="Sperre:" VerticalAlignment="Center" />
<ComboBox x:Name="LockBox" Width="200"
ToolTip.Tip="Gilt für die ausgewählte Position." />
<Button x:Name="ApplyLockButton" Content="Sperre setzen" />
<Rectangle Width="1" Fill="#40808080" Margin="6,2" />
<TextBlock Text="Umbuchen nach:" VerticalAlignment="Center" />
<ComboBox x:Name="TargetBookBox" Width="160" />
<TextBox x:Name="ReasonBox" Width="240" Watermark="Grund (Pflicht)" />
<Button x:Name="ReassignButton" Content="Umbuchen" />
</StackPanel>
</Border>
<Border Classes="statusbar" DockPanel.Dock="Bottom">
<TextBlock x:Name="StatusText" TextWrapping="Wrap" />
</Border>
<Grid RowDefinitions="*,Auto,140">
<dg:DataGrid x:Name="PositionsGrid" Grid.Row="0"
AutoGenerateColumns="False" SelectionMode="Single" IsReadOnly="True"
x:DataType="vm:ManagedPositionRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Buch" Binding="{Binding Book}" Width="100" />
<dg:DataGridTextColumn Header="Herkunft" Binding="{Binding Origin}" Width="150" />
<dg:DataGridTextColumn Header="Instrument" Binding="{Binding Instrument}" Width="220" />
<dg:DataGridTextColumn Header="Menge" Binding="{Binding Quantity}" Width="90" />
<dg:DataGridTextColumn Header="Einstand" Binding="{Binding AvgPrice}" Width="100" />
<dg:DataGridTextColumn Header="Gebunden" Binding="{Binding Bound}" Width="120" />
<dg:DataGridTextColumn Header="Broker" Binding="{Binding Broker}" Width="90" />
<dg:DataGridTextColumn Header="Sperren" Binding="{Binding Restrictions}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
<TextBlock Grid.Row="1" x:Name="UnknownHeader" Margin="8,10,8,4" FontWeight="SemiBold"
Text="Beim Broker, in keinem Buch" IsVisible="False" />
<!-- Bestand, den der Broker führt und den wir niemandem zuordnen können. Er steht
bewusst getrennt und nicht zwischen den eigenen Zeilen: Es ist eine andere Art von
Aussage – nicht „gehört jemandem", sondern „gehört niemandem, den wir kennen". -->
<dg:DataGrid x:Name="UnknownGrid" Grid.Row="2"
AutoGenerateColumns="False" SelectionMode="Single" IsReadOnly="True"
IsVisible="False" x:DataType="vm:UnknownPositionRow">
<dg:DataGrid.Columns>
<dg:DataGridTextColumn Header="Symbol" Binding="{Binding Symbol}" Width="120" />
<dg:DataGridTextColumn Header="Typ" Binding="{Binding SecType}" Width="80" />
<dg:DataGridTextColumn Header="Währung" Binding="{Binding Currency}" Width="90" />
<dg:DataGridTextColumn Header="Menge" Binding="{Binding Quantity}" Width="100" />
<dg:DataGridTextColumn Header="Einstand" Binding="{Binding AvgCost}" Width="110" />
<dg:DataGridTextColumn Header="Hinweis" Binding="{Binding Note}" Width="*" />
</dg:DataGrid.Columns>
</dg:DataGrid>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,217 @@
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using IBKRTrader.App.Shell;
using IBKRTrader.App.ViewModels;
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Modularity;
using IBKRTrader.Core.Positions;
using IBKRTrader.Core.Time;
using IBKRTrader.Core.Trading;
namespace IBKRTrader.App.Views;
/// <summary>
/// Core-Ansicht: die Positionsverwaltung (#485). Zeigt, wem eine Position gehört, was der Broker
/// dazu sagt und welche Sperren gelten – und erlaubt beides zu ändern.
///
/// <para><b>Die Logik liegt nicht hier.</b> Dieses Fenster ruft <see cref="PositionManager"/> auf
/// und formatiert dessen Antwort. Eine Umbuchung, die nur über die Oberfläche ginge, wäre auf einer
/// kopflosen Instanz nicht durchführbar – dort gibt es dieselben Aufrufe über
/// <c>--positions</c> und <c>--reassign</c>.</para>
///
/// <para>Datenbank- und Broker-Zugriffe laufen NUR auf Anzeige und Nutzerinteraktion, nie im
/// Konstruktor: <c>--smoke-ui</c> prüft die Fenster-Konstruktion ohne Datenbank.</para>
/// </summary>
public partial class PositionsWindow : Window
{
private readonly PositionManager _manager;
/// <summary>Die Ziele einer Umbuchung. Reservierte Bücher, plus alles, was Module halten.</summary>
private static readonly string[] ReassignTargets =
[BookId.Manual.Value, BookId.Unassigned.Value, BookId.House.Value, BookId.Diagnostics.Value];
private static readonly (string Label, PositionLock Level)[] LockLevels =
[
("frei – keine Sperre", PositionLock.Free),
("nur abbauen (kein Zukauf)", PositionLock.CloseOnly),
("nur aufbauen (kein Verkauf)", PositionLock.OpenOnly),
("ganz gesperrt (auch Schließen!)", PositionLock.Locked)
];
public PositionsWindow(IModuleUiHost uiHost, PositionManager manager)
{
_manager = manager;
InitializeComponent();
WindowMenu.Wire(this.FindControl<Menu>("WindowMenuBar")!, uiHost, "core.positions");
this.FindControl<ComboBox>("LockBox")!.ItemsSource = LockLevels.Select(l => l.Label).ToList();
this.FindControl<ComboBox>("LockBox")!.SelectedIndex = 0;
this.FindControl<ComboBox>("TargetBookBox")!.ItemsSource = ReassignTargets;
this.FindControl<Button>("RefreshButton")!.Click += async (_, _) => await RefreshAsync();
this.FindControl<Button>("ApplyLockButton")!.Click += async (_, _) => await ApplyLockAsync();
this.FindControl<Button>("ReassignButton")!.Click += async (_, _) => await ReassignAsync();
Opened += async (_, _) => await RefreshAsync();
}
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
// ─── Anzeigen ────────────────────────────────────────────────────────────────
/// <summary>Die zuletzt geladene Fassung – die Grid-Zeilen sind Text und taugen nicht als Quelle.</summary>
private IReadOnlyList<ManagedPosition> _loaded = [];
private async Task RefreshAsync()
{
var inv = CultureInfo.InvariantCulture;
var includeBroker = this.FindControl<CheckBox>("IncludeBrokerCheck")!.IsChecked == true;
try
{
SetStatus(includeBroker ? "Lade Positionen und Bestand des Brokers …" : "Lade Positionen …");
var overview = await _manager.GetOverviewAsync(includeBroker);
_loaded = overview.Positions;
this.FindControl<DataGrid>("PositionsGrid")!.ItemsSource = overview.Positions
.Select(p => new ManagedPositionRow(
Book: p.Book.Value,
Origin: p.Origin,
Instrument: p.Instrument.Key,
Quantity: p.Quantity.ToString("N0", inv),
AvgPrice: p.AvgPrice.ToString("N2", inv),
Bound: p.BoundCapital.ToString("N0", inv) + " " + p.Instrument.Currency,
// Der Gedankenstrich ist nicht Kosmetik: "0" hieße, der Broker hält nichts.
// Ohne Verbindung wissen wir es schlicht nicht.
Broker: p.BrokerQuantity is { } b ? b.ToString("N0", inv) : "–",
Restrictions: Describe(p)))
.ToList();
var unknownGrid = this.FindControl<DataGrid>("UnknownGrid")!;
var unknownHeader = this.FindControl<TextBlock>("UnknownHeader")!;
unknownGrid.ItemsSource = overview.UnknownToUs
.Select(p => new UnknownPositionRow(
p.Symbol, p.SecType, p.Currency,
p.Quantity.ToString("N0", inv), p.AvgCost.ToString("N2", inv),
"Der Abgleich schreibt sie nach UNASSIGNED; von dort zuordnen."))
.ToList();
unknownGrid.IsVisible = overview.UnknownToUs.Count > 0;
unknownHeader.IsVisible = overview.UnknownToUs.Count > 0;
var diverging = overview.Positions.Count(p => p.Diverges);
SetStatus(
$"{overview.Positions.Count} Position(en)" +
(diverging > 0 ? $", {diverging} weichen vom Broker ab" : "") +
(overview.UnknownToUs.Count > 0 ? $", {overview.UnknownToUs.Count} ohne Buch" : "") +
(includeBroker && !overview.BrokerReachable
? ". Der Broker hat nichts gemeldet – die Spalte bleibt leer, weil ein leeres " +
"Konto und eine gestörte Verbindung von hier aus gleich aussehen."
: "."));
}
catch (Exception ex)
{
SetStatus($"Laden fehlgeschlagen: {ex.Message}");
}
}
private static string Describe(ManagedPosition p)
{
var parts = new List<string>(3);
if (p.Lock != PositionLock.Free)
parts.Add(p.Lock switch
{
PositionLock.CloseOnly => "nur abbauen",
PositionLock.OpenOnly => "nur aufbauen",
PositionLock.Locked => "GESPERRT (auch Schließen)",
_ => p.Lock.ToString()
});
if (p.Rule.Blocked) parts.Add("Instrument gesperrt");
if (p.Rule.MaxQuantity is { } max)
parts.Add(FormattableString.Invariant($"höchstens {max} Stück"));
return string.Join(", ", parts);
}
// ─── Eingreifen ──────────────────────────────────────────────────────────────
private ManagedPosition? Selected()
{
var index = this.FindControl<DataGrid>("PositionsGrid")!.SelectedIndex;
return index >= 0 && index < _loaded.Count ? _loaded[index] : null;
}
private async Task ApplyLockAsync()
{
if (Selected() is not { } position)
{
SetStatus("Keine Position ausgewählt.");
return;
}
var index = this.FindControl<ComboBox>("LockBox")!.SelectedIndex;
if (index < 0 || index >= LockLevels.Length) return;
var (label, level) = LockLevels[index];
try
{
await _manager.SetLockAsync(position.Book, position.Instrument, level, "über die Positionsansicht gesetzt");
SetStatus($"{position.Instrument.Key} in {position.Book.Value}: {label}." +
(level == PositionLock.Locked
? " Achtung: Diese Stufe verhindert auch, dass ein Modul die Position schließt."
: ""));
await RefreshAsync();
}
catch (Exception ex)
{
SetStatus($"Sperre nicht gesetzt: {ex.Message}");
}
}
private async Task ReassignAsync()
{
if (Selected() is not { } position)
{
SetStatus("Keine Position ausgewählt.");
return;
}
if (this.FindControl<ComboBox>("TargetBookBox")!.SelectedItem is not string targetName)
{
SetStatus("Kein Zielbuch gewählt.");
return;
}
var reason = this.FindControl<TextBox>("ReasonBox")!.Text ?? "";
try
{
var result = await _manager.ReassignAsync(
position.Instrument, position.Book, BookId.Parse(targetName), reason);
SetStatus(result.Message);
if (result.Success)
{
this.FindControl<TextBox>("ReasonBox")!.Text = "";
await RefreshAsync();
}
}
catch (Exception ex)
{
SetStatus($"Umbuchung fehlgeschlagen: {ex.Message}");
}
}
private void SetStatus(string text) =>
this.FindControl<TextBlock>("StatusText")!.Text = $"{text} | {AppTimeZone.Now:HH:mm:ss}";
}
@@ -0,0 +1,309 @@
using System.Globalization;
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Trading;
using Microsoft.EntityFrameworkCore;
namespace IBKRTrader.Core.Positions;
/// <summary>
/// Eine Position, wie die Verwaltung sie zeigt: eigener Bestand, Herkunft, Sperren – und daneben,
/// was der Broker führt.
/// </summary>
/// <param name="Book">Buch, dem sie zugeordnet ist. MANUAL und UNASSIGNED sind gleichwertige Bücher.</param>
/// <param name="Instrument">Betroffenes Instrument.</param>
/// <param name="Quantity">Eigener Bestand, vorzeichenbehaftet.</param>
/// <param name="AvgPrice">Durchschnittlicher Einstand.</param>
/// <param name="BoundCapital">Gebundenes Kapital – bei geschriebenen Puts die Verpflichtung, nicht die Prämie.</param>
/// <param name="Lock">Sperrstufe des Betreibers für dieses Buch und Instrument.</param>
/// <param name="Rule">Regel des Instruments (Handelsverbot, Stückgrenze) – gilt über alle Bücher.</param>
/// <param name="BrokerQuantity">
/// Was der Broker führt. <c>null</c> heißt <b>nicht bekannt</b> und ausdrücklich nicht „null Stück":
/// Ohne Verbindung ist jede Aussage über Abweichungen wertlos.
///
/// <para>Dezimal und nicht ganzzahlig, weil der Broker es so meldet – Bruchstücke gibt es. Sie hier
/// zu runden hieße, eine Abweichung wegzurechnen, die es gibt.</para>
/// </param>
public sealed record ManagedPosition(
BookId Book,
InstrumentRef Instrument,
int Quantity,
decimal AvgPrice,
decimal BoundCapital,
PositionLock Lock,
InstrumentRule Rule,
decimal? BrokerQuantity)
{
/// <summary>Herkunft in einem Wort – das, wonach beim Aufräumen zuerst gefragt wird.</summary>
public string Origin => Book.Kind switch
{
BookKind.Manual => "Von Hand",
BookKind.Unassigned => "Herkunft unbekannt",
BookKind.House => "Haus",
_ => Book.Value
};
/// <summary>
/// Weicht unser Bestand von dem des Brokers ab? <c>false</c>, solange der Broker nichts gesagt
/// hat – eine Abweichung, die nur aus fehlenden Daten besteht, ist keine.
/// </summary>
public bool Diverges => BrokerQuantity is { } broker && broker != Quantity;
public bool IsRestricted => Lock != PositionLock.Free || Rule.IsRestrictive;
}
/// <summary>
/// Was der Überblick über alle Bücher hergibt.
/// </summary>
/// <param name="Positions">Alle offenen Positionen, über sämtliche Bücher.</param>
/// <param name="BrokerReachable">
/// Stand die Verbindung zum Broker? Ist sie es nicht, sind sämtliche <c>BrokerQuantity</c> leer und
/// aus dem Vergleich darf nichts gefolgert werden.
/// </param>
/// <param name="UnknownToUs">
/// Positionen, die der Broker führt und wir in keinem Buch haben. Sie sind der eigentliche Grund
/// für diese Ansicht: Bestand, den niemand angelegt zu haben behauptet.
/// </param>
public sealed record PositionOverview(
IReadOnlyList<ManagedPosition> Positions,
bool BrokerReachable,
IReadOnlyList<BrokerPosition> UnknownToUs);
/// <summary>
/// Die Positionsverwaltung (#485): sehen, wem eine Position gehört, sie umhängen und sie
/// stufenweise sperren.
///
/// <para><b>Warum das ein eigener Dienst ist und nicht Teil der Oberfläche.</b> Alle drei
/// Handlungen sind Eingriffe des Betreibers in die Buchführung, nicht Anzeige. Sie brauchen
/// dieselbe Sorgfalt wie ein Handelspfad: eine Umbuchung, die eine Menge verändert, wäre ein
/// stiller Handel; eine Sperre, die nur in einem Fenster steht, wäre keine. Deshalb liegen sie
/// hier – abrufbar aus der Shell, aus der REST-Schnittstelle und aus dem Daemon, mit identischem
/// Verhalten.</para>
///
/// <para><b>Was diese Klasse nicht tut:</b> Sie handelt nicht. Kein Aufruf hier löst eine Order
/// aus, weder zum Glattstellen noch zum Ausgleichen einer Abweichung. Was mit einer fremden
/// Position geschieht, entscheidet ein Mensch.</para>
/// </summary>
public sealed class PositionManager
{
private const string LogModule = "Positionen";
private readonly IDbContextFactory<CoreDbContext> _dbf;
private readonly IPortfolioService _portfolio;
private readonly IBrokerPortfolioReader _broker;
private readonly TradingRuleService _rules;
private readonly LoggingService _logger;
public PositionManager(
IDbContextFactory<CoreDbContext> dbf,
IPortfolioService portfolio,
IBrokerPortfolioReader broker,
TradingRuleService rules,
LoggingService logger)
{
_dbf = dbf;
_portfolio = portfolio;
_broker = broker;
_rules = rules;
_logger = logger;
}
// ─── Sehen ───────────────────────────────────────────────────────────────────
/// <summary>
/// Der Überblick über alle Bücher, angereichert um Sperren und den Bestand des Brokers.
/// </summary>
/// <param name="includeBroker">
/// Den Broker mit abfragen. <c>false</c> für eine reine Anzeige der eigenen Buchführung – die
/// Abfrage kostet eine Verbindung und mehrere Sekunden.
/// </param>
public async Task<PositionOverview> GetOverviewAsync(
bool includeBroker = true, CancellationToken ct = default)
{
var own = await _portfolio.GetAllPositionsAsync(ct).ConfigureAwait(false);
IReadOnlyList<BrokerPosition> brokerPositions = Array.Empty<BrokerPosition>();
var reachable = false;
if (includeBroker)
{
brokerPositions = await _broker.GetPositionsAsync(ct).ConfigureAwait(false);
// Eine leere Antwort ist zweideutig: keine Positionen oder keine Verbindung. Solange wir
// das nicht auseinanderhalten koennen, gilt sie NICHT als Feststellung - sonst sieht ein
// Verbindungsabbruch aus wie ein leergeraeumtes Konto.
reachable = brokerPositions.Count > 0;
}
var byKey = brokerPositions
.Where(p => p.Instrument is not null)
.GroupBy(p => p.Instrument!.Value.Key, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Sum(p => p.Quantity), StringComparer.OrdinalIgnoreCase);
var managed = new List<ManagedPosition>(own.Count);
foreach (var position in own.Where(p => p.Quantity != 0))
{
var book = BookId.Parse(position.Module);
// Verbindung steht und der Broker kennt das Papier nicht: Das ist eine Aussage – nämlich
// null Stück – und keine fehlende Angabe. Ohne Verbindung bleibt es leer, weil dann
// jeder Vergleich wertlos wäre.
decimal? brokerQuantity = null;
if (reachable)
brokerQuantity = byKey.TryGetValue(position.Instrument.Key, out var q) ? q : 0m;
managed.Add(new ManagedPosition(
Book: book,
Instrument: position.Instrument,
Quantity: position.Quantity,
AvgPrice: position.AvgPrice,
BoundCapital: position.BoundCapital,
Lock: await _rules.GetLockAsync(book, position.Instrument, ct).ConfigureAwait(false),
Rule: await _rules.GetRuleAsync(position.Instrument, ct).ConfigureAwait(false),
BrokerQuantity: brokerQuantity));
}
// Bestand, den der Broker fuehrt und den wir in keinem Buch haben. Der Abgleich (#306)
// schreibt solche Positionen nach UNASSIGNED; bis er gelaufen ist, stehen sie hier.
var ownKeys = own.Where(p => p.Quantity != 0)
.Select(p => p.Instrument.Key)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var unknown = brokerPositions
.Where(p => p.Quantity != 0)
.Where(p => p.Instrument is not { } key || !ownKeys.Contains(key.Key))
.ToList();
return new PositionOverview(
[.. managed.OrderBy(m => m.Book.Value, StringComparer.Ordinal)
.ThenBy(m => m.Instrument.Key, StringComparer.Ordinal)],
reachable,
unknown);
}
// ─── Umhängen ────────────────────────────────────────────────────────────────
/// <summary>Ergebnis einer Umbuchung.</summary>
/// <param name="Success">Hat sie stattgefunden?</param>
/// <param name="Message">Was geschehen ist bzw. warum nicht.</param>
public readonly record struct ReassignResult(bool Success, string Message);
/// <summary>
/// Hängt eine Position von einem Buch in ein anderes um – der häufigste Fall ist UNASSIGNED
/// nach MANUAL, wenn der Betreiber eine Position wiedererkennt.
///
/// <para><b>Die Menge bleibt unangetastet.</b> Umgehängt wird die Zuordnung, nicht der Bestand.
/// Eine Umbuchung, die eine Stückzahl verändert, wäre ein Handel ohne Order – und stünde in
/// keiner Historie.</para>
///
/// <para><b>Warum das Cap des Zielbuchs hier nicht sperrt.</b> Die Position existiert bereits;
/// sie zu benennen erzeugt kein neues Risiko. Würde die Grenze die Umbuchung verhindern, bliebe
/// jede zu große Position für immer in UNASSIGNED – dem einzigen Buch ohne Grenze. Das
/// Überschreiten ist ein zulässiger Zustand (<see cref="Headroom.OverCap"/>): Es sperrt Zukäufe,
/// heilt aber nichts durch Verkauf. Gemeldet wird es trotzdem, und zwar deutlich.</para>
/// </summary>
/// <param name="instrument">Die betroffene Position.</param>
/// <param name="from">Buch, aus dem sie kommt – als Schutz gegen das Umhängen der falschen Zeile.</param>
/// <param name="to">Zielbuch.</param>
/// <param name="reason">Begründung des Betreibers; landet im Log.</param>
public async Task<ReassignResult> ReassignAsync(
InstrumentRef instrument, BookId from, BookId to, string reason,
CancellationToken ct = default)
{
if (from == to)
return new ReassignResult(false, $"Quelle und Ziel sind dasselbe Buch ({from.Value}).");
if (string.IsNullOrWhiteSpace(reason))
return new ReassignResult(false,
"Eine Umbuchung ohne Begründung ist später nicht mehr nachvollziehbar.");
await using var db = await _dbf.CreateDbContextAsync(ct).ConfigureAwait(false);
var source = await db.Positions
.FirstOrDefaultAsync(p => p.Module == from.Value && p.ContractKey == instrument.Key, ct)
.ConfigureAwait(false);
if (source is null || source.Quantity == 0)
return new ReassignResult(false,
$"Im Buch {from.Value} liegt keine offene Position auf {instrument.Key}.");
var target = await db.Positions
.FirstOrDefaultAsync(p => p.Module == to.Value && p.ContractKey == instrument.Key, ct)
.ConfigureAwait(false);
var moved = source.Quantity;
var movedCost = source.AvgPrice;
// Die Gegenrichtung zuerst, BEVOR irgendetwas angefasst wird: 100 Stück long in ein Buch zu
// schieben, das 100 short hält, ergibt eine Position von null - also eine Glattstellung. Die
// gehört über eine Order gemacht, mit Preis und Historie, nicht über die Verwaltung.
if (target is not null && target.Quantity != 0 &&
Math.Sign(target.Quantity) != Math.Sign(source.Quantity))
return new ReassignResult(false,
$"{to.Value} hält {instrument.Key} in der Gegenrichtung " +
$"({target.Quantity} gegen {source.Quantity}). Das ist eine Glattstellung, keine " +
"Umbuchung – sie gehört über eine Order gemacht, nicht über die Verwaltung.");
// Immer löschen und neu anlegen, nie das Buch in der bestehenden Zeile überschreiben:
// (Module, ContractKey) ist der Primärschlüssel von core_position. EF lehnt eine Änderung am
// Schlüssel ab - im Test sofort, in Betrieb ebenso. Aufgefallen beim ersten Testlauf.
var quantity = (target?.Quantity ?? 0) + source.Quantity;
var price = target is null || quantity == 0
? source.AvgPrice
// Zusammengelegt wird mengengewichtet. Ein neuer Einstand wäre eine erfundene Zahl, und
// die GuV des Zielbuchs hängt daran.
: (target.Quantity * target.AvgPrice + source.Quantity * source.AvgPrice) / quantity;
var moving = new CorePosition
{
Module = to.Value,
ContractKey = source.ContractKey,
Symbol = source.Symbol,
Kind = source.Kind,
Expiry = source.Expiry,
Strike = source.Strike,
Right = source.Right,
Multiplier = source.Multiplier,
Currency = source.Currency,
Quantity = quantity,
AvgPrice = price,
UpdatedAt = DateTime.UtcNow
};
db.Positions.Remove(source);
if (target is not null) db.Positions.Remove(target);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
db.Positions.Add(moving);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
var text = string.Create(CultureInfo.InvariantCulture,
$"Position umgebucht: {moved} x {instrument.Key} zu {movedCost:F2} von {from.Value} " +
$"nach {to.Value}. Grund: {reason}");
// Warn und nicht Info: Das ist ein Eingriff von Hand in die Buchfuehrung. Er soll in der
// Datei stehen, die man liest, wenn man wissen will, warum eine Zahl sich geaendert hat.
_logger.Warn(LogModule, text);
return new ReassignResult(true, text);
}
// ─── Sperren ─────────────────────────────────────────────────────────────────
/// <summary>
/// Setzt die Sperrstufe einer Position. Reicht durch an den <see cref="TradingRuleService"/>,
/// damit die Oberfläche nur einen Dienst kennen muss.
/// </summary>
public Task SetLockAsync(
BookId book, InstrumentRef instrument, PositionLock level, string? note = null,
CancellationToken ct = default) =>
_rules.SetLockAsync(book, instrument, level, note, ct);
/// <summary>Setzt oder löscht die Regel eines Instruments (Handelsverbot, Stückgrenze).</summary>
public Task SetRuleAsync(
string contractKeyOrSymbol, InstrumentRule rule, string? note = null,
CancellationToken ct = default) =>
_rules.SetRuleAsync(contractKeyOrSymbol, rule, note, ct);
}
@@ -4,18 +4,26 @@ using System.Net;
using System.Text;
using System.Text.Json;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Positions;
using IBKRTrader.Core.Settings;
namespace IBKRTrader.Core.Workers.BuiltIn;
/// <summary>
/// WebApiService – permanenter REST-API-Service auf Basis von HttpListener.
/// Basis-Routen: GET /api/status, GET /api/workers
/// Wird später schrittweise um vollständige API-Endpunkte erweitert.
///
/// <para>Routen: <c>GET /api/status</c>, <c>GET /api/workers</c>, <c>GET /api/positions</c>.</para>
///
/// <para><b>Ausschließlich lesend, und das ist Absicht.</b> Der Listener lauscht auf
/// <c>localhost</c> und kennt keine Anmeldung. Solange das so ist, gehört hierher nichts, was
/// etwas verändert – eine Umbuchung oder eine Sperre über eine offene Schnittstelle wäre ein
/// Eingriff in die Bücher, den niemand belegen kann. Schreibende Endpunkte kommen erst mit
/// Anmeldung und Herkunftsnachweis.</para>
/// </summary>
public class WebApiService : WorkerBase
{
private readonly SettingsService _settings;
private readonly PositionManager _positions;
private WorkerEngine? _engine; // wird nach DI-Aufbau gesetzt (kein readonly)
private HttpListener? _listener;
@@ -28,10 +36,12 @@ public class WebApiService : WorkerBase
protected override TimeSpan? Interval => null;
public WebApiService(LoggingService logger, IDbContextFactory<CoreDbContext> db, SettingsService settings)
public WebApiService(LoggingService logger, IDbContextFactory<CoreDbContext> db,
SettingsService settings, PositionManager positions)
: base(logger, db)
{
_settings = settings;
_settings = settings;
_positions = positions;
Info.Active = settings.Settings.Webserver.Enabled;
Info.RunEvery = "Service";
}
@@ -69,6 +79,50 @@ public class WebApiService : WorkerBase
}
}
/// <summary>
/// Der Positionsbestand über alle Bücher (#485).
///
/// <para>Die Broker-Abfrage kostet eine Verbindung und mehrere Sekunden und bleibt deshalb
/// abschaltbar: <c>?broker=0</c> antwortet allein aus der eigenen Buchführung. <c>brokerQuantity</c>
/// ist <c>null</c>, wenn der Broker nichts gemeldet hat – das ist ausdrücklich etwas anderes als
/// null Stück, und ein Client darf daraus keine Abweichung ableiten.</para>
/// </summary>
private async Task<object> PositionsAsync(HttpListenerContext ctx)
{
var includeBroker = !string.Equals(
ctx.Request.QueryString["broker"], "0", StringComparison.Ordinal);
var overview = await _positions.GetOverviewAsync(includeBroker).ConfigureAwait(false);
return new
{
brokerReachable = overview.BrokerReachable,
positions = overview.Positions.Select(p => new
{
book = p.Book.Value,
origin = p.Origin,
instrument = p.Instrument.Key,
currency = p.Instrument.Currency,
quantity = p.Quantity,
avgPrice = p.AvgPrice,
boundCapital = p.BoundCapital,
brokerQuantity = p.BrokerQuantity,
diverges = p.Diverges,
positionLock = p.Lock.ToString(),
blocked = p.Rule.Blocked,
maxQuantity = p.Rule.MaxQuantity
}),
unknownToUs = overview.UnknownToUs.Select(p => new
{
symbol = p.Symbol,
secType = p.SecType,
currency = p.Currency,
quantity = p.Quantity,
avgCost = p.AvgCost
})
};
}
private async Task RouteAsync(HttpListenerContext ctx)
{
try
@@ -81,6 +135,7 @@ public class WebApiService : WorkerBase
"/api/workers" => _engine?.WorkerInfos
.Select(w => new { w.WorkerName, w.Module, w.Type,
w.Info, w.Active }) ?? [],
"/api/positions" => await PositionsAsync(ctx).ConfigureAwait(false),
_ => null
};
+164
View File
@@ -0,0 +1,164 @@
using System.Globalization;
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Positions;
using IBKRTrader.Core.Trading;
using Microsoft.Extensions.DependencyInjection;
namespace IBKRTrader.Daemon;
/// <summary>
/// Die Positionsverwaltung auf der Kommandozeile (#485) – dieselben Aufrufe, die auch das Fenster
/// der Shell macht.
///
/// <para><b>Wozu doppelt.</b> Der Testbetrieb läuft kopflos als systemd-Dienst. Wer dort wissen
/// will, wem eine Position gehört, hat kein Fenster – und ein Werkzeug, das nur in der Oberfläche
/// existiert, fehlt genau dann, wenn man es braucht.</para>
/// </summary>
internal static class PositionTool
{
public static async Task<int> ListAsync(IServiceProvider services, bool includeBroker)
{
var inv = CultureInfo.InvariantCulture;
var manager = services.GetRequiredService<PositionManager>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
var overview = await manager.GetOverviewAsync(includeBroker, cts.Token).ConfigureAwait(false);
Console.WriteLine();
Console.WriteLine("=== Offene Positionen ===");
if (overview.Positions.Count == 0)
{
Console.WriteLine("Keine.");
}
else
{
Console.WriteLine($"{"Buch",-12} {"Herkunft",-20} {"Instrument",-24} {"Menge",8} " +
$"{"Einstand",10} {"Gebunden",12} {"Broker",8} Sperren");
foreach (var p in overview.Positions)
{
var broker = p.BrokerQuantity is { } b ? b.ToString("N0", inv) : "–";
var flags = Describe(p);
Console.WriteLine(
$"{p.Book.Value,-12} {p.Origin,-20} {p.Instrument.Key,-24} " +
$"{p.Quantity.ToString("N0", inv),8} {p.AvgPrice.ToString("N2", inv),10} " +
$"{p.BoundCapital.ToString("N0", inv),12} {broker,8} {flags}");
}
}
if (includeBroker && !overview.BrokerReachable)
{
Console.WriteLine();
Console.WriteLine("Der Broker hat nichts gemeldet. Die Spalte „Broker\" bleibt deshalb leer:");
Console.WriteLine("Ein leeres Konto und eine gestörte Verbindung sehen von hier aus gleich aus,");
Console.WriteLine("und aus dem einen darf nicht auf das andere geschlossen werden.");
}
var diverging = overview.Positions.Where(p => p.Diverges).ToList();
if (diverging.Count > 0)
{
Console.WriteLine();
Console.WriteLine("=== Abweichungen gegen den Broker ===");
foreach (var p in diverging)
Console.WriteLine($" {p.Instrument.Key}: wir {p.Quantity}, Broker {p.BrokerQuantity}");
Console.WriteLine("Zum Auflösen: --reconcile --dry-run, dann ohne --dry-run.");
}
if (overview.UnknownToUs.Count > 0)
{
Console.WriteLine();
Console.WriteLine("=== Beim Broker, in keinem Buch ===");
foreach (var p in overview.UnknownToUs)
Console.WriteLine($" {p.Symbol} ({p.SecType}, {p.Currency}): " +
$"{p.Quantity.ToString("N0", inv)} zu {p.AvgCost.ToString("N2", inv)}");
Console.WriteLine("Der Abgleich schreibt sie nach UNASSIGNED; von dort lassen sie sich zuordnen:");
Console.WriteLine(" --reassign <INSTRUMENT> --from UNASSIGNED --to MANUAL --reason \"...\"");
}
return 0;
}
private static string Describe(ManagedPosition p)
{
var parts = new List<string>(3);
if (p.Lock != PositionLock.Free)
parts.Add(p.Lock switch
{
PositionLock.CloseOnly => "nur abbauen",
PositionLock.OpenOnly => "nur aufbauen",
PositionLock.Locked => "GESPERRT (auch Schließen)",
_ => p.Lock.ToString()
});
if (p.Rule.Blocked) parts.Add("Instrument gesperrt");
if (p.Rule.MaxQuantity is { } max)
parts.Add(FormattableString.Invariant($"höchstens {max}"));
return parts.Count == 0 ? "" : string.Join(", ", parts);
}
/// <summary>
/// Hängt eine Position um. <b>Ohne <c>--confirm</c> ein Trockenlauf</b> – eine Umbuchung ändert
/// die Buchführung, und wer sie versehentlich auslöst, merkt es erst an einer falschen Zahl.
/// </summary>
public static async Task<int> ReassignAsync(
IServiceProvider services, string instrumentKey, string? from, string? to, string? reason,
bool confirm)
{
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to))
{
Console.Error.WriteLine("ABBRUCH: --from und --to sind Pflicht. Das Quellbuch ist keine " +
"Zierde, sondern der Schutz gegen die falsche Zeile.");
return 1;
}
if (string.IsNullOrWhiteSpace(reason))
{
Console.Error.WriteLine("ABBRUCH: --reason ist Pflicht. Eine Umbuchung ohne Begründung " +
"ist in einem Monat nicht mehr erklärbar.");
return 1;
}
if (!InstrumentRef.TryParse(instrumentKey, out var instrument))
{
Console.Error.WriteLine($"ABBRUCH: \"{instrumentKey}\" ist kein lesbarer Instrumentenschlüssel.");
return 1;
}
BookId source, target;
try
{
source = BookId.Parse(from);
target = BookId.Parse(to);
}
catch (ArgumentException ex)
{
Console.Error.WriteLine($"ABBRUCH: {ex.Message}");
return 1;
}
Console.WriteLine($"Vorhaben: {instrument.Key} von {source.Value} nach {target.Value}.");
Console.WriteLine($"Grund: {reason}");
if (!confirm)
{
Console.WriteLine();
Console.WriteLine("=== TROCKENLAUF – nichts umgebucht ===");
Console.WriteLine("Zum Ausführen denselben Aufruf mit --confirm wiederholen.");
return 0;
}
var manager = services.GetRequiredService<PositionManager>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
var result = await manager
.ReassignAsync(instrument, source, target, reason, cts.Token)
.ConfigureAwait(false);
Console.WriteLine(result.Message);
return result.Success ? 0 : 1;
}
}
+17
View File
@@ -63,6 +63,13 @@ internal static class Program
// Eine hängende Order zurücknehmen. Braucht --confirm: Stornieren ist ein Eingriff.
var cancelOrder = ArgAfter(args, "--cancel-order");
// Positionsverwaltung (#485): wem gehört was, und was ist gesperrt. Liest nur;
// --no-broker laesst die Broker-Abfrage weg und antwortet damit sofort.
var positions = HasFlag(args, "--positions");
// Eine Position umhängen. Braucht --from, --to, --reason und --confirm.
var reassign = ArgAfter(args, "--reassign");
var modules = AppHostBuilder.CreateModules();
using var host = AppHostBuilder.Build(modules);
@@ -94,6 +101,16 @@ internal static class Program
if (reconcile)
return await RunReconcileAsync(host.Services, !HasFlag(args, "--dry-run")).ConfigureAwait(false);
if (positions)
return await PositionTool.ListAsync(
host.Services, !HasFlag(args, "--no-broker")).ConfigureAwait(false);
if (reassign is not null)
return await PositionTool.ReassignAsync(
host.Services, reassign,
ArgAfter(args, "--from"), ArgAfter(args, "--to"), ArgAfter(args, "--reason"),
HasFlag(args, "--confirm")).ConfigureAwait(false);
if (openOrders)
return await OrderBookTool.ListAsync(host.Services).ConfigureAwait(false);
+1
View File
@@ -266,6 +266,7 @@ public static class AppHostBuilder
CreateModules().Select(BookNameOf).Append(BookId.Diagnostics.Value)));
services.AddSingleton<ReservationService>();
services.AddSingleton<TradingRuleService>();
services.AddSingleton<Core.Positions.PositionManager>();
services.AddSingleton<CapitalService>();
services.AddSingleton<ICapitalService>(sp => sp.GetRequiredService<CapitalService>());
@@ -0,0 +1,251 @@
using FluentAssertions;
using IBKRTrader.Core.Capital;
using IBKRTrader.Core.Logging;
using IBKRTrader.Core.Persistence.Ef;
using IBKRTrader.Core.Persistence.Entities;
using IBKRTrader.Core.Positions;
using IBKRTrader.Core.Trading;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace IBKRTrader.Tests.Positions;
/// <summary>
/// Die Positionsverwaltung (#485). Geprüft wird gegen die EF-InMemory-Datenbank; Broker und
/// Buchführung sind gemockt.
///
/// <para>Der Schwerpunkt liegt auf dem Umhängen. Es ist die einzige Handlung hier, die Daten
/// verändert – und die einzige, bei der ein Fehler wie ein Handel aussähe, den niemand ausgelöst
/// hat.</para>
/// </summary>
[Trait("cat", "unit")]
public class PositionManagerTests
{
private sealed class InMemoryFactory(DbContextOptions<CoreDbContext> options)
: IDbContextFactory<CoreDbContext>
{
public CoreDbContext CreateDbContext() => new(options);
}
private static readonly InstrumentRef Sap = InstrumentRef.ForStock("SAP", "EUR");
private static readonly BookId Ct = BookId.ForModule("CT");
private readonly IPortfolioService _portfolio = Substitute.For<IPortfolioService>();
private readonly IBrokerPortfolioReader _broker = Substitute.For<IBrokerPortfolioReader>();
private PositionManager CreateSut(out IDbContextFactory<CoreDbContext> factory)
{
var options = new DbContextOptionsBuilder<CoreDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
factory = new InMemoryFactory(options);
var logger = new LoggingService();
return new PositionManager(factory, _portfolio, _broker, new TradingRuleService(factory, logger), logger);
}
private static CorePosition Row(string book, int quantity, decimal avgPrice) => new()
{
Module = book,
ContractKey = Sap.Key,
Symbol = "SAP",
Currency = "EUR",
Quantity = quantity,
AvgPrice = avgPrice,
Multiplier = 1,
UpdatedAt = DateTime.UtcNow
};
private static async Task SeedAsync(IDbContextFactory<CoreDbContext> factory, params CorePosition[] rows)
{
await using var db = factory.CreateDbContext();
db.Positions.AddRange(rows);
await db.SaveChangesAsync();
}
// ─── Umhängen ────────────────────────────────────────────────────────────────
[Fact]
public async Task Reassign_MovesTheBook_ButNeverTheQuantity()
{
var sut = CreateSut(out var factory);
await SeedAsync(factory, Row(BookId.Unassigned.Value, 100, 230m));
var result = await sut.ReassignAsync(
Sap, BookId.Unassigned, BookId.Manual, "beim Aufräumen wiedererkannt");
result.Success.Should().BeTrue();
await using var db = factory.CreateDbContext();
var rows = await db.Positions.ToListAsync();
rows.Should().ContainSingle("es entsteht keine zweite Zeile");
rows[0].Module.Should().Be(BookId.Manual.Value);
rows[0].Quantity.Should().Be(100, "umgebucht wird die Zuordnung, nicht der Bestand");
rows[0].AvgPrice.Should().Be(230m, "ein neuer Einstand wäre eine erfundene Zahl");
}
[Fact]
public async Task Reassign_WithoutAReason_IsRefused()
{
// Eine Umbuchung ohne Begründung ist ein Monat später nicht mehr erklärbar – und sie ist
// der einzige Weg, auf dem Bestand ohne Order das Buch wechselt.
var sut = CreateSut(out var factory);
await SeedAsync(factory, Row(BookId.Unassigned.Value, 100, 230m));
var result = await sut.ReassignAsync(Sap, BookId.Unassigned, BookId.Manual, " ");
result.Success.Should().BeFalse();
result.Message.Should().Contain("Begründung");
}
[Fact]
public async Task Reassign_FromTheWrongBook_ChangesNothing()
{
// Das Quellbuch ist nicht Zierde, sondern Schutz: Wer sich in der Zeile vertut, soll eine
// Absage bekommen und nicht eine fremde Position verschieben.
var sut = CreateSut(out var factory);
await SeedAsync(factory, Row(BookId.Unassigned.Value, 100, 230m));
var result = await sut.ReassignAsync(Sap, Ct, BookId.Manual, "Tippfehler");
result.Success.Should().BeFalse();
result.Message.Should().Contain("CT");
await using var db = factory.CreateDbContext();
(await db.Positions.SingleAsync()).Module.Should().Be(BookId.Unassigned.Value);
}
[Fact]
public async Task Reassign_IntoABookThatAlreadyHoldsIt_MergesWithWeightedCost()
{
var sut = CreateSut(out var factory);
await SeedAsync(factory,
Row(BookId.Unassigned.Value, 100, 200m),
Row(BookId.Manual.Value, 100, 300m));
var result = await sut.ReassignAsync(Sap, BookId.Unassigned, BookId.Manual, "zusammenlegen");
result.Success.Should().BeTrue();
await using var db = factory.CreateDbContext();
var row = await db.Positions.SingleAsync();
row.Module.Should().Be(BookId.Manual.Value);
row.Quantity.Should().Be(200);
row.AvgPrice.Should().Be(250m, "mengengewichtet aus 100x200 und 100x300");
}
[Fact]
public async Task Reassign_AgainstAnOppositePosition_IsRefused_BecauseThatWouldBeATrade()
{
// 100 long nach einem Buch mit 100 short zu schieben, ergäbe eine Position von null – also
// eine Glattstellung. Die gehört über eine Order gemacht, mit Preis und Historie.
var sut = CreateSut(out var factory);
await SeedAsync(factory,
Row(BookId.Unassigned.Value, 100, 200m),
Row(BookId.Manual.Value, -100, 300m));
var result = await sut.ReassignAsync(Sap, BookId.Unassigned, BookId.Manual, "zusammenlegen");
result.Success.Should().BeFalse();
result.Message.Should().Contain("Glattstellung");
await using var db = factory.CreateDbContext();
(await db.Positions.CountAsync()).Should().Be(2, "es wurde nichts angefasst");
}
[Fact]
public async Task Reassign_ToTheSameBook_IsRefused()
{
var sut = CreateSut(out var factory);
await SeedAsync(factory, Row(BookId.Manual.Value, 100, 230m));
(await sut.ReassignAsync(Sap, BookId.Manual, BookId.Manual, "egal"))
.Success.Should().BeFalse();
}
// ─── Überblick ───────────────────────────────────────────────────────────────
[Fact]
public async Task Overview_WithoutABroker_LeavesTheComparisonEmpty_InsteadOfClaimingZero()
{
// Der wichtigste Fall der ganzen Ansicht: Ohne Verbindung darf keine Abweichung behauptet
// werden. Stünde dort "Broker: 0", sähe jede Störung aus wie ein leergeräumtes Konto.
var sut = CreateSut(out _);
_portfolio.GetAllPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Position>>([new Position("CT", Sap, 100, 230m)]));
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>([]));
var overview = await sut.GetOverviewAsync();
overview.BrokerReachable.Should().BeFalse();
overview.Positions.Should().ContainSingle();
overview.Positions[0].BrokerQuantity.Should().BeNull();
overview.Positions[0].Diverges.Should().BeFalse("eine Abweichung aus fehlenden Daten ist keine");
}
[Fact]
public async Task Overview_ShowsTheOrigin_AndFindsWhatNoBookClaims()
{
var sut = CreateSut(out _);
_portfolio.GetAllPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Position>>(
[new Position(BookId.Unassigned.Value, Sap, 100, 230m)]));
var alv = InstrumentRef.ForStock("ALV", "EUR");
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>(
[
new BrokerPosition { Symbol = "SAP", SecType = "STK", Currency = "EUR",
ConId = 1, Quantity = 100, AvgCost = 230m, Instrument = Sap },
new BrokerPosition { Symbol = "ALV", SecType = "STK", Currency = "EUR",
ConId = 2, Quantity = 5, AvgCost = 450m, Instrument = alv }
]));
var overview = await sut.GetOverviewAsync();
overview.BrokerReachable.Should().BeTrue();
overview.Positions[0].Origin.Should().Be("Herkunft unbekannt");
overview.Positions[0].Diverges.Should().BeFalse();
overview.UnknownToUs.Should().ContainSingle("ALV führt der Broker, wir in keinem Buch")
.Which.Symbol.Should().Be("ALV");
}
[Fact]
public async Task Overview_ReportsADivergenceWhenTheBrokerHoldsSomethingElse()
{
var sut = CreateSut(out _);
_portfolio.GetAllPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Position>>([new Position("CT", Sap, 100, 230m)]));
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>(
[new BrokerPosition { Symbol = "SAP", SecType = "STK", Currency = "EUR",
ConId = 1, Quantity = 60, AvgCost = 230m, Instrument = Sap }]));
var overview = await sut.GetOverviewAsync();
overview.Positions[0].BrokerQuantity.Should().Be(60m);
overview.Positions[0].Diverges.Should().BeTrue();
}
[Fact]
public async Task Overview_CarriesTheLockThrough()
{
var sut = CreateSut(out _);
_portfolio.GetAllPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Position>>([new Position("CT", Sap, 100, 230m)]));
_broker.GetPositionsAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<BrokerPosition>>([]));
await sut.SetLockAsync(Ct, Sap, PositionLock.CloseOnly, "nur noch abbauen");
var overview = await sut.GetOverviewAsync(includeBroker: false);
overview.Positions[0].Lock.Should().Be(PositionLock.CloseOnly);
overview.Positions[0].IsRestricted.Should().BeTrue();
}
}