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 { /// /// #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. /// 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(() => 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(() => 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(() => 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(new StubMarketRepository()); services.AddPlatforms(); using var sp = services.BuildServiceProvider(); var registry = sp.GetRequiredService(); var adapter = registry.ForAccount(new AccountState()); Assert.IsType(adapter); Assert.IsType(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 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(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 _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 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> GetRecentMarketsAsync(int limit, CancellationToken ct = default) => Task.FromResult>(Array.Empty()); public Task GetMarketAsync(InstrumentRef instrument, CancellationToken ct = default) => Task.FromResult(null); public Task GetResolutionAsync(InstrumentRef instrument, CancellationToken ct = default) => Task.FromResult(MarketResolution.Open); } private sealed class NullBook : IOrderBookSource { public Task GetBestPriceAsync(InstrumentRef instrument, OrderSide side, CancellationToken ct = default) => Task.FromResult(null); } private sealed class NullPortfolio : IPortfolioSource { public Task GetAvailableBalanceAsync(AccountState account, CancellationToken ct = default) => Task.FromResult(0m); public Task?> GetPositionsAsync(AccountState account, CancellationToken ct = default) => Task.FromResult?>(Array.Empty()); } } }