Files
PolyTraderSharp/tests/PolyTrader.Tests/PlatformTests.cs
T
RichardandClaude Opus 5 c863bf5fce
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
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>
2026-09-18 10:54:00 +02:00

419 lines
19 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>());
}
}
}