Plattform-Fundament im Core: Vertraege, Registry, Global-Adapter (#623)
Neue neutrale Handelsvertraege (Maerkte, Orderbuch, Orders, Portfolio, Gebuehren) hinter IPlatformAdapter, aufgeloest ueber IPlatformRegistry je Konto. Polymarket Global ist eine duenne Huelle um die bestehenden Dienste; PolymarketClobClient bleibt unveraendert. Das Gateway lehnt ab, was die heutige Anbindung nicht sauber kann (Post-only, IOC/GTD, falsche Plattform), bevor etwas an den CLOB geht. core_accounts und core_markets bekommen die Spalte Platform (Vorgabe polymarket-global), core_markets den Schluessel (Platform, Id). core_positions bleibt: die Plattform folgt aus dem Konto. Noch nutzt kein Modul die Vertraege - das Handelsverhalten ist unveraendert. Vor dem ersten Start auf dem Server: dotnet ef database update --project src/PolyTrader.Core (Migration AddPlatformColumns). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
|
||||
namespace PolyTrader.Core.Platforms
|
||||
{
|
||||
/// <summary>
|
||||
/// Ein handelbarer Ausgang auf einer Plattform (#623) – der plattformneutrale Ersatz für die
|
||||
/// nackte <c>TokenId</c>.
|
||||
///
|
||||
/// <para><see cref="Key"/> ist innerhalb der Plattform eindeutig und das, was in
|
||||
/// <c>core_positions.TokenId</c> gespeichert wird. Die Spalte behält ihren Namen, damit die bestehenden
|
||||
/// Fundstellen schrittweise umziehen können.</para>
|
||||
/// <list type="table">
|
||||
/// <item><term>Polymarket Global</term><description>Key = tokenId, MarketKey = conditionId</description></item>
|
||||
/// <item><term>Polymarket US</term><description>Key = "{marketSlug}:YES" bzw. ":NO", MarketKey = marketSlug</description></item>
|
||||
/// <item><term>Kalshi</term><description>Key = "{ticker}:YES" bzw. ":NO", MarketKey = ticker</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Gleichheit hängt nur an Plattform und <see cref="Key"/>. <see cref="MarketKey"/> und
|
||||
/// <see cref="Outcome"/> sind Beiwerk, das nicht jeder Aufrufer kennt.</para>
|
||||
/// </summary>
|
||||
public sealed record InstrumentRef(PlatformId Platform, string Key, string MarketKey = "", string Outcome = "")
|
||||
{
|
||||
public string Key { get; init; } = !string.IsNullOrWhiteSpace(Key)
|
||||
? Key
|
||||
: throw new ArgumentException("Instrument-Schlüssel darf nicht leer sein.", nameof(Key));
|
||||
|
||||
/// <summary>Ausgang auf Polymarket Global über seine Token-ID.</summary>
|
||||
public static InstrumentRef PolymarketGlobal(string tokenId, string conditionId = "", string outcome = "") =>
|
||||
new(PlatformId.PolymarketGlobal, tokenId, conditionId, outcome);
|
||||
|
||||
public bool Equals(InstrumentRef? other) =>
|
||||
other is not null && Platform == other.Platform && string.Equals(Key, other.Key, StringComparison.Ordinal);
|
||||
|
||||
public override int GetHashCode() => HashCode.Combine(Platform, Key);
|
||||
|
||||
public override string ToString() => $"{Platform}:{Key}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PolyTrader.Core.Platforms
|
||||
{
|
||||
/// <summary>Gültigkeit einer Order, plattformneutral. Jeder Adapter übersetzt in seine eigenen Begriffe.</summary>
|
||||
public enum TimeInForce
|
||||
{
|
||||
/// <summary>Bleibt im Buch, bis ausgeführt oder storniert.</summary>
|
||||
GoodTillCancel,
|
||||
/// <summary>Ganz oder gar nicht, sofort.</summary>
|
||||
FillOrKill,
|
||||
/// <summary>Sofort so viel wie möglich, Rest verfällt.</summary>
|
||||
ImmediateOrCancel,
|
||||
/// <summary>Bleibt im Buch bis zu einem Zeitpunkt.</summary>
|
||||
GoodTillDate,
|
||||
/// <summary>Bleibt im Buch bis Handelsschluss des Tages.</summary>
|
||||
Day
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Was ein Handelsplatz kann (#623). Logik fragt nach Fähigkeiten, nicht nach dem Namen der Plattform:
|
||||
/// <c>if (caps.PublicTraderActivity)</c> statt <c>if (platform == PolymarketGlobal)</c>. Ein neuer Adapter
|
||||
/// bringt seine Fähigkeiten mit, und bestehende Module entscheiden richtig, ohne ihn zu kennen.
|
||||
/// </summary>
|
||||
public sealed record PlatformCapabilities
|
||||
{
|
||||
/// <summary>Trades fremder Konten sind öffentlich beobachtbar – Voraussetzung für CopyTrading.</summary>
|
||||
public bool PublicTraderActivity { get; init; }
|
||||
|
||||
/// <summary>Abwicklung auf einer Blockchain: gewonnene Positionen müssen eingelöst werden (Redeem, #146).</summary>
|
||||
public bool OnChainSettlement { get; init; }
|
||||
|
||||
/// <summary>Orders, die abgelehnt werden, statt sofort gegen das Buch auszuführen.</summary>
|
||||
public bool PostOnlyOrders { get; init; }
|
||||
|
||||
/// <summary>Preis/Menge einer offenen Order ändern, ohne sie zu stornieren und neu zu platzieren.</summary>
|
||||
public bool OrderModify { get; init; }
|
||||
|
||||
/// <summary>Maker bekommen eine Gutschrift statt einer Gebühr.</summary>
|
||||
public bool MakerRebate { get; init; }
|
||||
|
||||
/// <summary>Mengen unter einem ganzen Kontrakt/Share sind handelbar.</summary>
|
||||
public bool FractionalQuantity { get; init; }
|
||||
|
||||
/// <summary>Die Plattform bietet eine eigene Testumgebung mit Spielgeld.</summary>
|
||||
public bool NativeDemoEnvironment { get; init; }
|
||||
|
||||
/// <summary>Welche Gültigkeiten der Adapter annimmt. Alles andere lehnt er ab.</summary>
|
||||
public IReadOnlySet<TimeInForce> TimeInForce { get; init; } = new HashSet<TimeInForce>();
|
||||
|
||||
/// <summary>Währung, in der Salden und Auszahlungen geführt werden, z. B. "USDC" oder "USD".</summary>
|
||||
public string SettlementCurrency { get; init; } = "USD";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTrader.Core.Platforms
|
||||
{
|
||||
// Handelsverträge je Plattform (#623). Module sprechen gegen diese Verträge, nicht gegen
|
||||
// Polymarket*-Klassen. Aufgelöst werden sie über IPlatformRegistry, nie direkt.
|
||||
//
|
||||
// Bewusst noch NICHT hier: Echtzeit-Streams (Markt/Konto). Der einzige Stream-Nutzer ist heute
|
||||
// CopyTrading mit seinem eigenen WSS-Client; der Vertrag entsteht mit dem ersten zweiten Nutzer
|
||||
// (#625, Polymarket-US-WebSockets), statt auf Vorrat.
|
||||
|
||||
/// <summary>Märkte finden und ihren Auflösungsstand abfragen.</summary>
|
||||
public interface IMarketCatalog
|
||||
{
|
||||
/// <summary>Die zuletzt angelegten Märkte, neueste zuerst.</summary>
|
||||
Task<IReadOnlyList<PlatformMarket>> GetRecentMarketsAsync(int limit, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Der Markt, zu dem ein Ausgang gehört; <c>null</c>, wenn unbekannt oder bei Fehler.</summary>
|
||||
Task<PlatformMarket?> GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default);
|
||||
|
||||
Task<MarketResolution> GetResolutionAsync(InstrumentRef instrument, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Preise aus dem Orderbuch.</summary>
|
||||
public interface IOrderBookSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Bester Preis, zu dem man sofort handeln kann: für <see cref="OrderSide.Buy"/> der niedrigste Ask,
|
||||
/// für <see cref="OrderSide.Sell"/> das höchste Bid. <c>null</c> bei leerem Buch oder Fehler.
|
||||
/// </summary>
|
||||
Task<decimal?> GetBestPriceAsync(InstrumentRef instrument, OrderSide side, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Orders platzieren, abfragen, stornieren. Der geldkritische Vertrag.</summary>
|
||||
public interface IOrderGateway
|
||||
{
|
||||
Task<OrderResult> PlaceAsync(AccountState account, OrderRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Offene Orders; mit <paramref name="instrument"/> nur für diesen Ausgang.</summary>
|
||||
Task<IReadOnlyList<OpenOrder>> GetOpenOrdersAsync(AccountState account, InstrumentRef? instrument = null, CancellationToken ct = default);
|
||||
|
||||
Task<bool> CancelAsync(AccountState account, string orderId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Saldo und Positionen des eigenen Kontos.</summary>
|
||||
public interface IPortfolioSource
|
||||
{
|
||||
/// <summary>Für Orders verfügbares Guthaben in der Abwicklungswährung der Plattform.</summary>
|
||||
Task<decimal> GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Offene Positionen; <c>null</c> bei Fehler (nicht zu verwechseln mit „keine Positionen").</summary>
|
||||
Task<IReadOnlyList<PlatformPosition>?> GetPositionsAsync(AccountState account, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gebühren der Plattform. Die Form unterscheidet sich je Plattform (Global: Satz in bps auf das Notional;
|
||||
/// US: Θ·C·p·(1−p)) – deshalb eine Rechnung, kein Satz.
|
||||
/// </summary>
|
||||
public interface IFeeModel
|
||||
{
|
||||
/// <summary>Gebühr in der Abwicklungswährung; negativ bei Maker-Gutschrift.</summary>
|
||||
decimal EstimateFee(decimal price, decimal quantity, LiquidityRole role, FeeContext context);
|
||||
}
|
||||
|
||||
/// <summary>Ein Handelsplatz mit allen seinen Verträgen.</summary>
|
||||
public interface IPlatformAdapter
|
||||
{
|
||||
PlatformId Id { get; }
|
||||
PlatformCapabilities Capabilities { get; }
|
||||
IMarketCatalog Markets { get; }
|
||||
IOrderBookSource OrderBook { get; }
|
||||
IOrderGateway Orders { get; }
|
||||
IPortfolioSource Portfolio { get; }
|
||||
IFeeModel Fees { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PolyTrader.Core.Platforms
|
||||
{
|
||||
/// <summary>
|
||||
/// Kennung eines Handelsplatzes (#623). Der Wert wird in der Datenbank gespeichert
|
||||
/// (<c>core_accounts.Platform</c>, <c>core_markets.Platform</c>) – bestehende Werte daher nie umbenennen.
|
||||
///
|
||||
/// <para>Die Plattform gehört zum Konto, nicht zur Installation: Eine Installation kann Konten
|
||||
/// mehrerer Plattformen führen. Konzept: docs/archiv/konzepte/KONZEPT-Multi-Plattform.md.</para>
|
||||
/// </summary>
|
||||
public readonly record struct PlatformId
|
||||
{
|
||||
public const string PolymarketGlobalValue = "polymarket-global";
|
||||
public const string PolymarketUsValue = "polymarket-us";
|
||||
public const string KalshiValue = "kalshi";
|
||||
|
||||
public static readonly PlatformId PolymarketGlobal = new(PolymarketGlobalValue);
|
||||
public static readonly PlatformId PolymarketUs = new(PolymarketUsValue);
|
||||
public static readonly PlatformId Kalshi = new(KalshiValue);
|
||||
|
||||
/// <summary>Alle Plattformen, die PolyTrader dem Namen nach kennt – nicht alle haben schon einen Adapter.</summary>
|
||||
public static IReadOnlyList<PlatformId> Known { get; } = new[] { PolymarketGlobal, PolymarketUs, Kalshi };
|
||||
|
||||
public PlatformId(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("Plattform-Kennung darf nicht leer sein.", nameof(value));
|
||||
Value = value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Liest eine gespeicherte Kennung. Leer oder <c>null</c> ergibt Polymarket Global – das ist der
|
||||
/// Stand aller Konten und Märkte, die vor #623 angelegt wurden.
|
||||
/// </summary>
|
||||
public static PlatformId Parse(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? PolymarketGlobal : new PlatformId(value);
|
||||
|
||||
/// <summary>Anzeigename für Oberflächen und Logs.</summary>
|
||||
public string DisplayName => Value switch
|
||||
{
|
||||
PolymarketGlobalValue => "Polymarket",
|
||||
PolymarketUsValue => "Polymarket US",
|
||||
KalshiValue => "Kalshi",
|
||||
_ => Value
|
||||
};
|
||||
|
||||
public override string ToString() => Value ?? PolymarketGlobalValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PolyTrader.Core.Platforms
|
||||
{
|
||||
public enum OrderSide { Buy, Sell }
|
||||
|
||||
/// <summary>Rolle bei einer Ausführung – bestimmt die Gebühr.</summary>
|
||||
public enum LiquidityRole { Taker, Maker }
|
||||
|
||||
/// <summary>
|
||||
/// Eine Order, plattformneutral (#623). Preise sind immer Wahrscheinlichkeiten 0..1 – ein Adapter,
|
||||
/// dessen Plattform in Cent rechnet, rechnet selbst um.
|
||||
///
|
||||
/// <para>Genau eine der beiden Mengenangaben ist gesetzt: <see cref="Quantity"/> (Kontrakte/Shares) oder
|
||||
/// <see cref="NotionalUsd"/> (Betrag). Plattformspezifisches (Polymarket Global: negRisk, Tick-Size,
|
||||
/// Gebühr in der Signatur) bleibt im Adapter.</para>
|
||||
/// </summary>
|
||||
public sealed record OrderRequest
|
||||
{
|
||||
public required InstrumentRef Instrument { get; init; }
|
||||
public required OrderSide Side { get; init; }
|
||||
public required decimal LimitPrice { get; init; }
|
||||
public decimal? Quantity { get; init; }
|
||||
public decimal? NotionalUsd { get; init; }
|
||||
public TimeInForce TimeInForce { get; init; } = TimeInForce.FillOrKill;
|
||||
public bool PostOnly { get; init; }
|
||||
|
||||
/// <summary>Welches Modul die Order auslöst – für Journal und die Plattform-Sperre (#624).</summary>
|
||||
public string OriginModule { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Korrelation zum Entscheidungsjournal (SignalId).</summary>
|
||||
public string CorrelationId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public enum OrderOutcome
|
||||
{
|
||||
/// <summary>Die Plattform hat die Order angenommen.</summary>
|
||||
Accepted,
|
||||
/// <summary>Nicht ausgeführt, weil die Gegenseite nicht reichte (z. B. FOK ohne Tiefe). Kein Fehler.</summary>
|
||||
NotFilled,
|
||||
/// <summary>Vom Adapter vor dem Senden abgelehnt (unzulässige Anfrage) – es ging nichts an die Plattform.</summary>
|
||||
Invalid,
|
||||
/// <summary>Von der Plattform abgelehnt oder technischer Fehler.</summary>
|
||||
Rejected
|
||||
}
|
||||
|
||||
public sealed record OrderResult(OrderOutcome Outcome, string Message, string? OrderId = null)
|
||||
{
|
||||
public bool IsAccepted => Outcome == OrderOutcome.Accepted;
|
||||
|
||||
public static OrderResult Invalid(string message) => new(OrderOutcome.Invalid, message);
|
||||
}
|
||||
|
||||
/// <summary>Offene Order. <see cref="Instrument"/> ist <c>null</c>, wenn die Plattform es in der Liste nicht mitliefert.</summary>
|
||||
public sealed record OpenOrder(string Id, OrderSide Side, decimal Price, InstrumentRef? Instrument = null);
|
||||
|
||||
/// <summary>Ein Ausgang eines Marktes mit seinem Namen ("Yes", "No", "Lakers" …).</summary>
|
||||
public sealed record PlatformOutcome(string Name, InstrumentRef Instrument);
|
||||
|
||||
/// <summary>Markt-Metadaten, plattformneutral.</summary>
|
||||
public sealed record PlatformMarket
|
||||
{
|
||||
public required PlatformId Platform { get; init; }
|
||||
/// <summary>Marktschlüssel der Plattform (Global: conditionId, US: Slug, Kalshi: Ticker).</summary>
|
||||
public required string MarketKey { get; init; }
|
||||
public string Slug { get; init; } = string.Empty;
|
||||
public string Question { get; init; } = string.Empty;
|
||||
public string Category { get; init; } = string.Empty;
|
||||
public DateTime? EndDateUtc { get; init; }
|
||||
public bool Active { get; init; }
|
||||
public bool Closed { get; init; }
|
||||
public IReadOnlyList<PlatformOutcome> Outcomes { get; init; } = Array.Empty<PlatformOutcome>();
|
||||
}
|
||||
|
||||
/// <summary>Auflösungsstand eines Ausgangs.</summary>
|
||||
public sealed record MarketResolution(bool IsClosed, bool IsWinner)
|
||||
{
|
||||
public static readonly MarketResolution Open = new(false, false);
|
||||
}
|
||||
|
||||
/// <summary>Eigene Position laut Plattform.</summary>
|
||||
public sealed record PlatformPosition
|
||||
{
|
||||
public required InstrumentRef Instrument { get; init; }
|
||||
public decimal Size { get; init; }
|
||||
public decimal AveragePrice { get; init; }
|
||||
public decimal CurrentPrice { get; init; }
|
||||
public decimal CostUsd { get; init; }
|
||||
public decimal CurrentValueUsd { get; init; }
|
||||
public string Slug { get; init; } = string.Empty;
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public DateTime? EndDateUtc { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Was die Gebührenberechnung über den Markt wissen muss. Jedes Feld ist optional.</summary>
|
||||
public sealed record FeeContext
|
||||
{
|
||||
public string? Category { get; init; }
|
||||
/// <summary>Gebührensatz, den die Plattform für diesen Markt meldet (Polymarket Global: bps). 0 = unbekannt.</summary>
|
||||
public int MarketFeeBps { get; init; }
|
||||
|
||||
public static readonly FeeContext None = new();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTrader.Core.Platforms
|
||||
{
|
||||
/// <summary>Löst den Adapter einer Plattform auf – der einzige Weg an Handelsverträge (#623).</summary>
|
||||
public interface IPlatformRegistry
|
||||
{
|
||||
/// <summary>Plattformen, für die ein Adapter registriert ist.</summary>
|
||||
IReadOnlyList<PlatformId> Available { get; }
|
||||
|
||||
bool IsAvailable(PlatformId platform);
|
||||
|
||||
/// <summary>Wirft, wenn für die Plattform kein Adapter registriert ist.</summary>
|
||||
IPlatformAdapter Get(PlatformId platform);
|
||||
|
||||
bool TryGet(PlatformId platform, out IPlatformAdapter? adapter);
|
||||
|
||||
/// <summary>Adapter der Plattform, zu der das Konto gehört.</summary>
|
||||
IPlatformAdapter ForAccount(AccountState account);
|
||||
}
|
||||
|
||||
public sealed class PlatformRegistry : IPlatformRegistry
|
||||
{
|
||||
private readonly Dictionary<PlatformId, IPlatformAdapter> _adapters;
|
||||
|
||||
public PlatformRegistry(IEnumerable<IPlatformAdapter> adapters)
|
||||
{
|
||||
_adapters = new Dictionary<PlatformId, IPlatformAdapter>();
|
||||
foreach (var adapter in adapters ?? throw new ArgumentNullException(nameof(adapters)))
|
||||
{
|
||||
if (!_adapters.TryAdd(adapter.Id, adapter))
|
||||
throw new InvalidOperationException($"Für die Plattform '{adapter.Id}' ist mehr als ein Adapter registriert.");
|
||||
}
|
||||
Available = _adapters.Keys.ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<PlatformId> Available { get; }
|
||||
|
||||
public bool IsAvailable(PlatformId platform) => _adapters.ContainsKey(platform);
|
||||
|
||||
public IPlatformAdapter Get(PlatformId platform) =>
|
||||
_adapters.TryGetValue(platform, out var adapter)
|
||||
? adapter
|
||||
: throw new InvalidOperationException(
|
||||
$"Für die Plattform '{platform.DisplayName}' ist in dieser Installation kein Adapter vorhanden.");
|
||||
|
||||
public bool TryGet(PlatformId platform, out IPlatformAdapter? adapter) =>
|
||||
_adapters.TryGetValue(platform, out adapter);
|
||||
|
||||
public IPlatformAdapter ForAccount(AccountState account) =>
|
||||
Get(PlatformId.Parse((account ?? throw new ArgumentNullException(nameof(account))).Platform));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.Core.Persistence;
|
||||
using PolyTrader.Core.Platforms.PolymarketGlobal;
|
||||
using PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTrader.Core.Platforms
|
||||
{
|
||||
public static class PlatformServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registriert die Plattform-Adapter und die Registry (#623). Setzt voraus, dass
|
||||
/// <see cref="PolymarketApiService"/>, <see cref="PolymarketClobClient"/> und die Core-Persistenz
|
||||
/// schon registriert sind.
|
||||
///
|
||||
/// <para>Der Global-Adapter nutzt denselben <see cref="PolymarketClobClient"/>-Singleton wie die Module –
|
||||
/// bewusst ohne Umweg über die <c>IClobClient</c>-Registrierung des CopyTrading-Moduls, die fehlt, wenn
|
||||
/// CopyTrading abgeschaltet ist.</para>
|
||||
/// </summary>
|
||||
public static IServiceCollection AddPlatforms(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IPlatformAdapter>(sp =>
|
||||
{
|
||||
var api = sp.GetRequiredService<PolymarketApiService>();
|
||||
var clob = sp.GetRequiredService<PolymarketClobClient>();
|
||||
return new PolymarketGlobalAdapter(
|
||||
new GlobalMarketCatalog(api),
|
||||
new GlobalOrderBookSource(api),
|
||||
new GlobalOrderGateway(clob, sp.GetRequiredService<IMarketRepository>()),
|
||||
new GlobalPortfolioSource(clob, api),
|
||||
new GlobalFeeModel());
|
||||
});
|
||||
|
||||
services.AddSingleton<IPlatformRegistry>(sp => new PlatformRegistry(sp.GetServices<IPlatformAdapter>()));
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PolyTrader.Core.Trading;
|
||||
using PolyTraderSharp.Models;
|
||||
using PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTrader.Core.Platforms.PolymarketGlobal
|
||||
{
|
||||
/// <summary>Marktkatalog über Gamma-API (#623) – Hülle um <see cref="PolymarketApiService"/>.</summary>
|
||||
public sealed class GlobalMarketCatalog : IMarketCatalog
|
||||
{
|
||||
private readonly PolymarketApiService _api;
|
||||
|
||||
public GlobalMarketCatalog(PolymarketApiService api) => _api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
|
||||
public async Task<IReadOnlyList<PlatformMarket>> GetRecentMarketsAsync(int limit, CancellationToken ct = default)
|
||||
{
|
||||
var markets = await _api.GetRecentMarketsAsync(limit);
|
||||
return markets.Select(ToPlatformMarket).ToList();
|
||||
}
|
||||
|
||||
public async Task<PlatformMarket?> GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default)
|
||||
{
|
||||
EnsureGlobal(instrument);
|
||||
var market = await _api.GetMarketByTokenIdAsync(instrument.Key);
|
||||
return market == null ? null : ToPlatformMarket(market);
|
||||
}
|
||||
|
||||
public async Task<MarketResolution> GetResolutionAsync(InstrumentRef instrument, CancellationToken ct = default)
|
||||
{
|
||||
EnsureGlobal(instrument);
|
||||
// Der Slug wird von CheckMarketResolutionAsync nicht ausgewertet; maßgeblich ist die Token-ID.
|
||||
var (isResolved, isWinner) = await _api.CheckMarketResolutionAsync(instrument.MarketKey, instrument.Key);
|
||||
return new MarketResolution(isResolved, isWinner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Übersetzt die gespeicherten Global-Metadaten. <c>ClobTokenIds</c> und <c>Outcomes</c> sind JSON-Arrays
|
||||
/// als Text und gehören positionsweise zusammen; fehlt ein Name, heißt der Ausgang nach seiner Stelle.
|
||||
/// </summary>
|
||||
internal static PlatformMarket ToPlatformMarket(MarketData md)
|
||||
{
|
||||
var tokenIds = ParseStringArray(md.ClobTokenIds);
|
||||
var names = ParseStringArray(md.Outcomes);
|
||||
string marketKey = !string.IsNullOrEmpty(md.ConditionId) ? md.ConditionId : md.Id;
|
||||
|
||||
var outcomes = tokenIds
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select((id, i) =>
|
||||
{
|
||||
string name = i < names.Count ? names[i] : $"Ausgang {i + 1}";
|
||||
return new PlatformOutcome(name, InstrumentRef.PolymarketGlobal(id, marketKey, name));
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new PlatformMarket
|
||||
{
|
||||
Platform = PlatformId.PolymarketGlobal,
|
||||
MarketKey = marketKey,
|
||||
Slug = md.Slug,
|
||||
Question = md.Question,
|
||||
Category = md.Category,
|
||||
EndDateUtc = md.EndDate,
|
||||
Active = md.Active,
|
||||
Closed = md.Closed,
|
||||
Outcomes = outcomes
|
||||
};
|
||||
}
|
||||
|
||||
internal static List<string> ParseStringArray(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new List<string>();
|
||||
return doc.RootElement.EnumerateArray()
|
||||
.Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() ?? string.Empty : e.ToString())
|
||||
.ToList();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
internal static void EnsureGlobal(InstrumentRef instrument)
|
||||
{
|
||||
if (instrument == null) throw new ArgumentNullException(nameof(instrument));
|
||||
if (instrument.Platform != PlatformId.PolymarketGlobal)
|
||||
throw new ArgumentException($"Instrument gehört zu '{instrument.Platform}', nicht zu Polymarket Global.", nameof(instrument));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bester Preis aus dem CLOB-Orderbuch (#623) – Hülle um <see cref="PolymarketApiService.GetOrderBookPriceAsync"/>.</summary>
|
||||
public sealed class GlobalOrderBookSource : IOrderBookSource
|
||||
{
|
||||
private readonly PolymarketApiService _api;
|
||||
|
||||
public GlobalOrderBookSource(PolymarketApiService api) => _api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
|
||||
public Task<decimal?> GetBestPriceAsync(InstrumentRef instrument, OrderSide side, CancellationToken ct = default)
|
||||
{
|
||||
GlobalMarketCatalog.EnsureGlobal(instrument);
|
||||
// GetOrderBookPriceAsync("BUY") liefert den besten Ask, ("SELL") das beste Bid – genau der Vertrag.
|
||||
return _api.GetOrderBookPriceAsync(instrument.Key, GlobalOrderGateway.SideString(side));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saldo und Positionen (#623): Saldo aus dem CLOB (<c>/balance-allowance</c>, das für Orders verfügbare
|
||||
/// Collateral), Positionen aus der Data-API.
|
||||
/// </summary>
|
||||
public sealed class GlobalPortfolioSource : IPortfolioSource
|
||||
{
|
||||
private readonly PolymarketClobClient _clob;
|
||||
private readonly PolymarketApiService _api;
|
||||
|
||||
public GlobalPortfolioSource(PolymarketClobClient clob, PolymarketApiService api)
|
||||
{
|
||||
_clob = clob ?? throw new ArgumentNullException(nameof(clob));
|
||||
_api = api ?? throw new ArgumentNullException(nameof(api));
|
||||
}
|
||||
|
||||
public Task<decimal> GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default) =>
|
||||
_clob.GetUsdcBalanceAsync(account ?? throw new ArgumentNullException(nameof(account)));
|
||||
|
||||
public async Task<IReadOnlyList<PlatformPosition>?> GetPositionsAsync(AccountState account, CancellationToken ct = default)
|
||||
{
|
||||
if (account == null) throw new ArgumentNullException(nameof(account));
|
||||
var raw = await _api.SyncOpenPositionsAsync(account.WalletAddress);
|
||||
// SyncOpenPositionsAsync liefert null sowohl bei Fehler als auch bei null Positionen, außer die
|
||||
// Wallet ist leer (dann leere Liste). Der Vertrag kennt nur „Fehler = null" – das übernehmen wir.
|
||||
if (raw == null) return null;
|
||||
return raw.Select(ToPosition).Where(p => p != null).Select(p => p!).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Übersetzt einen Eintrag von data-api.polymarket.com/positions. Ohne <c>asset</c> kein Ergebnis.</summary>
|
||||
internal static PlatformPosition? ToPosition(JsonElement el)
|
||||
{
|
||||
string asset = Str(el, "asset");
|
||||
if (string.IsNullOrEmpty(asset)) return null;
|
||||
|
||||
DateTime? endDate = null;
|
||||
if (DateTime.TryParse(Str(el, "endDate"), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var ed))
|
||||
endDate = ed;
|
||||
|
||||
return new PlatformPosition
|
||||
{
|
||||
Instrument = InstrumentRef.PolymarketGlobal(asset, Str(el, "conditionId"), Str(el, "outcome")),
|
||||
Size = Dec(el, "size"),
|
||||
AveragePrice = Dec(el, "avgPrice"),
|
||||
CurrentPrice = Dec(el, "curPrice"),
|
||||
CostUsd = Dec(el, "initialValue"),
|
||||
CurrentValueUsd = Dec(el, "currentValue"),
|
||||
Slug = Str(el, "slug"),
|
||||
Title = Str(el, "title"),
|
||||
EndDateUtc = endDate
|
||||
};
|
||||
}
|
||||
|
||||
private static string Str(JsonElement el, string name) =>
|
||||
el.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String ? p.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
// Die Data-API liefert Zahlen mal als Zahl, mal als Text.
|
||||
private static decimal Dec(JsonElement el, string name)
|
||||
{
|
||||
if (!el.TryGetProperty(name, out var p)) return 0m;
|
||||
if (p.ValueKind == JsonValueKind.Number && p.TryGetDecimal(out var d)) return d;
|
||||
if (p.ValueKind == JsonValueKind.String &&
|
||||
decimal.TryParse(p.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var s)) return s;
|
||||
return 0m;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gebühren von Polymarket Global (#623) – Hülle um <see cref="FeeModel"/>: Taker zahlt den Satz des
|
||||
/// Marktes (bps auf das Notional), sonst den Kategorie-Fallback; Maker zahlen nichts.
|
||||
/// </summary>
|
||||
public sealed class GlobalFeeModel : IFeeModel
|
||||
{
|
||||
public decimal EstimateFee(decimal price, decimal quantity, LiquidityRole role, FeeContext context)
|
||||
{
|
||||
if (role == LiquidityRole.Maker) return 0m;
|
||||
int bps = FeeModel.ResolveBps(context?.MarketFeeBps ?? 0, context?.Category);
|
||||
return FeeModel.FeeUsd(price * quantity, bps);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PolyTrader.Core.Persistence;
|
||||
using PolyTrader.Core.Trading;
|
||||
using PolyTraderSharp.Models;
|
||||
using PolyTraderSharp.Services;
|
||||
|
||||
namespace PolyTrader.Core.Platforms.PolymarketGlobal
|
||||
{
|
||||
/// <summary>
|
||||
/// Order-Gateway für Polymarket Global (#623): übersetzt eine neutrale <see cref="OrderRequest"/> in einen
|
||||
/// Aufruf des bestehenden <see cref="IClobClient"/> – und sonst nichts.
|
||||
///
|
||||
/// <para><b>Hülle, kein Umbau.</b> Signierung, Betragsrechnung, Tick-Size-/Fee-Nachregelung und alle
|
||||
/// Wiederholungen bleiben in <see cref="PolymarketClobClient"/> (.agents/rules/clob.md). Diese Klasse
|
||||
/// ergänzt nur, was die Module heute selbst vor dem Aufruf bestimmen: negRisk und Gebührensatz aus den
|
||||
/// Markt-Metadaten – nach demselben Muster wie die SELL-Leiter (<c>FeeModel.ResolveBps</c>, Fallback bei
|
||||
/// unbekanntem Markt).</para>
|
||||
/// </summary>
|
||||
public sealed class GlobalOrderGateway : IOrderGateway
|
||||
{
|
||||
private readonly IClobClient _clob;
|
||||
private readonly IMarketRepository _markets;
|
||||
|
||||
public GlobalOrderGateway(IClobClient clob, IMarketRepository markets)
|
||||
{
|
||||
_clob = clob ?? throw new ArgumentNullException(nameof(clob));
|
||||
_markets = markets ?? throw new ArgumentNullException(nameof(markets));
|
||||
}
|
||||
|
||||
public async Task<OrderResult> PlaceAsync(AccountState account, OrderRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (account == null) throw new ArgumentNullException(nameof(account));
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
|
||||
string? invalid = Validate(account, request);
|
||||
if (invalid != null) return OrderResult.Invalid(invalid);
|
||||
|
||||
decimal investAmountUsd = request.Quantity.HasValue
|
||||
? request.Quantity.Value * request.LimitPrice
|
||||
: request.NotionalUsd!.Value;
|
||||
|
||||
// Wie die Module vor #623: negRisk und Gebühr aus den Markt-Metadaten, sonst Kategorie-Fallback.
|
||||
MarketData? market = _markets.FindByTokenId(request.Instrument.Key);
|
||||
bool isNegRisk = market?.NegRisk ?? false;
|
||||
int feeBps = market != null ? FeeModel.ResolveBps(market.TakerFeeBps, market.Category) : FeeModel.FallbackBps(null);
|
||||
|
||||
string response = await _clob.PlaceOrderAsync(
|
||||
account,
|
||||
request.Instrument.Key,
|
||||
SideString(request.Side),
|
||||
investAmountUsd,
|
||||
request.LimitPrice,
|
||||
OrderTypeString(request.TimeInForce),
|
||||
debugPayloadLog: false,
|
||||
isNegRisk: isNegRisk,
|
||||
actualFeeBps: feeBps);
|
||||
|
||||
return MapResponse(response);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OpenOrder>> GetOpenOrdersAsync(AccountState account, InstrumentRef? instrument = null, CancellationToken ct = default)
|
||||
{
|
||||
if (account == null) throw new ArgumentNullException(nameof(account));
|
||||
if (instrument != null && instrument.Platform != PlatformId.PolymarketGlobal)
|
||||
throw new ArgumentException($"Instrument gehört zu '{instrument.Platform}', nicht zu Polymarket Global.", nameof(instrument));
|
||||
|
||||
var orders = await _clob.GetOpenOrdersAsync(account, instrument?.Key ?? string.Empty);
|
||||
return orders
|
||||
.Select(o => new OpenOrder(o.Id, ParseSide(o.Side), o.Price, instrument))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task<bool> CancelAsync(AccountState account, string orderId, CancellationToken ct = default)
|
||||
{
|
||||
if (account == null) throw new ArgumentNullException(nameof(account));
|
||||
return _clob.CancelOrderAsync(account, orderId);
|
||||
}
|
||||
|
||||
/// <summary>Prüft, was vor dem Senden feststehen muss. <c>null</c> = zulässig.</summary>
|
||||
internal static string? Validate(AccountState account, OrderRequest request)
|
||||
{
|
||||
if (request.Instrument.Platform != PlatformId.PolymarketGlobal)
|
||||
return $"Instrument gehört zu '{request.Instrument.Platform.DisplayName}', nicht zu Polymarket.";
|
||||
if (PlatformId.Parse(account.Platform) != PlatformId.PolymarketGlobal)
|
||||
return $"Konto '{account.Name}' gehört zu '{PlatformId.Parse(account.Platform).DisplayName}', nicht zu Polymarket.";
|
||||
if (request.PostOnly)
|
||||
return "Post-only-Orders unterstützt Polymarket nicht.";
|
||||
if (!PolymarketGlobalAdapter.CapabilitiesValue.TimeInForce.Contains(request.TimeInForce))
|
||||
return $"Gültigkeit {request.TimeInForce} unterstützt Polymarket nicht.";
|
||||
if (request.LimitPrice <= 0m || request.LimitPrice >= 1m)
|
||||
return $"Limitpreis {request.LimitPrice} liegt nicht zwischen 0 und 1.";
|
||||
if (request.Quantity.HasValue == request.NotionalUsd.HasValue)
|
||||
return "Genau eine Mengenangabe setzen: Stückzahl oder Betrag.";
|
||||
if ((request.Quantity ?? request.NotionalUsd) <= 0m)
|
||||
return "Menge muss größer als 0 sein.";
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string SideString(OrderSide side) => side == OrderSide.Buy ? "BUY" : "SELL";
|
||||
|
||||
internal static OrderSide ParseSide(string side) =>
|
||||
string.Equals(side, "SELL", StringComparison.OrdinalIgnoreCase) ? OrderSide.Sell : OrderSide.Buy;
|
||||
|
||||
internal static string OrderTypeString(TimeInForce tif) => tif switch
|
||||
{
|
||||
TimeInForce.GoodTillCancel => "GTC",
|
||||
TimeInForce.FillOrKill => "FOK",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tif), tif, "Von Polymarket nicht unterstützt.")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Übersetzt die Antwort des CLOB-Clients. Er liefert "OK", "SKIPPED_LIQUIDITY" (FOK-Kauf ohne Tiefe,
|
||||
/// bewusst leise) oder einen Fehlertext.
|
||||
/// </summary>
|
||||
internal static OrderResult MapResponse(string? response) => response switch
|
||||
{
|
||||
"OK" => new OrderResult(OrderOutcome.Accepted, "OK"),
|
||||
"SKIPPED_LIQUIDITY" => new OrderResult(OrderOutcome.NotFilled, "Nicht genügend Liquidität (FOK)."),
|
||||
null or "" => new OrderResult(OrderOutcome.Rejected, "Keine Antwort vom CLOB."),
|
||||
_ => new OrderResult(OrderOutcome.Rejected, response)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PolyTrader.Core.Platforms.PolymarketGlobal
|
||||
{
|
||||
/// <summary>
|
||||
/// Polymarket Global (polymarket.com, CLOB auf Polygon) als Plattform-Adapter (#623). Bündelt die dünnen
|
||||
/// Hüllen um die bestehenden Dienste; keine eigene Logik.
|
||||
/// </summary>
|
||||
public sealed class PolymarketGlobalAdapter : IPlatformAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Fähigkeiten laut heutiger Anbindung – nicht laut Plattform. Polymarket kennt z. B. GTD und FAK; der
|
||||
/// bestehende CLOB-Client setzt bei GTD aber eine feste Ablaufzeit, und FAK nutzt er nicht. Was hier
|
||||
/// fehlt, lehnt das Gateway ab, statt es halb richtig zu senden.
|
||||
/// </summary>
|
||||
public static readonly PlatformCapabilities CapabilitiesValue = new()
|
||||
{
|
||||
PublicTraderActivity = true,
|
||||
OnChainSettlement = true,
|
||||
PostOnlyOrders = false,
|
||||
OrderModify = false,
|
||||
MakerRebate = false,
|
||||
FractionalQuantity = true,
|
||||
NativeDemoEnvironment = false,
|
||||
TimeInForce = new HashSet<TimeInForce> { TimeInForce.GoodTillCancel, TimeInForce.FillOrKill },
|
||||
SettlementCurrency = "USDC"
|
||||
};
|
||||
|
||||
public PolymarketGlobalAdapter(
|
||||
IMarketCatalog markets, IOrderBookSource orderBook, IOrderGateway orders, IPortfolioSource portfolio, IFeeModel fees)
|
||||
{
|
||||
Markets = markets ?? throw new ArgumentNullException(nameof(markets));
|
||||
OrderBook = orderBook ?? throw new ArgumentNullException(nameof(orderBook));
|
||||
Orders = orders ?? throw new ArgumentNullException(nameof(orders));
|
||||
Portfolio = portfolio ?? throw new ArgumentNullException(nameof(portfolio));
|
||||
Fees = fees ?? throw new ArgumentNullException(nameof(fees));
|
||||
}
|
||||
|
||||
public PlatformId Id => PlatformId.PolymarketGlobal;
|
||||
public PlatformCapabilities Capabilities => CapabilitiesValue;
|
||||
public IMarketCatalog Markets { get; }
|
||||
public IOrderBookSource OrderBook { get; }
|
||||
public IOrderGateway Orders { get; }
|
||||
public IPortfolioSource Portfolio { get; }
|
||||
public IFeeModel Fees { get; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user