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:
@@ -32,6 +32,13 @@ public static class CoreViews
|
|||||||
CreateView = () => new WorkersWindow(host, sp.GetRequiredService<WorkerEngine>())
|
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
|
host.RegisterView(new ModuleView
|
||||||
{
|
{
|
||||||
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
|
Id = "core.logs", Title = "Logs", Group = "Core", Order = 20,
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ public sealed record LogRow(string Text, IBrush Color);
|
|||||||
/// <summary>Eine offene Position im Modul-Fenster.</summary>
|
/// <summary>Eine offene Position im Modul-Fenster.</summary>
|
||||||
public sealed record PositionRow(string Symbol, int Quantity, decimal AvgPrice, decimal Notional);
|
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>
|
/// <summary>Eine Zeile der Supervisor-Berichtsliste. Tokens als fertiger Text (Prompt / Completion).</summary>
|
||||||
public sealed record SupervisorReportRow(
|
public sealed record SupervisorReportRow(
|
||||||
DateTime CreatedAt, string Profile, string Model, string Question, int ToolCallCount, string Tokens);
|
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;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using IBKRTrader.Core.Logging;
|
using IBKRTrader.Core.Logging;
|
||||||
|
using IBKRTrader.Core.Positions;
|
||||||
using IBKRTrader.Core.Settings;
|
using IBKRTrader.Core.Settings;
|
||||||
|
|
||||||
namespace IBKRTrader.Core.Workers.BuiltIn;
|
namespace IBKRTrader.Core.Workers.BuiltIn;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// WebApiService – permanenter REST-API-Service auf Basis von HttpListener.
|
/// 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>
|
/// </summary>
|
||||||
public class WebApiService : WorkerBase
|
public class WebApiService : WorkerBase
|
||||||
{
|
{
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
|
private readonly PositionManager _positions;
|
||||||
private WorkerEngine? _engine; // wird nach DI-Aufbau gesetzt (kein readonly)
|
private WorkerEngine? _engine; // wird nach DI-Aufbau gesetzt (kein readonly)
|
||||||
private HttpListener? _listener;
|
private HttpListener? _listener;
|
||||||
|
|
||||||
@@ -28,10 +36,12 @@ public class WebApiService : WorkerBase
|
|||||||
|
|
||||||
protected override TimeSpan? Interval => null;
|
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)
|
: base(logger, db)
|
||||||
{
|
{
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_positions = positions;
|
||||||
Info.Active = settings.Settings.Webserver.Enabled;
|
Info.Active = settings.Settings.Webserver.Enabled;
|
||||||
Info.RunEvery = "Service";
|
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)
|
private async Task RouteAsync(HttpListenerContext ctx)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -81,6 +135,7 @@ public class WebApiService : WorkerBase
|
|||||||
"/api/workers" => _engine?.WorkerInfos
|
"/api/workers" => _engine?.WorkerInfos
|
||||||
.Select(w => new { w.WorkerName, w.Module, w.Type,
|
.Select(w => new { w.WorkerName, w.Module, w.Type,
|
||||||
w.Info, w.Active }) ?? [],
|
w.Info, w.Active }) ?? [],
|
||||||
|
"/api/positions" => await PositionsAsync(ctx).ConfigureAwait(false),
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,13 @@ internal static class Program
|
|||||||
// Eine hängende Order zurücknehmen. Braucht --confirm: Stornieren ist ein Eingriff.
|
// Eine hängende Order zurücknehmen. Braucht --confirm: Stornieren ist ein Eingriff.
|
||||||
var cancelOrder = ArgAfter(args, "--cancel-order");
|
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();
|
var modules = AppHostBuilder.CreateModules();
|
||||||
using var host = AppHostBuilder.Build(modules);
|
using var host = AppHostBuilder.Build(modules);
|
||||||
|
|
||||||
@@ -94,6 +101,16 @@ internal static class Program
|
|||||||
if (reconcile)
|
if (reconcile)
|
||||||
return await RunReconcileAsync(host.Services, !HasFlag(args, "--dry-run")).ConfigureAwait(false);
|
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)
|
if (openOrders)
|
||||||
return await OrderBookTool.ListAsync(host.Services).ConfigureAwait(false);
|
return await OrderBookTool.ListAsync(host.Services).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ public static class AppHostBuilder
|
|||||||
CreateModules().Select(BookNameOf).Append(BookId.Diagnostics.Value)));
|
CreateModules().Select(BookNameOf).Append(BookId.Diagnostics.Value)));
|
||||||
services.AddSingleton<ReservationService>();
|
services.AddSingleton<ReservationService>();
|
||||||
services.AddSingleton<TradingRuleService>();
|
services.AddSingleton<TradingRuleService>();
|
||||||
|
services.AddSingleton<Core.Positions.PositionManager>();
|
||||||
services.AddSingleton<CapitalService>();
|
services.AddSingleton<CapitalService>();
|
||||||
services.AddSingleton<ICapitalService>(sp => sp.GetRequiredService<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user