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
@@ -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
};