Plattform-Fundament im Core: Vertraege, Registry, Global-Adapter (#623)
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s

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:
Richard
2026-09-18 10:54:00 +02:00
co-authored by Claude Opus 5
parent 05979406c7
commit c863bf5fce
21 changed files with 1820 additions and 10 deletions
@@ -12,7 +12,8 @@ namespace PolyTrader.Tests.Fakes
/// </summary>
public sealed class FakeClobClient : IClobClient
{
public sealed record PlacedOrder(string TokenId, string Side, decimal Usdc, decimal Price, string OrderType);
public sealed record PlacedOrder(string TokenId, string Side, decimal Usdc, decimal Price, string OrderType,
bool IsNegRisk = false, int FeeBps = 0);
// Aufzeichnungen
public List<PlacedOrder> Placed { get; } = new();
@@ -36,7 +37,7 @@ namespace PolyTrader.Tests.Fakes
string orderType = "FOK", bool debugPayloadLog = false, bool isNegRisk = false, int actualFeeBps = 0,
decimal? overrideTickSize = null, int? overrideMakerDecimals = null, int? overrideTakerDecimals = null)
{
Placed.Add(new PlacedOrder(tokenId, sideStr, investAmountUsd, limitPrice, orderType));
Placed.Add(new PlacedOrder(tokenId, sideStr, investAmountUsd, limitPrice, orderType, isNegRisk, actualFeeBps));
string result = PlaceResults.Count > 0 ? PlaceResults.Dequeue() : PlaceResult;
return Task.FromResult(result);
}
+418
View File
@@ -0,0 +1,418 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Persistence;
using PolyTrader.Core.Persistence.Ef;
using PolyTrader.Core.Platforms;
using PolyTrader.Core.Platforms.PolymarketGlobal;
using PolyTrader.Core.Trading;
using PolyTrader.Tests.Fakes;
using PolyTrader.Tests.TestSupport;
using PolyTraderSharp.Models;
using PolyTraderSharp.Services;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// #623 Plattform-Fundament: Kennungen, Registry, und dass der Global-Adapter den bestehenden CLOB-Client
/// genau so aufruft, wie die Module es vor #623 selbst getan haben.
/// </summary>
public class PlatformTests
{
// ── PlatformId ─────────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_of_missing_value_is_polymarket_global(string? stored)
{
Assert.Equal(PlatformId.PolymarketGlobal, PlatformId.Parse(stored));
}
[Fact]
public void Parse_normalizes_case_and_whitespace()
{
Assert.Equal(PlatformId.PolymarketUs, PlatformId.Parse(" Polymarket-US "));
}
[Fact]
public void Stored_values_are_stable()
{
// Diese Werte stehen in der Datenbank. Ändert sich einer, verlieren Bestandskonten ihre Plattform.
Assert.Equal("polymarket-global", PlatformId.PolymarketGlobal.Value);
Assert.Equal("polymarket-us", PlatformId.PolymarketUs.Value);
Assert.Equal("kalshi", PlatformId.Kalshi.Value);
}
[Fact]
public void New_accounts_and_markets_default_to_polymarket_global()
{
Assert.Equal(PlatformId.PolymarketGlobalValue, new AccountState().Platform);
Assert.Equal(PlatformId.PolymarketGlobalValue, new MarketData().Platform);
}
// ── InstrumentRef ─────────────────────────────────────────────────────
[Fact]
public void Instruments_are_equal_by_platform_and_key_only()
{
var a = InstrumentRef.PolymarketGlobal("123", "0xcond", "Yes");
var b = InstrumentRef.PolymarketGlobal("123");
Assert.Equal(a, b);
Assert.Equal(a.GetHashCode(), b.GetHashCode());
}
[Fact]
public void Same_key_on_different_platforms_is_a_different_instrument()
{
Assert.NotEqual(
new InstrumentRef(PlatformId.PolymarketGlobal, "x"),
new InstrumentRef(PlatformId.PolymarketUs, "x"));
}
[Fact]
public void Empty_instrument_key_is_rejected()
{
Assert.Throws<ArgumentException>(() => new InstrumentRef(PlatformId.PolymarketGlobal, " "));
}
// ── Registry ──────────────────────────────────────────────────────────
[Fact]
public void Registry_resolves_adapter_for_account_platform()
{
var global = NewGlobalAdapter(new FakeClobClient(), new StubMarketRepository());
var registry = new PlatformRegistry(new[] { global });
Assert.Same(global, registry.ForAccount(new AccountState()));
Assert.Same(global, registry.ForAccount(new AccountState { Platform = "" }));
Assert.Equal(new[] { PlatformId.PolymarketGlobal }, registry.Available);
}
[Fact]
public void Registry_names_missing_platform_instead_of_returning_null()
{
var registry = new PlatformRegistry(new[] { NewGlobalAdapter(new FakeClobClient(), new StubMarketRepository()) });
var ex = Assert.Throws<InvalidOperationException>(() => registry.Get(PlatformId.PolymarketUs));
Assert.Contains("Polymarket US", ex.Message);
Assert.False(registry.TryGet(PlatformId.Kalshi, out _));
}
[Fact]
public void Registry_refuses_two_adapters_for_one_platform()
{
var a = NewGlobalAdapter(new FakeClobClient(), new StubMarketRepository());
var b = NewGlobalAdapter(new FakeClobClient(), new StubMarketRepository());
Assert.Throws<InvalidOperationException>(() => new PlatformRegistry(new[] { a, b }));
}
[Fact]
public void AddPlatforms_wires_global_adapter_from_the_app_singletons()
{
// Wie Program.cs: API-Dienst, CLOB-Client und Persistenz sind registriert, CopyTrading (und damit
// dessen IClobClient-Registrierung) bewusst NICHT – der Adapter muss trotzdem auflösbar sein.
var logger = new TerminalLogger();
var services = new ServiceCollection();
services.AddSingleton(new PolymarketApiService(logger, new HttpClient()));
services.AddSingleton(new PolymarketClobClient(logger, new HttpClient()));
services.AddSingleton<IMarketRepository>(new StubMarketRepository());
services.AddPlatforms();
using var sp = services.BuildServiceProvider();
var registry = sp.GetRequiredService<IPlatformRegistry>();
var adapter = registry.ForAccount(new AccountState());
Assert.IsType<PolymarketGlobalAdapter>(adapter);
Assert.IsType<GlobalOrderGateway>(adapter.Orders);
Assert.True(adapter.Capabilities.PublicTraderActivity);
Assert.False(adapter.Capabilities.PostOnlyOrders);
}
// ── Global-Gateway: derselbe CLOB-Aufruf wie vorher ───────────────────
[Fact]
public async Task Buy_by_notional_passes_amount_price_negrisk_and_market_fee_through()
{
var clob = new FakeClobClient();
var markets = new StubMarketRepository(new MarketData
{
Id = "m1", ClobTokenIds = "[\"tok-yes\",\"tok-no\"]", NegRisk = true, TakerFeeBps = 180, Category = "Sports"
});
var gateway = new GlobalOrderGateway(clob, markets);
var result = await gateway.PlaceAsync(new AccountState(), new OrderRequest
{
Instrument = InstrumentRef.PolymarketGlobal("tok-yes"),
Side = OrderSide.Buy,
LimitPrice = 0.42m,
NotionalUsd = 25m,
TimeInForce = TimeInForce.FillOrKill
});
Assert.True(result.IsAccepted);
var placed = Assert.Single(clob.Placed);
Assert.Equal(new FakeClobClient.PlacedOrder("tok-yes", "BUY", 25m, 0.42m, "FOK", IsNegRisk: true, FeeBps: 180), placed);
}
[Fact]
public async Task Sell_by_quantity_converts_to_amount_like_the_sell_ladder()
{
// Die SELL-Leiter übergibt pos.Size * Preis als Betrag – das Gateway muss dasselbe senden.
var clob = new FakeClobClient();
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
await gateway.PlaceAsync(new AccountState(), new OrderRequest
{
Instrument = InstrumentRef.PolymarketGlobal("tok"),
Side = OrderSide.Sell,
LimitPrice = 0.8m,
Quantity = 50m,
TimeInForce = TimeInForce.GoodTillCancel
});
var placed = Assert.Single(clob.Placed);
Assert.Equal("SELL", placed.Side);
Assert.Equal(40m, placed.Usdc);
Assert.Equal("GTC", placed.OrderType);
}
[Fact]
public async Task Unknown_market_uses_conservative_fee_fallback_and_no_negrisk()
{
var clob = new FakeClobClient();
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
await gateway.PlaceAsync(new AccountState(), Buy("tok", 0.5m, 10m));
var placed = Assert.Single(clob.Placed);
Assert.False(placed.IsNegRisk);
Assert.Equal(FeeModel.FallbackBps(null), placed.FeeBps);
}
public static IEnumerable<object[]> InvalidRequests()
{
var ok = Buy("tok", 0.5m, 10m);
yield return new object[] { ok with { Instrument = new InstrumentRef(PlatformId.PolymarketUs, "slug:YES") }, "Polymarket US" };
yield return new object[] { ok with { PostOnly = true }, "Post-only" };
yield return new object[] { ok with { TimeInForce = TimeInForce.ImmediateOrCancel }, "Gültigkeit" };
yield return new object[] { ok with { TimeInForce = TimeInForce.GoodTillDate }, "Gültigkeit" };
yield return new object[] { ok with { LimitPrice = 1m }, "Limitpreis" };
yield return new object[] { ok with { LimitPrice = 0m }, "Limitpreis" };
yield return new object[] { ok with { Quantity = 5m }, "Mengenangabe" };
yield return new object[] { ok with { NotionalUsd = null }, "Mengenangabe" };
yield return new object[] { ok with { NotionalUsd = 0m }, "größer als 0" };
}
[Theory]
[MemberData(nameof(InvalidRequests))]
public async Task Invalid_request_never_reaches_the_clob(OrderRequest request, string expectedHint)
{
var clob = new FakeClobClient();
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
var result = await gateway.PlaceAsync(new AccountState(), request);
Assert.Equal(OrderOutcome.Invalid, result.Outcome);
Assert.Contains(expectedHint, result.Message);
Assert.Empty(clob.Placed);
}
[Fact]
public async Task Account_of_another_platform_never_reaches_the_clob()
{
var clob = new FakeClobClient();
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
var result = await gateway.PlaceAsync(new AccountState { Platform = PlatformId.PolymarketUsValue }, Buy("tok", 0.5m, 10m));
Assert.Equal(OrderOutcome.Invalid, result.Outcome);
Assert.Empty(clob.Placed);
}
[Theory]
[InlineData("OK", OrderOutcome.Accepted)]
[InlineData("SKIPPED_LIQUIDITY", OrderOutcome.NotFilled)]
[InlineData("ERROR", OrderOutcome.Rejected)]
[InlineData("Error: Missing API or Private Keys", OrderOutcome.Rejected)]
[InlineData("", OrderOutcome.Rejected)]
public async Task Clob_response_is_mapped(string response, OrderOutcome expected)
{
var clob = new FakeClobClient { PlaceResult = response };
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
var result = await gateway.PlaceAsync(new AccountState(), Buy("tok", 0.5m, 10m));
Assert.Equal(expected, result.Outcome);
}
[Fact]
public async Task Open_orders_and_cancel_go_to_the_clob()
{
var clob = new FakeClobClient();
clob.OpenOrdersByAsset["tok"] = new() { ("o1", "SELL", 0.7m) };
clob.AllOpenOrders.Add(("o2", "BUY", 0.3m));
var gateway = new GlobalOrderGateway(clob, new StubMarketRepository());
var instrument = InstrumentRef.PolymarketGlobal("tok");
var forToken = await gateway.GetOpenOrdersAsync(new AccountState(), instrument);
var all = await gateway.GetOpenOrdersAsync(new AccountState());
bool canceled = await gateway.CancelAsync(new AccountState(), "o1");
Assert.Equal(new OpenOrder("o1", OrderSide.Sell, 0.7m, instrument), Assert.Single(forToken));
Assert.Equal(new OpenOrder("o2", OrderSide.Buy, 0.3m), Assert.Single(all));
Assert.True(canceled);
Assert.Equal(new[] { "o1" }, clob.CanceledOrderIds);
}
// ── Markt- und Positionsdaten ─────────────────────────────────────────
[Fact]
public void Stored_market_maps_outcomes_to_instruments_in_order()
{
var market = GlobalMarketCatalog.ToPlatformMarket(new MarketData
{
Id = "555", ConditionId = "0xcond", Slug = "ev", Question = "Q?", Category = "Politics",
ClobTokenIds = "[\"t-yes\", \"t-no\"]", Outcomes = "[\"Yes\", \"No\"]",
EndDate = new DateTime(2026, 11, 3, 0, 0, 0, DateTimeKind.Utc), Active = true
});
Assert.Equal(PlatformId.PolymarketGlobal, market.Platform);
Assert.Equal("0xcond", market.MarketKey);
Assert.Equal(new[] { "Yes", "No" }, market.Outcomes.Select(o => o.Name));
Assert.Equal("t-no", market.Outcomes[1].Instrument.Key);
Assert.Equal("0xcond", market.Outcomes[1].Instrument.MarketKey);
}
[Theory]
[InlineData("")]
[InlineData("not json")]
[InlineData("{\"a\":1}")]
public void Market_without_readable_token_ids_has_no_outcomes(string clobTokenIds)
{
var market = GlobalMarketCatalog.ToPlatformMarket(new MarketData { Id = "1", ClobTokenIds = clobTokenIds });
Assert.Empty(market.Outcomes);
Assert.Equal("1", market.MarketKey); // ohne ConditionId die Gamma-ID
}
[Fact]
public void Data_api_position_is_mapped_with_numbers_as_text_or_number()
{
using var doc = JsonDocument.Parse("""
{"asset":"tok","conditionId":"0xc","outcome":"Yes","size":"12.5","avgPrice":0.4,
"curPrice":"0.55","initialValue":5,"currentValue":"6.875","slug":"s","title":"T",
"endDate":"2026-12-31T00:00:00Z"}
""");
var pos = GlobalPortfolioSource.ToPosition(doc.RootElement)!;
Assert.Equal(InstrumentRef.PolymarketGlobal("tok"), pos.Instrument);
Assert.Equal(12.5m, pos.Size);
Assert.Equal(0.4m, pos.AveragePrice);
Assert.Equal(0.55m, pos.CurrentPrice);
Assert.Equal(5m, pos.CostUsd);
Assert.Equal(6.875m, pos.CurrentValueUsd);
Assert.Equal(new DateTime(2026, 12, 31, 0, 0, 0, DateTimeKind.Utc), pos.EndDateUtc);
}
[Fact]
public void Data_api_entry_without_asset_is_skipped()
{
using var doc = JsonDocument.Parse("{\"size\":\"3\"}");
Assert.Null(GlobalPortfolioSource.ToPosition(doc.RootElement));
}
// ── Gebühren ──────────────────────────────────────────────────────────
[Fact]
public void Global_taker_fee_equals_existing_fee_model()
{
var fees = new GlobalFeeModel();
var ctx = new FeeContext { Category = "Crypto" };
decimal fee = fees.EstimateFee(0.5m, 100m, LiquidityRole.Taker, ctx);
Assert.Equal(FeeModel.FeeUsd(50m, FeeModel.CryptoBps), fee);
Assert.Equal(FeeModel.FeeUsd(50m, 75), fees.EstimateFee(0.5m, 100m, LiquidityRole.Taker, new FeeContext { MarketFeeBps = 75 }));
}
[Fact]
public void Global_maker_pays_nothing()
{
Assert.Equal(0m, new GlobalFeeModel().EstimateFee(0.5m, 100m, LiquidityRole.Maker, FeeContext.None));
}
// ── Persistenz: Märkte je Plattform ───────────────────────────────────
[Fact]
public void Same_market_id_on_two_platforms_is_stored_twice_and_reads_stay_global()
{
var repo = new EfMarketRepository(new InMemoryContextFactory<CoreDbContext>(o => new CoreDbContext(o)));
repo.Upsert(new MarketData { Id = "42", Question = "global", ClobTokenIds = "[\"tok\"]" });
repo.Upsert(new MarketData { Id = "42", Question = "us", ClobTokenIds = "[\"tok\"]", Platform = PlatformId.PolymarketUsValue });
Assert.Equal("global", repo.GetById("42")!.Question);
Assert.Equal("global", repo.FindByTokenId("tok")!.Question);
Assert.Equal("global", Assert.Single(repo.GetActive()).Question);
repo.Update(new MarketData { Id = "42", Question = "us2", Platform = PlatformId.PolymarketUsValue });
Assert.Equal("global", repo.GetById("42")!.Question);
}
// ── Hilfen ────────────────────────────────────────────────────────────
private static OrderRequest Buy(string token, decimal price, decimal usd) => new()
{
Instrument = InstrumentRef.PolymarketGlobal(token),
Side = OrderSide.Buy,
LimitPrice = price,
NotionalUsd = usd
};
private static PolymarketGlobalAdapter NewGlobalAdapter(FakeClobClient clob, IMarketRepository markets) =>
new(new NullCatalog(), new NullBook(), new GlobalOrderGateway(clob, markets), new NullPortfolio(), new GlobalFeeModel());
private sealed class StubMarketRepository : IMarketRepository
{
private readonly List<MarketData> _markets;
public StubMarketRepository(params MarketData[] markets) => _markets = markets.ToList();
public MarketData? GetById(string id) => _markets.FirstOrDefault(m => m.Id == id);
public MarketData? FindByTokenId(string tokenId) => _markets.FirstOrDefault(m => m.ClobTokenIds.Contains(tokenId));
public List<MarketData> GetActive() => _markets.Where(m => !m.Closed).ToList();
public void Upsert(MarketData market) { }
public void Insert(MarketData market) { }
public void Update(MarketData market) { }
public void EnsureIndexes() { }
}
private sealed class NullCatalog : IMarketCatalog
{
public Task<IReadOnlyList<PlatformMarket>> GetRecentMarketsAsync(int limit, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<PlatformMarket>>(Array.Empty<PlatformMarket>());
public Task<PlatformMarket?> GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default) =>
Task.FromResult<PlatformMarket?>(null);
public Task<MarketResolution> GetResolutionAsync(InstrumentRef instrument, CancellationToken ct = default) =>
Task.FromResult(MarketResolution.Open);
}
private sealed class NullBook : IOrderBookSource
{
public Task<decimal?> GetBestPriceAsync(InstrumentRef instrument, OrderSide side, CancellationToken ct = default) =>
Task.FromResult<decimal?>(null);
}
private sealed class NullPortfolio : IPortfolioSource
{
public Task<decimal> GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default) => Task.FromResult(0m);
public Task<IReadOnlyList<PlatformPosition>?> GetPositionsAsync(AccountState account, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<PlatformPosition>?>(Array.Empty<PlatformPosition>());
}
}
}