From 1a8b75f96f0c6855581be3a38069f09eca37d1f5 Mon Sep 17 00:00:00 2001 From: Kostya Date: Thu, 10 Sep 2026 11:55:50 +0300 Subject: tests: cover the ex-2 loader, order book trading and desktop state Add tests for the behaviour introduced in the last few commits, so later changes to matching, settlement or the UI state can't quietly break it. On the service side, Ex2LoaderTest loads the ex-2 fixtures and checks that every invalid file is rejected for its own reason, including events with no market maker or two of them, and v1 files. The order book tests cover buys and sells that match or rest in the ladder, self-trade prevention, cash and shares reserved by resting orders, price bounds, risk rejections, and resolution cancelling resting orders and paying the holders. A simulation checks that LMSR settlement conserves money. LedgerMapperTest used to pin the unmapped seq and at fields to their defaults. Now that the mapper fills them from the entry id and time, assert the real values and add a case for a null time. On the desktop side, AppStateTest drives the real ServiceEngine through loading, event creation, LMSR and order book trading, and checks that the observable state updates. Smaller tests check chart timestamps, money and probability formatting, and that every animation settles on its final frame, which is what the UI shows when animations are off. --- .../java/market/guess/service/Ex2LoaderTest.java | 135 +++++++ .../guess/service/OrderBookSimulationTest.java | 235 ++++++++++++ .../market/guess/service/OrderBookTradingTest.java | 416 +++++++++++++++++++++ .../infrastructure/adapter/LedgerMapperTest.java | 25 +- 4 files changed, 800 insertions(+), 11 deletions(-) create mode 100644 service/src/test/java/market/guess/service/Ex2LoaderTest.java create mode 100644 service/src/test/java/market/guess/service/OrderBookSimulationTest.java create mode 100644 service/src/test/java/market/guess/service/OrderBookTradingTest.java (limited to 'service/src/test/java') diff --git a/service/src/test/java/market/guess/service/Ex2LoaderTest.java b/service/src/test/java/market/guess/service/Ex2LoaderTest.java new file mode 100644 index 0000000..d2da8e0 --- /dev/null +++ b/service/src/test/java/market/guess/service/Ex2LoaderTest.java @@ -0,0 +1,135 @@ +package market.guess.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2; +import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2; +import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2; +import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; +import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class Ex2LoaderTest { + private InMemoryEventRepository eventRepo; + private InMemoryUserRepository userRepo; + private XMLLoaderV2 loader; + + @BeforeEach + void setUp() { + eventRepo = new InMemoryEventRepository(); + userRepo = new InMemoryUserRepository(); + loader = new XMLLoaderV2(eventRepo, userRepo, new EventMapperV2(), new XMLValidatorV2()); + } + + @Test + void testLoadSmallXml() { + var path = Path.of("test-data/ex-2/small.xml"); + var res = loader.load(path); + assertTrue(res.isSuccess(), "Loading small.xml should succeed"); + assertEquals(2, res.getData().eventsLoaded()); + assertEquals(2, eventRepo.getAll().size()); + assertEquals(3, userRepo.getAll().size()); + + var ev1 = eventRepo.get("1").orElse(null); + assertNotNull(ev1); + assertEquals("Tikva", ev1.getMarketMaker()); + + var ev2 = eventRepo.get("2").orElse(null); + assertNotNull(ev2); + assertEquals("Avrum", ev2.getMarketMaker()); + + var avrum = userRepo.get("Avrum").orElse(null); + assertNotNull(avrum); + assertEquals(0, avrum.getLedger().getBalance().compareTo(new java.math.BigDecimal("1000.00"))); + } + + @Test + void testLoadMultipleXml() { + var path = Path.of("test-data/ex-2/multiple.xml"); + var res = loader.load(path); + assertTrue(res.isSuccess(), "Loading multiple.xml should succeed"); + assertEquals(4, res.getData().eventsLoaded()); + assertEquals(4, eventRepo.getAll().size()); + assertEquals(3, userRepo.getAll().size()); + } + + @Test + void testError2XmlFailsOnZeroInitialCash() { + var path = Path.of("test-data/ex-2/error-2.xml"); + var res = loader.load(path); + assertFalse(res.isSuccess(), "error-2.xml must fail validation"); + assertTrue( + res.getMessage().contains("initial cash") || res.getDetails().contains("initial cash"), + "Error message should mention initial cash: " + res.getMessage()); + } + + @Test + void testError3XmlFailsOnUnknownEventId() { + var path = Path.of("test-data/ex-2/error-3.xml"); + var res = loader.load(path); + assertFalse(res.isSuccess(), "error-3.xml must fail validation"); + assertTrue( + res.getMessage().contains("12") || res.getDetails().contains("12"), + "Error message should mention non-existent event ID 12: " + res.getMessage()); + } + + @Test + void testEventWithTwoMarketMakersFails(@TempDir Path dir) throws Exception { + // Avrum now also claims event 1, which Tikva already makes; event 2 is left without one. + var res = loader.load(smallXmlWith(dir, "", "")); + assertFalse(res.isSuccess()); + assertTrue(res.getDetails().contains("more than one market maker"), res.getDetails()); + } + + @Test + void testEventWithoutMarketMakerFails(@TempDir Path dir) throws Exception { + var res = loader.load(smallXmlWith(dir, "", "")); + assertFalse(res.isSuccess()); + assertTrue(res.getDetails().contains("Event ID 2 has no market maker"), res.getDetails()); + } + + private static Path smallXmlWith(Path dir, String from, String to) throws Exception { + var xml = Files.readString(Path.of("test-data/ex-2/small.xml")).replace(from, to); + return Files.writeString(dir.resolve("mm.xml"), xml); + } + + /** One file per spec validation rule (Ex1 + Ex2); each is small.xml with exactly one defect. */ + @Test + void testEachValidationRuleRejectsItsFile() { + String[][] cases = { + {"does-not-exist.xml", "FILE_NOT_FOUND"}, + {"ex1-not-xml.txt", "NOT_XML"}, + {"ex1-duplicate-event-id.xml", "DUPLICATE_ID"}, + {"ex1-commission-above-90.xml", "COMMISSION_OUT_OF_RANGE"}, + {"ex1-commission-below-0.xml", "COMMISSION_OUT_OF_RANGE"}, + {"ex2-duplicate-user.xml", "DUPLICATE_USER"}, + {"ex2-zero-initial-cash.xml", "INVALID_INITIAL_CASH"}, + {"ex2-negative-initial-cash.xml", "INVALID_INITIAL_CASH"}, + {"ex2-mm-unknown-event.xml", "UNKNOWN_MM_EVENT"}, + {"ex2-event-without-mm.xml", "MISSING_MM"}, + {"ex2-event-two-mms.xml", "DUPLICATE_MM"}, + }; + for (var c : cases) { + assertTrue(loader.load(Path.of("test-data/ex-2/small.xml")).isSuccess()); + var res = loader.load(Path.of("test-data/ex-2/invalid", c[0])); + assertFalse(res.isSuccess(), c[0] + " must be rejected"); + assertEquals(c[1], res.getMessage(), c[0] + " rejected for the wrong reason: " + res.getDetails()); + assertEquals(2, eventRepo.getAll().size(), c[0] + " must not replace the loaded file"); + System.out.println(c[0] + " -> " + res.getDetails()); + } + } + + @Test + void testEx1MultipleXmlFailsBecauseV1NotSupported() { + var path = Path.of("test-data/ex-1/multiple.xml"); + var res = loader.load(path); + assertFalse(res.isSuccess(), "ex-1 multiple.xml must fail because V1 schema is not supported"); + } +} diff --git a/service/src/test/java/market/guess/service/OrderBookSimulationTest.java b/service/src/test/java/market/guess/service/OrderBookSimulationTest.java new file mode 100644 index 0000000..a2e8cf3 --- /dev/null +++ b/service/src/test/java/market/guess/service/OrderBookSimulationTest.java @@ -0,0 +1,235 @@ +package market.guess.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import market.guess.model.event.CommissionTiming; +import market.guess.model.event.CreateEventRequest; +import market.guess.model.event.MechanismType; +import market.guess.model.market.OrderBookDTO; +import market.guess.model.market.PurchaseReceiptDTO; +import market.guess.service.catalog.LocalCatalogContext; +import market.guess.service.catalog.infrastructure.MarketContext; +import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2; +import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2; +import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2; +import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; +import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; +import market.guess.service.domain.Event; +import market.guess.service.domain.User; +import market.guess.service.fulfillment.LocalFulfillmentContext; +import market.guess.service.ledger.LedgerContext; +import market.guess.service.matching.LocalMatchingEngine; +import market.guess.service.mechanism.OrderBookTradingMechanism; +import market.guess.service.mechanism.OrderBookTradingMechanism.RestingOrder; +import market.guess.service.risk.LocalRiskEngine; +import market.guess.service.settlement.LocalSettlementContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * Replays the "Will it rain tomorrow?" order-book walkthrough (clob_simulation.html) step by step, + * once per commission timing, checking books, holdings, the pool and every trader's cash. + */ +public class OrderBookSimulationTest { + private static final int YES = 0; + private static final int NO = 1; + + private InMemoryEventRepository eventRepo; + private InMemoryUserRepository userRepo; + private LocalCatalogContext catalog; + private LocalGuessMarketContext market; + + @BeforeEach + public void setUp() { + eventRepo = new InMemoryEventRepository(); + userRepo = new InMemoryUserRepository(); + var context = new MarketContext(eventRepo, userRepo); + var loader = new XMLLoaderV2(eventRepo, userRepo, new EventMapperV2(), new XMLValidatorV2()); + catalog = new LocalCatalogContext(loader, context, eventRepo); + var ledger = new LedgerContext(userRepo, java.time.Clock.systemUTC()); + market = + new LocalGuessMarketContext( + context, + new LocalRiskEngine(eventRepo), + new LocalMatchingEngine(java.time.Clock.systemUTC()), + new LocalFulfillmentContext(ledger), + new LocalSettlementContext(ledger)); + + userRepo.add(new User("Zoe", 500)); + userRepo.add(new User("Alice", 200)); + userRepo.add(new User("Bob", 200)); + userRepo.add(new User("Carol", 200)); + } + + @ParameterizedTest(name = "commission {0}") + @EnumSource(value = CommissionTiming.class, names = {"ON_PURCHASE", "ON_CLOSE"}) + public void willItRainTomorrow(CommissionTiming timing) { + boolean onPurchase = timing == CommissionTiming.ON_PURCHASE; + var created = + catalog.createEvent( + new CreateEventRequest( + "Will it rain tomorrow?", "", MechanismType.ORDER_BOOK, 1, timing, "Zoe", + null, BigDecimal.ONE, List.of("YES", "NO"), true)); + assertTrue(created.isSuccess(), created.getMessage()); + + // 1. Market opens. 2. Zoe mints the first pairs: $100 into the pool, 100 YES + 100 NO, no quotes. + assertTrue(catalog.openEvent("1").isSuccess()); + assertCash("Zoe", "400.00"); + assertPool("100.00"); + assertHolds("Zoe", 100, 100); + assertBook(YES, List.of(), List.of()); + assertBook(NO, List.of(), List.of()); + + // 3. Bob bids on YES. 4. Carol bids on YES too. + place("Bob", "YES", "BUY", "0.50", 20); + place("Carol", "YES", "BUY", "0.48", 15); + // 5. Zoe quotes a YES ask. 6. Zoe adds a deeper ask. + place("Zoe", "YES", "SELL", "0.58", 25); + assertEquals("$0.08", orderBook(YES).spread()); + assertEquals("0.54", book().prices()[YES].toPlainString()); // mid-price + place("Zoe", "YES", "SELL", "0.65", 15); + assertBook(YES, List.of("Bob 20@0.50", "Carol 15@0.48"), List.of("Zoe 25@0.58", "Zoe 15@0.65")); + + // 7. Alice's order arrives. 8. Matched: a resale — Zoe is paid, the pool doesn't move. + var receipt = place("Alice", "YES", "BUY", "0.58", 25); + assertEquals("14.50", receipt.sharesCost()); + assertCash("Alice", onPurchase ? "185.36" : "185.50"); // 1% of 14.50 = 0.145 -> 0.14 + assertCash("Zoe", onPurchase ? "414.64" : "414.50"); + assertPool("100.00"); + assertBook(YES, List.of("Bob 20@0.50", "Carol 15@0.48"), List.of("Zoe 15@0.65")); + assertEquals("$0.15", orderBook(YES).spread()); // the cheap level is gone, spread widens + assertEquals("0.58", lastTrade(YES)); + + // 9. Zoe quotes a NO ask. 10. Bob's order arrives. 11. Matched: a partial fill of Zoe's 50. + place("Zoe", "NO", "SELL", "0.45", 50); + receipt = place("Bob", "NO", "BUY", "0.45", 25); + assertEquals("11.25", receipt.sharesCost()); + assertBook(NO, List.of(), List.of("Zoe 25@0.45")); + assertCash("Bob", onPurchase ? "188.64" : "188.75"); + assertEquals("0.45", lastTrade(NO)); + + // 12. Zoe's order arrives: sell 30 YES at 0.45 or better. + // 13. Matched: 20 at Bob's 0.50, then 10 at Carol's 0.48. (Self-trade prevention keeps it + // from merging against Zoe's own NO ask, which would otherwise pay 0.55.) + receipt = place("Zoe", "YES", "SELL", "0.45", 30); + assertEquals(2, receipt.trades().size()); + assertEquals("14.80", receipt.sharesCost()); + assertBook(YES, List.of("Carol 5@0.48"), List.of("Zoe 15@0.65")); + assertEquals("0.48", lastTrade(YES)); + assertCash("Zoe", onPurchase ? "440.95" : "440.55"); + assertCash("Bob", onPurchase ? "178.54" : "178.75"); + assertCash("Carol", onPurchase ? "195.15" : "195.20"); + assertHolds("Zoe", 45, 75); + + // 14. Carol bids on NO below Zoe's 0.45 ask, so it rests. + place("Carol", "NO", "BUY", "0.42", 35); + assertBook(NO, List.of("Carol 35@0.42"), List.of("Zoe 25@0.45")); + + // 15. Alice's order arrives: buy 40 YES at 0.62. No ask is that cheap, but 0.62 + 0.42 >= $1. + // 16. Matched: a peer-to-peer mint of min(40, 35) = 35 pairs. Carol pays her 0.42, Alice the + // remaining 0.58, both into the pool; Alice's other 5 rest at her own price. + receipt = place("Alice", "YES", "BUY", "0.62", 40); + assertEquals("35.00", receipt.sharesCost()); + assertPool("135.00"); + assertBook(YES, List.of("Alice 5@0.62", "Carol 5@0.48"), List.of("Zoe 15@0.65")); + assertBook(NO, List.of(), List.of("Zoe 25@0.45")); + assertEquals("0.58", lastTrade(YES)); + assertEquals("0.42", lastTrade(NO)); + assertHolds("Alice", 60, 0); + assertHolds("Carol", 10, 35); + assertCash("Alice", onPurchase ? "164.86" : "165.20"); + assertCash("Carol", onPurchase ? "180.30" : "180.50"); + + // 17. An order gets rejected before it reaches the book. + var rejected = market.placeOrder("Bob", "1", "YES", "BUY", new BigDecimal("1.05"), 10); + assertFalse(rejected.isSuccess()); + assertEquals("Price must be between $0.01 and $0.99.", rejected.getMessage()); + assertBook(YES, List.of("Alice 5@0.62", "Carol 5@0.48"), List.of("Zoe 15@0.65")); + + // 18. Bob asks 0.15 for his 25 NO. The walkthrough predates merging and leaves this unmatched, + // but 0.15 + Zoe's 0.65 YES ask <= $1, so 15 pairs merge back into the pool: Zoe is paid her + // 0.65, Bob the remaining 0.35. His other 10 NO rest, still with no buyer. + receipt = place("Bob", "NO", "SELL", "0.15", 25); + assertEquals("15.00", receipt.sharesCost()); // 15*0.35 + 15*0.65 + assertPool("120.00"); + assertBook(YES, List.of("Alice 5@0.62", "Carol 5@0.48"), List.of()); + assertBook(NO, List.of(), List.of("Bob 10@0.15", "Zoe 25@0.45")); + assertHolds("Zoe", 30, 75); + assertHolds("Bob", 20, 10); + assertCash("Bob", onPurchase ? "183.79" : "184.00"); + assertCash("Zoe", onPurchase ? "451.05" : "450.30"); + + // 19. Resolution: YES wins. Each of the 120 YES shares pays $1 (less 1% on close, to Zoe), + // every resting order is cancelled and the pool is emptied. + assertTrue(market.settleEvent("Zoe", "1", "YES").isSuccess()); + assertPool("0.00"); + assertBook(YES, List.of(), List.of()); + assertBook(NO, List.of(), List.of()); + assertCash("Zoe", onPurchase ? "481.05" : "481.20"); + assertCash("Alice", onPurchase ? "224.86" : "224.60"); + assertCash("Bob", onPurchase ? "203.79" : "203.80"); + assertCash("Carol", onPurchase ? "190.30" : "190.40"); + assertEquals(onPurchase ? "0.75" : "1.20", event().getCommission().toPlainString()); + + // Money is conserved: commission only moves cash to Zoe sooner or later. + var total = + userRepo.getAll().stream() + .map(u -> u.getLedger().getBalance()) + .reduce(BigDecimal.ZERO, BigDecimal::add); + assertEquals("1100.00", money(total)); + } + + private PurchaseReceiptDTO place(String user, String option, String side, String price, long qty) { + var res = market.placeOrder(user, "1", option, side, new BigDecimal(price), qty); + assertTrue(res.isSuccess(), user + " " + side + " " + qty + " " + option + " @ " + price + ": " + res.getMessage()); + return res.getData(); + } + + private Event event() { + return eventRepo.get("1").orElseThrow(); + } + + private OrderBookTradingMechanism book() { + return (OrderBookTradingMechanism) event().getMechanism(); + } + + private OrderBookDTO orderBook(int option) { + return catalog.getEvent("1").getData().orderBooks().get(option); + } + + private String lastTrade(int option) { + return book().getLastTradePrice(option).toPlainString(); + } + + private void assertBook(int option, List bids, List asks) { + assertEquals(bids, levels(book().getBids().get(option)), "bids"); + assertEquals(asks, levels(book().getAsks().get(option)), "asks"); + } + + private static List levels(List orders) { + return orders.stream() + .map(o -> o.getUser() + " " + o.getQuantity() + "@" + o.getPrice().toPlainString()) + .toList(); + } + + private void assertCash(String user, String expected) { + assertEquals(expected, money(userRepo.get(user).orElseThrow().getLedger().getBalance()), user + " cash"); + } + + private void assertPool(String expected) { + assertEquals(expected, money(event().getLedger().getBalance()), "pool"); + } + + private void assertHolds(String user, long yes, long no) { + assertEquals(yes, event().getSharesHeldBy(user, "YES"), user + " YES"); + assertEquals(no, event().getSharesHeldBy(user, "NO"), user + " NO"); + } + + private static String money(BigDecimal value) { + return value.setScale(2, RoundingMode.HALF_EVEN).toPlainString(); + } +} diff --git a/service/src/test/java/market/guess/service/OrderBookTradingTest.java b/service/src/test/java/market/guess/service/OrderBookTradingTest.java new file mode 100644 index 0000000..0469de6 --- /dev/null +++ b/service/src/test/java/market/guess/service/OrderBookTradingTest.java @@ -0,0 +1,416 @@ +package market.guess.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.nio.file.Path; +import market.guess.service.catalog.LocalCatalogContext; +import market.guess.service.catalog.infrastructure.MarketContext; +import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2; +import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2; +import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2; +import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; +import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; +import market.guess.service.fulfillment.LocalFulfillmentContext; +import market.guess.service.ledger.LedgerContext; +import market.guess.service.matching.LocalMatchingEngine; +import market.guess.service.mechanism.OrderBookTradingMechanism; +import market.guess.service.risk.LocalRiskEngine; +import market.guess.service.settlement.LocalSettlementContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class OrderBookTradingTest { + + private InMemoryEventRepository eventRepo; + private InMemoryUserRepository userRepo; + private MarketContext marketContext; + private LocalCatalogContext catalog; + private LocalGuessMarketContext guessMarket; + private LedgerContext ledgerContext; + + @BeforeEach + public void setUp() { + eventRepo = new InMemoryEventRepository(); + userRepo = new InMemoryUserRepository(); + marketContext = new MarketContext(eventRepo, userRepo); + var loader = new XMLLoaderV2(eventRepo, userRepo, new EventMapperV2(), new XMLValidatorV2()); + catalog = new LocalCatalogContext(loader, marketContext, eventRepo); + ledgerContext = new LedgerContext(userRepo, java.time.Clock.systemUTC()); + var settlementContext = new LocalSettlementContext(ledgerContext); + guessMarket = + new LocalGuessMarketContext( + marketContext, + new LocalRiskEngine(eventRepo), + new LocalMatchingEngine(java.time.Clock.systemUTC()), + new LocalFulfillmentContext(ledgerContext), + settlementContext); + + // Load small.xml from ex-2 + var loadRes = catalog.loadEvents(Path.of("test-data/ex-2/small.xml")); + assertTrue(loadRes.isSuccess(), "Loading small.xml should succeed"); + } + + /** LMSR: the subsidy is paid once, and closing empties the contract back into user accounts. */ + @Test + public void testLmsrSettlementConservesMoney() { + var totalBefore = totalUserCash(); + + assertTrue(catalog.openEvent("1").isSuccess()); + var contract = eventRepo.get("1").orElseThrow().getLedger(); + var tikva = userRepo.get("Tikva").orElseThrow().getLedger(); + // Contract holds exactly what Tikva paid, not a second, self-seeded copy. + assertEquals(0, new BigDecimal("10000").subtract(tikva.getBalance()).compareTo(contract.getBalance())); + + var buy = guessMarket.placeOrder("Menash", "1", "1:0", null, 10); + assertTrue(buy.isSuccess(), buy.getMessage()); + assertEquals(0, totalBefore.compareTo(totalUserCash().add(contract.getBalance()))); + + assertTrue(guessMarket.settleEvent("Tikva", "1", "1:0").isSuccess()); + assertEquals(0, contract.getBalance().signum(), "contract must be empty after close"); + assertEquals(0, totalBefore.compareTo(totalUserCash()), "no money created or lost"); + } + + private BigDecimal totalUserCash() { + var total = BigDecimal.ZERO; + for (var u : userRepo.getAll()) total = total.add(u.getLedger().getBalance()); + return total; + } + + /** Avrum (the MM) quotes his minted Argentina shares, which opening the event no longer does. */ + private void avrumAsks100At50() { + var res = guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.50"), 100); + assertTrue(res.isSuccess(), res.getMessage()); + } + + @Test + public void testOpeningMintsWithoutQuoting() { + var avrum = userRepo.get("Avrum").orElseThrow(); + var before = avrum.getLedger().getBalance(); + + var openRes = catalog.openEvent("2"); + assertTrue(openRes.isSuccess()); + + // $100 in, 100 pairs out, nothing on the books until Avrum quotes. + assertEquals(0, before.subtract(new BigDecimal("100")).compareTo(avrum.getLedger().getBalance())); + var event = eventRepo.get("2").orElseThrow(); + assertEquals(100, event.getSharesHeldBy("Avrum", "2:0")); + assertEquals(100, event.getSharesHeldBy("Avrum", "2:1")); + + var detail = catalog.getEvent("2").getData(); + assertEquals(2, detail.orderBooks().size()); + var obArg = detail.orderBooks().get(0); + assertEquals("2:0", obArg.optionKey()); + assertEquals("Argentina", obArg.optionName()); + assertNull(obArg.bestBid()); + assertNull(obArg.bestAsk()); + assertTrue(obArg.orders().isEmpty()); + } + + @Test + public void testBuyMatchingRestingAsk() { + catalog.openEvent("2"); + avrumAsks100At50(); + + var menash = userRepo.get("Menash").orElseThrow(); + var avrum = userRepo.get("Avrum").orElseThrow(); + var initialMenashBal = menash.getLedger().getBalance(); + var initialAvrumBal = avrum.getLedger().getBalance(); + + // Menash buys 10 shares of Argentina @ 0.50 + var buyRes = + guessMarket.placeOrder( + "Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 10); + assertTrue(buyRes.isSuccess(), "Buy order should succeed: " + buyRes.getMessage()); + + var receipt = buyRes.getData(); + assertEquals(1, receipt.trades().size()); + assertEquals("5.00", receipt.sharesCost()); + + // Menash paid $5.00, Avrum received $5.00 + assertEquals(0, initialMenashBal.subtract(new BigDecimal("5.00")).compareTo(menash.getLedger().getBalance())); + assertEquals(0, initialAvrumBal.add(new BigDecimal("5.00")).compareTo(avrum.getLedger().getBalance())); + + // Order book remaining ask should now be 90 shares @ 0.50 + var detail = catalog.getEvent("2").getData(); + var obArg = detail.orderBooks().get(0); + assertEquals(1, obArg.orders().size()); + assertEquals(90, obArg.orders().get(0).quantity()); + } + + @Test + public void testBuyRestingInLadderWhenNoMatchingAsk() { + catalog.openEvent("2"); + avrumAsks100At50(); + + // Menash places BUY order at 0.40 (below MM ask of 0.50) + var buyRes = + guessMarket.placeOrder( + "Menash", "2", "2:0", "BUY", new BigDecimal("0.40"), 20); + assertTrue(buyRes.isSuccess()); + + var receipt = buyRes.getData(); + // No immediate trades executed + assertEquals(0, receipt.trades().size()); + + // Order book should now have a BID and an ASK + var detail = catalog.getEvent("2").getData(); + var obArg = detail.orderBooks().get(0); + assertEquals("$0.40", obArg.bestBid()); + assertEquals("$0.50", obArg.bestAsk()); + assertEquals("$0.10", obArg.spread()); + assertEquals(2, obArg.orders().size()); + + var bidOrder = obArg.orders().stream().filter(o -> "BID".equals(o.side())).findFirst().orElseThrow(); + assertEquals("Menash", bidOrder.user()); + assertEquals(20, bidOrder.quantity()); + assertEquals("$0.40", bidOrder.price()); + } + + @Test + public void testSellMatchingRestingBid() { + catalog.openEvent("2"); + + // 1. Menash places a resting bid: BUY 15 @ 0.45 + var bidRes = + guessMarket.placeOrder( + "Menash", "2", "2:0", "BUY", new BigDecimal("0.45"), 15); + assertTrue(bidRes.isSuccess()); + + // 2. Avrum (who holds initial 100 shares of Argentina) sells 10 shares @ 0.45 + var initialAvrumBal = userRepo.get("Avrum").orElseThrow().getLedger().getBalance(); + var initialMenashBal = userRepo.get("Menash").orElseThrow().getLedger().getBalance(); + + var sellRes = + guessMarket.placeOrder( + "Avrum", "2", "2:0", "SELL", new BigDecimal("0.45"), 10); + assertTrue(sellRes.isSuccess(), "Sell order should succeed: " + sellRes.getMessage()); + + var receipt = sellRes.getData(); + assertEquals(1, receipt.trades().size()); + // 10 shares @ 0.45 = 4.50 + assertEquals("4.50", receipt.sharesCost()); + + // Avrum received 4.50, Menash paid 4.50 + var currentAvrumBal = userRepo.get("Avrum").orElseThrow().getLedger().getBalance(); + var currentMenashBal = userRepo.get("Menash").orElseThrow().getLedger().getBalance(); + assertEquals(0, initialAvrumBal.add(new BigDecimal("4.50")).compareTo(currentAvrumBal)); + assertEquals(0, initialMenashBal.subtract(new BigDecimal("4.50")).compareTo(currentMenashBal)); + + // Remaining resting bid should have 5 shares left + var detail = catalog.getEvent("2").getData(); + var obArg = detail.orderBooks().get(0); + var remainingBid = obArg.orders().stream().filter(o -> "BID".equals(o.side())).findFirst().orElseThrow(); + assertEquals(5, remainingBid.quantity()); + } + + @Test + public void testSellRestingInLadder() { + catalog.openEvent("2"); + avrumAsks100At50(); + + // Menash first buys 10 shares @ 0.50 from Avrum + guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 10); + + // Menash now owns 10 shares. Menash places a SELL order for 5 shares @ 0.60 (higher than existing ask 0.50) + var sellRes = + guessMarket.placeOrder( + "Menash", "2", "2:0", "SELL", new BigDecimal("0.60"), 5); + assertTrue(sellRes.isSuccess()); + + var detail = catalog.getEvent("2").getData(); + var obArg = detail.orderBooks().get(0); + + // The asks should contain Avrum's 90 @ 0.50 and Menash's 5 @ 0.60 + var menashAsk = + obArg.orders().stream() + .filter(o -> "Menash".equals(o.user()) && "ASK".equals(o.side())) + .findFirst() + .orElse(null); + assertNotNull(menashAsk); + assertEquals(5, menashAsk.quantity()); + assertEquals("$0.60", menashAsk.price()); + } + + @Test + public void testRiskEngineBlocksUnauthorizedSell() { + catalog.openEvent("2"); + + // Menash currently holds 0 shares of Argentina. Attempting to sell must be rejected by risk engine. + var sellRes = + guessMarket.placeOrder( + "Menash", "2", "2:0", "SELL", new BigDecimal("0.50"), 5); + assertFalse(sellRes.isSuccess(), "Sell should be rejected when user holds insufficient shares"); + assertTrue(sellRes.getMessage().contains("Insufficient shares")); + } + + @Test + public void testRiskEngineBlocksInsufficientFundsBuy() { + catalog.openEvent("2"); + + // Menash initial cash is 100. Attempting to buy 500 shares @ 0.50 ($250) must fail. + var buyRes = + guessMarket.placeOrder( + "Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 500); + assertFalse(buyRes.isSuccess(), "Buy should be rejected when user has insufficient funds"); + assertTrue(buyRes.getMessage().contains("Insufficient balance")); + } + + @Test + public void testRestingOrdersReserveCashAndShares() { + catalog.openEvent("2"); + + // Menash has $100: a resting $90 bid leaves only $10 for anything else. + assertTrue(guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.60"), 150).isSuccess()); + var overBudget = guessMarket.placeOrder("Menash", "2", "2:1", "BUY", new BigDecimal("0.60"), 20); + assertFalse(overBudget.isSuccess()); + assertTrue(overBudget.getMessage().contains("Insufficient balance")); + + // Avrum holds 100 Argentina: 60 already offered leaves 40 to offer again. + assertTrue(guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.70"), 60).isSuccess()); + var overSold = guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.80"), 50); + assertFalse(overSold.isSuccess()); + assertTrue(overSold.getMessage().contains("Insufficient shares")); + } + + @Test + public void testNoSelfTrade() { + catalog.openEvent("2"); + assertTrue(guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.55"), 10).isSuccess()); + var res = guessMarket.placeOrder("Avrum", "2", "2:0", "BUY", new BigDecimal("0.60"), 10); + assertTrue(res.isSuccess()); + assertEquals(0, res.getData().trades().size()); + assertEquals(2, catalog.getEvent("2").getData().orderBooks().get(0).orders().size()); + } + + @Test + public void testOrderBookParticipantsUpdatingOnTrades() { + // 1. Initial draft: participants contains Avrum with 0 shares, $0.00 value + var draftDetail = catalog.getEvent("2").getData(); + assertNotNull(draftDetail.participants()); + assertEquals(1, draftDetail.participants().size()); + var draftAvrum = draftDetail.participants().get(0); + assertEquals("Avrum", draftAvrum.user()); + assertEquals("MM", draftAvrum.tag()); + assertEquals(0, draftAvrum.yes()); + assertEquals(0, draftAvrum.no()); + assertEquals("$0.00", draftAvrum.value()); + + // 2. Open event: Avrum minted 100 YES, 100 NO, value $100.00 (100*0.50 + 100*0.50) + catalog.openEvent("2"); + var openDetail = catalog.getEvent("2").getData(); + assertEquals(1, openDetail.participants().size()); + var openAvrum = openDetail.participants().get(0); + assertEquals("Avrum", openAvrum.user()); + assertEquals("MM", openAvrum.tag()); + assertEquals(100, openAvrum.yes()); + assertEquals(100, openAvrum.no()); + assertEquals("$100.00", openAvrum.value()); + + // 3. Avrum quotes, Menash buys 10 YES shares (Argentina) @ 0.50 + avrumAsks100At50(); + var buyRes = + guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 10); + assertTrue(buyRes.isSuccess()); + + var afterBuyDetail = catalog.getEvent("2").getData(); + assertEquals(2, afterBuyDetail.participants().size()); + + var avrumAfterBuy = + afterBuyDetail.participants().stream() + .filter(p -> "Avrum".equals(p.user())) + .findFirst() + .orElseThrow(); + assertEquals("MM", avrumAfterBuy.tag()); + assertEquals(90, avrumAfterBuy.yes()); + assertEquals(100, avrumAfterBuy.no()); + assertEquals("$95.00", avrumAfterBuy.value()); + + var menashAfterBuy = + afterBuyDetail.participants().stream() + .filter(p -> "Menash".equals(p.user())) + .findFirst() + .orElseThrow(); + assertEquals("", menashAfterBuy.tag()); + assertEquals(10, menashAfterBuy.yes()); + assertEquals(0, menashAfterBuy.no()); + assertEquals("$5.00", menashAfterBuy.value()); + + // 4. Avrum places a resting BID for 10 @ 0.40 (below 0.50 ask), and Menash sells 4 YES shares @ 0.40 + var avrumBid = + guessMarket.placeOrder("Avrum", "2", "2:0", "BUY", new BigDecimal("0.40"), 10); + assertTrue(avrumBid.isSuccess()); + var menashSell = + guessMarket.placeOrder("Menash", "2", "2:0", "SELL", new BigDecimal("0.40"), 4); + assertTrue(menashSell.isSuccess()); + + var afterSellDetail = catalog.getEvent("2").getData(); + var avrumAfterSell = + afterSellDetail.participants().stream() + .filter(p -> "Avrum".equals(p.user())) + .findFirst() + .orElseThrow(); + assertEquals(94, avrumAfterSell.yes()); + assertEquals(100, avrumAfterSell.no()); + assertEquals("$92.30", avrumAfterSell.value()); + + var menashAfterSell = + afterSellDetail.participants().stream() + .filter(p -> "Menash".equals(p.user())) + .findFirst() + .orElseThrow(); + assertEquals(6, menashAfterSell.yes()); + assertEquals(0, menashAfterSell.no()); + assertEquals("$2.70", menashAfterSell.value()); + } + + @Test + public void testSellWalksBidsAndLastTradeIsLastFill() { + catalog.openEvent("2"); + avrumAsks100At50(); + guessMarket.placeOrder("Tikva", "2", "2:0", "BUY", new BigDecimal("0.50"), 100); // Tikva owns the YES + guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.45"), 20); + guessMarket.placeOrder("Avrum", "2", "2:0", "BUY", new BigDecimal("0.44"), 15); + + var res = guessMarket.placeOrder("Tikva", "2", "2:0", "SELL", new BigDecimal("0.40"), 30); + assertTrue(res.isSuccess(), res.getMessage()); + assertEquals(2, res.getData().trades().size()); + assertEquals("13.40", res.getData().sharesCost()); // 20*0.45 + 10*0.44 + + var ob = (OrderBookTradingMechanism) eventRepo.get("2").orElseThrow().getMechanism(); + assertEquals(5, ob.getBids().get(0).get(0).getQuantity()); + assertEquals("0.44", ob.getLastTradePrice(0).toPlainString()); // last fill, not the 0.4467 average + } + + @Test + public void testPriceMustBeStrictlyInsideZeroAndD() { + catalog.openEvent("2"); + for (var price : new String[] {"1.00", "0.00"}) { + var res = guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal(price), 1); + assertFalse(res.isSuccess()); + assertEquals("Price must be between $0.01 and $0.99.", res.getMessage()); + } + } + + @Test + public void testResolutionCancelsRestingOrdersAndPaysHolders() { + catalog.openEvent("2"); + avrumAsks100At50(); + guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 20); + guessMarket.placeOrder("Tikva", "2", "2:0", "BUY", new BigDecimal("0.40"), 10); + // Menash resells 5 to Tikva: only Tikva should be paid for those. + guessMarket.placeOrder("Menash", "2", "2:0", "SELL", new BigDecimal("0.40"), 5); + + var menash = userRepo.get("Menash").orElseThrow(); + var menashBal = menash.getLedger().getBalance(); + assertTrue(guessMarket.settleEvent("Avrum", "2", "2:0").isSuccess()); + + for (var book : catalog.getEvent("2").getData().orderBooks()) { + assertTrue(book.orders().isEmpty()); + } + // 15 shares held * $1, less 15% on-close commission. + assertEquals(0, menashBal.add(new BigDecimal("12.75")).compareTo(menash.getLedger().getBalance())); + // Pool held exactly the 100 minted pairs, and all 100 winning shares were paid out. + assertEquals(0, eventRepo.get("2").orElseThrow().getLedger().getBalance().signum()); + } +} diff --git a/service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java b/service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java index 976442c..6e16cee 100644 --- a/service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java +++ b/service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java @@ -11,6 +11,7 @@ import java.util.List; import market.guess.model.ledger.LedgerDTO; import market.guess.model.ledger.LedgerEntryDTO; import market.guess.model.ledger.LedgerType; +import market.guess.service.helpers.InstantOptions; import market.guess.service.ledger.Ledger; import market.guess.service.ledger.LedgerEntry; import org.junit.Test; @@ -90,24 +91,26 @@ public class LedgerMapperTest { assertNull(entry.balanceAfter()); } - /** - * LedgerEntry.id/time and Ledger's account-blocked state have no matching AccountDTO/ - * LedgerEntryDTO source property, so MapStruct leaves them at their Java defaults. This pins that - * known gap; once the mapper is taught to fill seq/at/blocked, update these assertions instead of - * deleting them. - */ @Test - public void unmappedTargetPropertiesFallBackToDefaults() { + public void mapsSeqAndAtFromEntryIdAndTime() { + Instant time = Instant.parse("2026-01-03T10:15:30Z"); Ledger ledger = new Ledger("dave", new BigDecimal("0")); ledger.record( - new LedgerEntry( - 7, Instant.now(), LedgerType.COMMISSION, BigDecimal.ONE, BigDecimal.ONE, "n")); + new LedgerEntry(7, time, LedgerType.COMMISSION, BigDecimal.ONE, BigDecimal.ONE, "n")); LedgerDTO dto = mapper.ledgerToLedgerDTO(ledger); assertFalse(dto.blocked()); LedgerEntryDTO entry = dto.entries().get(0); - assertEquals(0L, entry.seq()); - assertNull(entry.at()); + assertEquals(7L, entry.seq()); + assertEquals(InstantOptions.humanize(time), entry.at()); + } + + @Test + public void nullEntryTimeMapsToNullAt() { + Ledger ledger = new Ledger("erin", new BigDecimal("0")); + ledger.record(new LedgerEntry(1, null, LedgerType.REFUND, BigDecimal.ONE, BigDecimal.ONE, "n")); + + assertNull(mapper.ledgerToLedgerDTO(ledger).entries().get(0).at()); } } -- cgit v1.2.3