diff options
| author | Kostya <mail@sartin.in> | 2026-09-10 11:55:50 +0300 |
|---|---|---|
| committer | Kostya <mail@sartin.in> | 2026-09-10 11:55:50 +0300 |
| commit | 1a8b75f96f0c6855581be3a38069f09eca37d1f5 (patch) | |
| tree | de174d70987afa21fd0588c551c111e028ff020f | |
| parent | f23c20c1c66b39fb1f49307e08e8593fa708e026 (diff) | |
| download | guess-market-2.0.tar.gz guess-market-2.0.tar.xz guess-market-2.0.zip | |
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.
8 files changed, 1430 insertions, 11 deletions
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 @@ | |||
| 1 | package market.guess.service; | ||
| 2 | |||
| 3 | import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| 4 | import static org.junit.jupiter.api.Assertions.assertFalse; | ||
| 5 | import static org.junit.jupiter.api.Assertions.assertNotNull; | ||
| 6 | import static org.junit.jupiter.api.Assertions.assertTrue; | ||
| 7 | |||
| 8 | import java.nio.file.Files; | ||
| 9 | import java.nio.file.Path; | ||
| 10 | import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2; | ||
| 11 | import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2; | ||
| 12 | import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2; | ||
| 13 | import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; | ||
| 14 | import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; | ||
| 15 | import org.junit.jupiter.api.BeforeEach; | ||
| 16 | import org.junit.jupiter.api.Test; | ||
| 17 | import org.junit.jupiter.api.io.TempDir; | ||
| 18 | |||
| 19 | class Ex2LoaderTest { | ||
| 20 | private InMemoryEventRepository eventRepo; | ||
| 21 | private InMemoryUserRepository userRepo; | ||
| 22 | private XMLLoaderV2 loader; | ||
| 23 | |||
| 24 | @BeforeEach | ||
| 25 | void setUp() { | ||
| 26 | eventRepo = new InMemoryEventRepository(); | ||
| 27 | userRepo = new InMemoryUserRepository(); | ||
| 28 | loader = new XMLLoaderV2(eventRepo, userRepo, new EventMapperV2(), new XMLValidatorV2()); | ||
| 29 | } | ||
| 30 | |||
| 31 | @Test | ||
| 32 | void testLoadSmallXml() { | ||
| 33 | var path = Path.of("test-data/ex-2/small.xml"); | ||
| 34 | var res = loader.load(path); | ||
| 35 | assertTrue(res.isSuccess(), "Loading small.xml should succeed"); | ||
| 36 | assertEquals(2, res.getData().eventsLoaded()); | ||
| 37 | assertEquals(2, eventRepo.getAll().size()); | ||
| 38 | assertEquals(3, userRepo.getAll().size()); | ||
| 39 | |||
| 40 | var ev1 = eventRepo.get("1").orElse(null); | ||
| 41 | assertNotNull(ev1); | ||
| 42 | assertEquals("Tikva", ev1.getMarketMaker()); | ||
| 43 | |||
| 44 | var ev2 = eventRepo.get("2").orElse(null); | ||
| 45 | assertNotNull(ev2); | ||
| 46 | assertEquals("Avrum", ev2.getMarketMaker()); | ||
| 47 | |||
| 48 | var avrum = userRepo.get("Avrum").orElse(null); | ||
| 49 | assertNotNull(avrum); | ||
| 50 | assertEquals(0, avrum.getLedger().getBalance().compareTo(new java.math.BigDecimal("1000.00"))); | ||
| 51 | } | ||
| 52 | |||
| 53 | @Test | ||
| 54 | void testLoadMultipleXml() { | ||
| 55 | var path = Path.of("test-data/ex-2/multiple.xml"); | ||
| 56 | var res = loader.load(path); | ||
| 57 | assertTrue(res.isSuccess(), "Loading multiple.xml should succeed"); | ||
| 58 | assertEquals(4, res.getData().eventsLoaded()); | ||
| 59 | assertEquals(4, eventRepo.getAll().size()); | ||
| 60 | assertEquals(3, userRepo.getAll().size()); | ||
| 61 | } | ||
| 62 | |||
| 63 | @Test | ||
| 64 | void testError2XmlFailsOnZeroInitialCash() { | ||
| 65 | var path = Path.of("test-data/ex-2/error-2.xml"); | ||
| 66 | var res = loader.load(path); | ||
| 67 | assertFalse(res.isSuccess(), "error-2.xml must fail validation"); | ||
| 68 | assertTrue( | ||
| 69 | res.getMessage().contains("initial cash") || res.getDetails().contains("initial cash"), | ||
| 70 | "Error message should mention initial cash: " + res.getMessage()); | ||
| 71 | } | ||
| 72 | |||
| 73 | @Test | ||
| 74 | void testError3XmlFailsOnUnknownEventId() { | ||
| 75 | var path = Path.of("test-data/ex-2/error-3.xml"); | ||
| 76 | var res = loader.load(path); | ||
| 77 | assertFalse(res.isSuccess(), "error-3.xml must fail validation"); | ||
| 78 | assertTrue( | ||
| 79 | res.getMessage().contains("12") || res.getDetails().contains("12"), | ||
| 80 | "Error message should mention non-existent event ID 12: " + res.getMessage()); | ||
| 81 | } | ||
| 82 | |||
| 83 | @Test | ||
| 84 | void testEventWithTwoMarketMakersFails(@TempDir Path dir) throws Exception { | ||
| 85 | // Avrum now also claims event 1, which Tikva already makes; event 2 is left without one. | ||
| 86 | var res = loader.load(smallXmlWith(dir, "<event id=\"2\"/>", "<event id=\"1\"/>")); | ||
| 87 | assertFalse(res.isSuccess()); | ||
| 88 | assertTrue(res.getDetails().contains("more than one market maker"), res.getDetails()); | ||
| 89 | } | ||
| 90 | |||
| 91 | @Test | ||
| 92 | void testEventWithoutMarketMakerFails(@TempDir Path dir) throws Exception { | ||
| 93 | var res = loader.load(smallXmlWith(dir, "<event id=\"2\"/>", "")); | ||
| 94 | assertFalse(res.isSuccess()); | ||
| 95 | assertTrue(res.getDetails().contains("Event ID 2 has no market maker"), res.getDetails()); | ||
| 96 | } | ||
| 97 | |||
| 98 | private static Path smallXmlWith(Path dir, String from, String to) throws Exception { | ||
| 99 | var xml = Files.readString(Path.of("test-data/ex-2/small.xml")).replace(from, to); | ||
| 100 | return Files.writeString(dir.resolve("mm.xml"), xml); | ||
| 101 | } | ||
| 102 | |||
| 103 | /** One file per spec validation rule (Ex1 + Ex2); each is small.xml with exactly one defect. */ | ||
| 104 | @Test | ||
| 105 | void testEachValidationRuleRejectsItsFile() { | ||
| 106 | String[][] cases = { | ||
| 107 | {"does-not-exist.xml", "FILE_NOT_FOUND"}, | ||
| 108 | {"ex1-not-xml.txt", "NOT_XML"}, | ||
| 109 | {"ex1-duplicate-event-id.xml", "DUPLICATE_ID"}, | ||
| 110 | {"ex1-commission-above-90.xml", "COMMISSION_OUT_OF_RANGE"}, | ||
| 111 | {"ex1-commission-below-0.xml", "COMMISSION_OUT_OF_RANGE"}, | ||
| 112 | {"ex2-duplicate-user.xml", "DUPLICATE_USER"}, | ||
| 113 | {"ex2-zero-initial-cash.xml", "INVALID_INITIAL_CASH"}, | ||
| 114 | {"ex2-negative-initial-cash.xml", "INVALID_INITIAL_CASH"}, | ||
| 115 | {"ex2-mm-unknown-event.xml", "UNKNOWN_MM_EVENT"}, | ||
| 116 | {"ex2-event-without-mm.xml", "MISSING_MM"}, | ||
| 117 | {"ex2-event-two-mms.xml", "DUPLICATE_MM"}, | ||
| 118 | }; | ||
| 119 | for (var c : cases) { | ||
| 120 | assertTrue(loader.load(Path.of("test-data/ex-2/small.xml")).isSuccess()); | ||
| 121 | var res = loader.load(Path.of("test-data/ex-2/invalid", c[0])); | ||
| 122 | assertFalse(res.isSuccess(), c[0] + " must be rejected"); | ||
| 123 | assertEquals(c[1], res.getMessage(), c[0] + " rejected for the wrong reason: " + res.getDetails()); | ||
| 124 | assertEquals(2, eventRepo.getAll().size(), c[0] + " must not replace the loaded file"); | ||
| 125 | System.out.println(c[0] + " -> " + res.getDetails()); | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | @Test | ||
| 130 | void testEx1MultipleXmlFailsBecauseV1NotSupported() { | ||
| 131 | var path = Path.of("test-data/ex-1/multiple.xml"); | ||
| 132 | var res = loader.load(path); | ||
| 133 | assertFalse(res.isSuccess(), "ex-1 multiple.xml must fail because V1 schema is not supported"); | ||
| 134 | } | ||
| 135 | } | ||
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 @@ | |||
| 1 | package market.guess.service; | ||
| 2 | |||
| 3 | import static org.junit.jupiter.api.Assertions.*; | ||
| 4 | |||
| 5 | import java.math.BigDecimal; | ||
| 6 | import java.math.RoundingMode; | ||
| 7 | import java.util.List; | ||
| 8 | import market.guess.model.event.CommissionTiming; | ||
| 9 | import market.guess.model.event.CreateEventRequest; | ||
| 10 | import market.guess.model.event.MechanismType; | ||
| 11 | import market.guess.model.market.OrderBookDTO; | ||
| 12 | import market.guess.model.market.PurchaseReceiptDTO; | ||
| 13 | import market.guess.service.catalog.LocalCatalogContext; | ||
| 14 | import market.guess.service.catalog.infrastructure.MarketContext; | ||
| 15 | import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2; | ||
| 16 | import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2; | ||
| 17 | import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2; | ||
| 18 | import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; | ||
| 19 | import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; | ||
| 20 | import market.guess.service.domain.Event; | ||
| 21 | import market.guess.service.domain.User; | ||
| 22 | import market.guess.service.fulfillment.LocalFulfillmentContext; | ||
| 23 | import market.guess.service.ledger.LedgerContext; | ||
| 24 | import market.guess.service.matching.LocalMatchingEngine; | ||
| 25 | import market.guess.service.mechanism.OrderBookTradingMechanism; | ||
| 26 | import market.guess.service.mechanism.OrderBookTradingMechanism.RestingOrder; | ||
| 27 | import market.guess.service.risk.LocalRiskEngine; | ||
| 28 | import market.guess.service.settlement.LocalSettlementContext; | ||
| 29 | import org.junit.jupiter.api.BeforeEach; | ||
| 30 | import org.junit.jupiter.params.ParameterizedTest; | ||
| 31 | import org.junit.jupiter.params.provider.EnumSource; | ||
| 32 | |||
| 33 | /** | ||
| 34 | * Replays the "Will it rain tomorrow?" order-book walkthrough (clob_simulation.html) step by step, | ||
| 35 | * once per commission timing, checking books, holdings, the pool and every trader's cash. | ||
| 36 | */ | ||
| 37 | public class OrderBookSimulationTest { | ||
| 38 | private static final int YES = 0; | ||
| 39 | private static final int NO = 1; | ||
| 40 | |||
| 41 | private InMemoryEventRepository eventRepo; | ||
| 42 | private InMemoryUserRepository userRepo; | ||
| 43 | private LocalCatalogContext catalog; | ||
| 44 | private LocalGuessMarketContext market; | ||
| 45 | |||
| 46 | @BeforeEach | ||
| 47 | public void setUp() { | ||
| 48 | eventRepo = new InMemoryEventRepository(); | ||
| 49 | userRepo = new InMemoryUserRepository(); | ||
| 50 | var context = new MarketContext(eventRepo, userRepo); | ||
| 51 | var loader = new XMLLoaderV2(eventRepo, userRepo, new EventMapperV2(), new XMLValidatorV2()); | ||
| 52 | catalog = new LocalCatalogContext(loader, context, eventRepo); | ||
| 53 | var ledger = new LedgerContext(userRepo, java.time.Clock.systemUTC()); | ||
| 54 | market = | ||
| 55 | new LocalGuessMarketContext( | ||
| 56 | context, | ||
| 57 | new LocalRiskEngine(eventRepo), | ||
| 58 | new LocalMatchingEngine(java.time.Clock.systemUTC()), | ||
| 59 | new LocalFulfillmentContext(ledger), | ||
| 60 | new LocalSettlementContext(ledger)); | ||
| 61 | |||
| 62 | userRepo.add(new User("Zoe", 500)); | ||
| 63 | userRepo.add(new User("Alice", 200)); | ||
| 64 | userRepo.add(new User("Bob", 200)); | ||
| 65 | userRepo.add(new User("Carol", 200)); | ||
| 66 | } | ||
| 67 | |||
| 68 | @ParameterizedTest(name = "commission {0}") | ||
| 69 | @EnumSource(value = CommissionTiming.class, names = {"ON_PURCHASE", "ON_CLOSE"}) | ||
| 70 | public void willItRainTomorrow(CommissionTiming timing) { | ||
| 71 | boolean onPurchase = timing == CommissionTiming.ON_PURCHASE; | ||
| 72 | var created = | ||
| 73 | catalog.createEvent( | ||
| 74 | new CreateEventRequest( | ||
| 75 | "Will it rain tomorrow?", "", MechanismType.ORDER_BOOK, 1, timing, "Zoe", | ||
| 76 | null, BigDecimal.ONE, List.of("YES", "NO"), true)); | ||
| 77 | assertTrue(created.isSuccess(), created.getMessage()); | ||
| 78 | |||
| 79 | // 1. Market opens. 2. Zoe mints the first pairs: $100 into the pool, 100 YES + 100 NO, no quotes. | ||
| 80 | assertTrue(catalog.openEvent("1").isSuccess()); | ||
| 81 | assertCash("Zoe", "400.00"); | ||
| 82 | assertPool("100.00"); | ||
| 83 | assertHolds("Zoe", 100, 100); | ||
| 84 | assertBook(YES, List.of(), List.of()); | ||
| 85 | assertBook(NO, List.of(), List.of()); | ||
| 86 | |||
| 87 | // 3. Bob bids on YES. 4. Carol bids on YES too. | ||
| 88 | place("Bob", "YES", "BUY", "0.50", 20); | ||
| 89 | place("Carol", "YES", "BUY", "0.48", 15); | ||
| 90 | // 5. Zoe quotes a YES ask. 6. Zoe adds a deeper ask. | ||
| 91 | place("Zoe", "YES", "SELL", "0.58", 25); | ||
| 92 | assertEquals("$0.08", orderBook(YES).spread()); | ||
| 93 | assertEquals("0.54", book().prices()[YES].toPlainString()); // mid-price | ||
| 94 | place("Zoe", "YES", "SELL", "0.65", 15); | ||
| 95 | assertBook(YES, List.of("Bob 20@0.50", "Carol 15@0.48"), List.of("Zoe 25@0.58", "Zoe 15@0.65")); | ||
| 96 | |||
| 97 | // 7. Alice's order arrives. 8. Matched: a resale — Zoe is paid, the pool doesn't move. | ||
| 98 | var receipt = place("Alice", "YES", "BUY", "0.58", 25); | ||
| 99 | assertEquals("14.50", receipt.sharesCost()); | ||
| 100 | assertCash("Alice", onPurchase ? "185.36" : "185.50"); // 1% of 14.50 = 0.145 -> 0.14 | ||
| 101 | assertCash("Zoe", onPurchase ? "414.64" : "414.50"); | ||
| 102 | assertPool("100.00"); | ||
| 103 | assertBook(YES, List.of("Bob 20@0.50", "Carol 15@0.48"), List.of("Zoe 15@0.65")); | ||
| 104 | assertEquals("$0.15", orderBook(YES).spread()); // the cheap level is gone, spread widens | ||
| 105 | assertEquals("0.58", lastTrade(YES)); | ||
| 106 | |||
| 107 | // 9. Zoe quotes a NO ask. 10. Bob's order arrives. 11. Matched: a partial fill of Zoe's 50. | ||
| 108 | place("Zoe", "NO", "SELL", "0.45", 50); | ||
| 109 | receipt = place("Bob", "NO", "BUY", "0.45", 25); | ||
| 110 | assertEquals("11.25", receipt.sharesCost()); | ||
| 111 | assertBook(NO, List.of(), List.of("Zoe 25@0.45")); | ||
| 112 | assertCash("Bob", onPurchase ? "188.64" : "188.75"); | ||
| 113 | assertEquals("0.45", lastTrade(NO)); | ||
| 114 | |||
| 115 | // 12. Zoe's order arrives: sell 30 YES at 0.45 or better. | ||
| 116 | // 13. Matched: 20 at Bob's 0.50, then 10 at Carol's 0.48. (Self-trade prevention keeps it | ||
| 117 | // from merging against Zoe's own NO ask, which would otherwise pay 0.55.) | ||
| 118 | receipt = place("Zoe", "YES", "SELL", "0.45", 30); | ||
| 119 | assertEquals(2, receipt.trades().size()); | ||
| 120 | assertEquals("14.80", receipt.sharesCost()); | ||
| 121 | assertBook(YES, List.of("Carol 5@0.48"), List.of("Zoe 15@0.65")); | ||
| 122 | assertEquals("0.48", lastTrade(YES)); | ||
| 123 | assertCash("Zoe", onPurchase ? "440.95" : "440.55"); | ||
| 124 | assertCash("Bob", onPurchase ? "178.54" : "178.75"); | ||
| 125 | assertCash("Carol", onPurchase ? "195.15" : "195.20"); | ||
| 126 | assertHolds("Zoe", 45, 75); | ||
| 127 | |||
| 128 | // 14. Carol bids on NO below Zoe's 0.45 ask, so it rests. | ||
| 129 | place("Carol", "NO", "BUY", "0.42", 35); | ||
| 130 | assertBook(NO, List.of("Carol 35@0.42"), List.of("Zoe 25@0.45")); | ||
| 131 | |||
| 132 | // 15. Alice's order arrives: buy 40 YES at 0.62. No ask is that cheap, but 0.62 + 0.42 >= $1. | ||
| 133 | // 16. Matched: a peer-to-peer mint of min(40, 35) = 35 pairs. Carol pays her 0.42, Alice the | ||
| 134 | // remaining 0.58, both into the pool; Alice's other 5 rest at her own price. | ||
| 135 | receipt = place("Alice", "YES", "BUY", "0.62", 40); | ||
| 136 | assertEquals("35.00", receipt.sharesCost()); | ||
| 137 | assertPool("135.00"); | ||
| 138 | assertBook(YES, List.of("Alice 5@0.62", "Carol 5@0.48"), List.of("Zoe 15@0.65")); | ||
| 139 | assertBook(NO, List.of(), List.of("Zoe 25@0.45")); | ||
| 140 | assertEquals("0.58", lastTrade(YES)); | ||
| 141 | assertEquals("0.42", lastTrade(NO)); | ||
| 142 | assertHolds("Alice", 60, 0); | ||
| 143 | assertHolds("Carol", 10, 35); | ||
| 144 | assertCash("Alice", onPurchase ? "164.86" : "165.20"); | ||
| 145 | assertCash("Carol", onPurchase ? "180.30" : "180.50"); | ||
| 146 | |||
| 147 | // 17. An order gets rejected before it reaches the book. | ||
| 148 | var rejected = market.placeOrder("Bob", "1", "YES", "BUY", new BigDecimal("1.05"), 10); | ||
| 149 | assertFalse(rejected.isSuccess()); | ||
| 150 | assertEquals("Price must be between $0.01 and $0.99.", rejected.getMessage()); | ||
| 151 | assertBook(YES, List.of("Alice 5@0.62", "Carol 5@0.48"), List.of("Zoe 15@0.65")); | ||
| 152 | |||
| 153 | // 18. Bob asks 0.15 for his 25 NO. The walkthrough predates merging and leaves this unmatched, | ||
| 154 | // but 0.15 + Zoe's 0.65 YES ask <= $1, so 15 pairs merge back into the pool: Zoe is paid her | ||
| 155 | // 0.65, Bob the remaining 0.35. His other 10 NO rest, still with no buyer. | ||
| 156 | receipt = place("Bob", "NO", "SELL", "0.15", 25); | ||
| 157 | assertEquals("15.00", receipt.sharesCost()); // 15*0.35 + 15*0.65 | ||
| 158 | assertPool("120.00"); | ||
| 159 | assertBook(YES, List.of("Alice 5@0.62", "Carol 5@0.48"), List.of()); | ||
| 160 | assertBook(NO, List.of(), List.of("Bob 10@0.15", "Zoe 25@0.45")); | ||
| 161 | assertHolds("Zoe", 30, 75); | ||
| 162 | assertHolds("Bob", 20, 10); | ||
| 163 | assertCash("Bob", onPurchase ? "183.79" : "184.00"); | ||
| 164 | assertCash("Zoe", onPurchase ? "451.05" : "450.30"); | ||
| 165 | |||
| 166 | // 19. Resolution: YES wins. Each of the 120 YES shares pays $1 (less 1% on close, to Zoe), | ||
| 167 | // every resting order is cancelled and the pool is emptied. | ||
| 168 | assertTrue(market.settleEvent("Zoe", "1", "YES").isSuccess()); | ||
| 169 | assertPool("0.00"); | ||
| 170 | assertBook(YES, List.of(), List.of()); | ||
| 171 | assertBook(NO, List.of(), List.of()); | ||
| 172 | assertCash("Zoe", onPurchase ? "481.05" : "481.20"); | ||
| 173 | assertCash("Alice", onPurchase ? "224.86" : "224.60"); | ||
| 174 | assertCash("Bob", onPurchase ? "203.79" : "203.80"); | ||
| 175 | assertCash("Carol", onPurchase ? "190.30" : "190.40"); | ||
| 176 | assertEquals(onPurchase ? "0.75" : "1.20", event().getCommission().toPlainString()); | ||
| 177 | |||
| 178 | // Money is conserved: commission only moves cash to Zoe sooner or later. | ||
| 179 | var total = | ||
| 180 | userRepo.getAll().stream() | ||
| 181 | .map(u -> u.getLedger().getBalance()) | ||
| 182 | .reduce(BigDecimal.ZERO, BigDecimal::add); | ||
| 183 | assertEquals("1100.00", money(total)); | ||
| 184 | } | ||
| 185 | |||
| 186 | private PurchaseReceiptDTO place(String user, String option, String side, String price, long qty) { | ||
| 187 | var res = market.placeOrder(user, "1", option, side, new BigDecimal(price), qty); | ||
| 188 | assertTrue(res.isSuccess(), user + " " + side + " " + qty + " " + option + " @ " + price + ": " + res.getMessage()); | ||
| 189 | return res.getData(); | ||
| 190 | } | ||
| 191 | |||
| 192 | private Event event() { | ||
| 193 | return eventRepo.get("1").orElseThrow(); | ||
| 194 | } | ||
| 195 | |||
| 196 | private OrderBookTradingMechanism book() { | ||
| 197 | return (OrderBookTradingMechanism) event().getMechanism(); | ||
| 198 | } | ||
| 199 | |||
| 200 | private OrderBookDTO orderBook(int option) { | ||
| 201 | return catalog.getEvent("1").getData().orderBooks().get(option); | ||
| 202 | } | ||
| 203 | |||
| 204 | private String lastTrade(int option) { | ||
| 205 | return book().getLastTradePrice(option).toPlainString(); | ||
| 206 | } | ||
| 207 | |||
| 208 | private void assertBook(int option, List<String> bids, List<String> asks) { | ||
| 209 | assertEquals(bids, levels(book().getBids().get(option)), "bids"); | ||
| 210 | assertEquals(asks, levels(book().getAsks().get(option)), "asks"); | ||
| 211 | } | ||
| 212 | |||
| 213 | private static List<String> levels(List<RestingOrder> orders) { | ||
| 214 | return orders.stream() | ||
| 215 | .map(o -> o.getUser() + " " + o.getQuantity() + "@" + o.getPrice().toPlainString()) | ||
| 216 | .toList(); | ||
| 217 | } | ||
| 218 | |||
| 219 | private void assertCash(String user, String expected) { | ||
| 220 | assertEquals(expected, money(userRepo.get(user).orElseThrow().getLedger().getBalance()), user + " cash"); | ||
| 221 | } | ||
| 222 | |||
| 223 | private void assertPool(String expected) { | ||
| 224 | assertEquals(expected, money(event().getLedger().getBalance()), "pool"); | ||
| 225 | } | ||
| 226 | |||
| 227 | private void assertHolds(String user, long yes, long no) { | ||
| 228 | assertEquals(yes, event().getSharesHeldBy(user, "YES"), user + " YES"); | ||
| 229 | assertEquals(no, event().getSharesHeldBy(user, "NO"), user + " NO"); | ||
| 230 | } | ||
| 231 | |||
| 232 | private static String money(BigDecimal value) { | ||
| 233 | return value.setScale(2, RoundingMode.HALF_EVEN).toPlainString(); | ||
| 234 | } | ||
| 235 | } | ||
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 @@ | |||
| 1 | package market.guess.service; | ||
| 2 | |||
| 3 | import static org.junit.jupiter.api.Assertions.*; | ||
| 4 | |||
| 5 | import java.math.BigDecimal; | ||
| 6 | import java.nio.file.Path; | ||
| 7 | import market.guess.service.catalog.LocalCatalogContext; | ||
| 8 | import market.guess.service.catalog.infrastructure.MarketContext; | ||
| 9 | import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2; | ||
| 10 | import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2; | ||
| 11 | import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2; | ||
| 12 | import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; | ||
| 13 | import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; | ||
| 14 | import market.guess.service.fulfillment.LocalFulfillmentContext; | ||
| 15 | import market.guess.service.ledger.LedgerContext; | ||
| 16 | import market.guess.service.matching.LocalMatchingEngine; | ||
| 17 | import market.guess.service.mechanism.OrderBookTradingMechanism; | ||
| 18 | import market.guess.service.risk.LocalRiskEngine; | ||
| 19 | import market.guess.service.settlement.LocalSettlementContext; | ||
| 20 | import org.junit.jupiter.api.BeforeEach; | ||
| 21 | import org.junit.jupiter.api.Test; | ||
| 22 | |||
| 23 | public class OrderBookTradingTest { | ||
| 24 | |||
| 25 | private InMemoryEventRepository eventRepo; | ||
| 26 | private InMemoryUserRepository userRepo; | ||
| 27 | private MarketContext marketContext; | ||
| 28 | private LocalCatalogContext catalog; | ||
| 29 | private LocalGuessMarketContext guessMarket; | ||
| 30 | private LedgerContext ledgerContext; | ||
| 31 | |||
| 32 | @BeforeEach | ||
| 33 | public void setUp() { | ||
| 34 | eventRepo = new InMemoryEventRepository(); | ||
| 35 | userRepo = new InMemoryUserRepository(); | ||
| 36 | marketContext = new MarketContext(eventRepo, userRepo); | ||
| 37 | var loader = new XMLLoaderV2(eventRepo, userRepo, new EventMapperV2(), new XMLValidatorV2()); | ||
| 38 | catalog = new LocalCatalogContext(loader, marketContext, eventRepo); | ||
| 39 | ledgerContext = new LedgerContext(userRepo, java.time.Clock.systemUTC()); | ||
| 40 | var settlementContext = new LocalSettlementContext(ledgerContext); | ||
| 41 | guessMarket = | ||
| 42 | new LocalGuessMarketContext( | ||
| 43 | marketContext, | ||
| 44 | new LocalRiskEngine(eventRepo), | ||
| 45 | new LocalMatchingEngine(java.time.Clock.systemUTC()), | ||
| 46 | new LocalFulfillmentContext(ledgerContext), | ||
| 47 | settlementContext); | ||
| 48 | |||
| 49 | // Load small.xml from ex-2 | ||
| 50 | var loadRes = catalog.loadEvents(Path.of("test-data/ex-2/small.xml")); | ||
| 51 | assertTrue(loadRes.isSuccess(), "Loading small.xml should succeed"); | ||
| 52 | } | ||
| 53 | |||
| 54 | /** LMSR: the subsidy is paid once, and closing empties the contract back into user accounts. */ | ||
| 55 | @Test | ||
| 56 | public void testLmsrSettlementConservesMoney() { | ||
| 57 | var totalBefore = totalUserCash(); | ||
| 58 | |||
| 59 | assertTrue(catalog.openEvent("1").isSuccess()); | ||
| 60 | var contract = eventRepo.get("1").orElseThrow().getLedger(); | ||
| 61 | var tikva = userRepo.get("Tikva").orElseThrow().getLedger(); | ||
| 62 | // Contract holds exactly what Tikva paid, not a second, self-seeded copy. | ||
| 63 | assertEquals(0, new BigDecimal("10000").subtract(tikva.getBalance()).compareTo(contract.getBalance())); | ||
| 64 | |||
| 65 | var buy = guessMarket.placeOrder("Menash", "1", "1:0", null, 10); | ||
| 66 | assertTrue(buy.isSuccess(), buy.getMessage()); | ||
| 67 | assertEquals(0, totalBefore.compareTo(totalUserCash().add(contract.getBalance()))); | ||
| 68 | |||
| 69 | assertTrue(guessMarket.settleEvent("Tikva", "1", "1:0").isSuccess()); | ||
| 70 | assertEquals(0, contract.getBalance().signum(), "contract must be empty after close"); | ||
| 71 | assertEquals(0, totalBefore.compareTo(totalUserCash()), "no money created or lost"); | ||
| 72 | } | ||
| 73 | |||
| 74 | private BigDecimal totalUserCash() { | ||
| 75 | var total = BigDecimal.ZERO; | ||
| 76 | for (var u : userRepo.getAll()) total = total.add(u.getLedger().getBalance()); | ||
| 77 | return total; | ||
| 78 | } | ||
| 79 | |||
| 80 | /** Avrum (the MM) quotes his minted Argentina shares, which opening the event no longer does. */ | ||
| 81 | private void avrumAsks100At50() { | ||
| 82 | var res = guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.50"), 100); | ||
| 83 | assertTrue(res.isSuccess(), res.getMessage()); | ||
| 84 | } | ||
| 85 | |||
| 86 | @Test | ||
| 87 | public void testOpeningMintsWithoutQuoting() { | ||
| 88 | var avrum = userRepo.get("Avrum").orElseThrow(); | ||
| 89 | var before = avrum.getLedger().getBalance(); | ||
| 90 | |||
| 91 | var openRes = catalog.openEvent("2"); | ||
| 92 | assertTrue(openRes.isSuccess()); | ||
| 93 | |||
| 94 | // $100 in, 100 pairs out, nothing on the books until Avrum quotes. | ||
| 95 | assertEquals(0, before.subtract(new BigDecimal("100")).compareTo(avrum.getLedger().getBalance())); | ||
| 96 | var event = eventRepo.get("2").orElseThrow(); | ||
| 97 | assertEquals(100, event.getSharesHeldBy("Avrum", "2:0")); | ||
| 98 | assertEquals(100, event.getSharesHeldBy("Avrum", "2:1")); | ||
| 99 | |||
| 100 | var detail = catalog.getEvent("2").getData(); | ||
| 101 | assertEquals(2, detail.orderBooks().size()); | ||
| 102 | var obArg = detail.orderBooks().get(0); | ||
| 103 | assertEquals("2:0", obArg.optionKey()); | ||
| 104 | assertEquals("Argentina", obArg.optionName()); | ||
| 105 | assertNull(obArg.bestBid()); | ||
| 106 | assertNull(obArg.bestAsk()); | ||
| 107 | assertTrue(obArg.orders().isEmpty()); | ||
| 108 | } | ||
| 109 | |||
| 110 | @Test | ||
| 111 | public void testBuyMatchingRestingAsk() { | ||
| 112 | catalog.openEvent("2"); | ||
| 113 | avrumAsks100At50(); | ||
| 114 | |||
| 115 | var menash = userRepo.get("Menash").orElseThrow(); | ||
| 116 | var avrum = userRepo.get("Avrum").orElseThrow(); | ||
| 117 | var initialMenashBal = menash.getLedger().getBalance(); | ||
| 118 | var initialAvrumBal = avrum.getLedger().getBalance(); | ||
| 119 | |||
| 120 | // Menash buys 10 shares of Argentina @ 0.50 | ||
| 121 | var buyRes = | ||
| 122 | guessMarket.placeOrder( | ||
| 123 | "Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 10); | ||
| 124 | assertTrue(buyRes.isSuccess(), "Buy order should succeed: " + buyRes.getMessage()); | ||
| 125 | |||
| 126 | var receipt = buyRes.getData(); | ||
| 127 | assertEquals(1, receipt.trades().size()); | ||
| 128 | assertEquals("5.00", receipt.sharesCost()); | ||
| 129 | |||
| 130 | // Menash paid $5.00, Avrum received $5.00 | ||
| 131 | assertEquals(0, initialMenashBal.subtract(new BigDecimal("5.00")).compareTo(menash.getLedger().getBalance())); | ||
| 132 | assertEquals(0, initialAvrumBal.add(new BigDecimal("5.00")).compareTo(avrum.getLedger().getBalance())); | ||
| 133 | |||
| 134 | // Order book remaining ask should now be 90 shares @ 0.50 | ||
| 135 | var detail = catalog.getEvent("2").getData(); | ||
| 136 | var obArg = detail.orderBooks().get(0); | ||
| 137 | assertEquals(1, obArg.orders().size()); | ||
| 138 | assertEquals(90, obArg.orders().get(0).quantity()); | ||
| 139 | } | ||
| 140 | |||
| 141 | @Test | ||
| 142 | public void testBuyRestingInLadderWhenNoMatchingAsk() { | ||
| 143 | catalog.openEvent("2"); | ||
| 144 | avrumAsks100At50(); | ||
| 145 | |||
| 146 | // Menash places BUY order at 0.40 (below MM ask of 0.50) | ||
| 147 | var buyRes = | ||
| 148 | guessMarket.placeOrder( | ||
| 149 | "Menash", "2", "2:0", "BUY", new BigDecimal("0.40"), 20); | ||
| 150 | assertTrue(buyRes.isSuccess()); | ||
| 151 | |||
| 152 | var receipt = buyRes.getData(); | ||
| 153 | // No immediate trades executed | ||
| 154 | assertEquals(0, receipt.trades().size()); | ||
| 155 | |||
| 156 | // Order book should now have a BID and an ASK | ||
| 157 | var detail = catalog.getEvent("2").getData(); | ||
| 158 | var obArg = detail.orderBooks().get(0); | ||
| 159 | assertEquals("$0.40", obArg.bestBid()); | ||
| 160 | assertEquals("$0.50", obArg.bestAsk()); | ||
| 161 | assertEquals("$0.10", obArg.spread()); | ||
| 162 | assertEquals(2, obArg.orders().size()); | ||
| 163 | |||
| 164 | var bidOrder = obArg.orders().stream().filter(o -> "BID".equals(o.side())).findFirst().orElseThrow(); | ||
| 165 | assertEquals("Menash", bidOrder.user()); | ||
| 166 | assertEquals(20, bidOrder.quantity()); | ||
| 167 | assertEquals("$0.40", bidOrder.price()); | ||
| 168 | } | ||
| 169 | |||
| 170 | @Test | ||
| 171 | public void testSellMatchingRestingBid() { | ||
| 172 | catalog.openEvent("2"); | ||
| 173 | |||
| 174 | // 1. Menash places a resting bid: BUY 15 @ 0.45 | ||
| 175 | var bidRes = | ||
| 176 | guessMarket.placeOrder( | ||
| 177 | "Menash", "2", "2:0", "BUY", new BigDecimal("0.45"), 15); | ||
| 178 | assertTrue(bidRes.isSuccess()); | ||
| 179 | |||
| 180 | // 2. Avrum (who holds initial 100 shares of Argentina) sells 10 shares @ 0.45 | ||
| 181 | var initialAvrumBal = userRepo.get("Avrum").orElseThrow().getLedger().getBalance(); | ||
| 182 | var initialMenashBal = userRepo.get("Menash").orElseThrow().getLedger().getBalance(); | ||
| 183 | |||
| 184 | var sellRes = | ||
| 185 | guessMarket.placeOrder( | ||
| 186 | "Avrum", "2", "2:0", "SELL", new BigDecimal("0.45"), 10); | ||
| 187 | assertTrue(sellRes.isSuccess(), "Sell order should succeed: " + sellRes.getMessage()); | ||
| 188 | |||
| 189 | var receipt = sellRes.getData(); | ||
| 190 | assertEquals(1, receipt.trades().size()); | ||
| 191 | // 10 shares @ 0.45 = 4.50 | ||
| 192 | assertEquals("4.50", receipt.sharesCost()); | ||
| 193 | |||
| 194 | // Avrum received 4.50, Menash paid 4.50 | ||
| 195 | var currentAvrumBal = userRepo.get("Avrum").orElseThrow().getLedger().getBalance(); | ||
| 196 | var currentMenashBal = userRepo.get("Menash").orElseThrow().getLedger().getBalance(); | ||
| 197 | assertEquals(0, initialAvrumBal.add(new BigDecimal("4.50")).compareTo(currentAvrumBal)); | ||
| 198 | assertEquals(0, initialMenashBal.subtract(new BigDecimal("4.50")).compareTo(currentMenashBal)); | ||
| 199 | |||
| 200 | // Remaining resting bid should have 5 shares left | ||
| 201 | var detail = catalog.getEvent("2").getData(); | ||
| 202 | var obArg = detail.orderBooks().get(0); | ||
| 203 | var remainingBid = obArg.orders().stream().filter(o -> "BID".equals(o.side())).findFirst().orElseThrow(); | ||
| 204 | assertEquals(5, remainingBid.quantity()); | ||
| 205 | } | ||
| 206 | |||
| 207 | @Test | ||
| 208 | public void testSellRestingInLadder() { | ||
| 209 | catalog.openEvent("2"); | ||
| 210 | avrumAsks100At50(); | ||
| 211 | |||
| 212 | // Menash first buys 10 shares @ 0.50 from Avrum | ||
| 213 | guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 10); | ||
| 214 | |||
| 215 | // Menash now owns 10 shares. Menash places a SELL order for 5 shares @ 0.60 (higher than existing ask 0.50) | ||
| 216 | var sellRes = | ||
| 217 | guessMarket.placeOrder( | ||
| 218 | "Menash", "2", "2:0", "SELL", new BigDecimal("0.60"), 5); | ||
| 219 | assertTrue(sellRes.isSuccess()); | ||
| 220 | |||
| 221 | var detail = catalog.getEvent("2").getData(); | ||
| 222 | var obArg = detail.orderBooks().get(0); | ||
| 223 | |||
| 224 | // The asks should contain Avrum's 90 @ 0.50 and Menash's 5 @ 0.60 | ||
| 225 | var menashAsk = | ||
| 226 | obArg.orders().stream() | ||
| 227 | .filter(o -> "Menash".equals(o.user()) && "ASK".equals(o.side())) | ||
| 228 | .findFirst() | ||
| 229 | .orElse(null); | ||
| 230 | assertNotNull(menashAsk); | ||
| 231 | assertEquals(5, menashAsk.quantity()); | ||
| 232 | assertEquals("$0.60", menashAsk.price()); | ||
| 233 | } | ||
| 234 | |||
| 235 | @Test | ||
| 236 | public void testRiskEngineBlocksUnauthorizedSell() { | ||
| 237 | catalog.openEvent("2"); | ||
| 238 | |||
| 239 | // Menash currently holds 0 shares of Argentina. Attempting to sell must be rejected by risk engine. | ||
| 240 | var sellRes = | ||
| 241 | guessMarket.placeOrder( | ||
| 242 | "Menash", "2", "2:0", "SELL", new BigDecimal("0.50"), 5); | ||
| 243 | assertFalse(sellRes.isSuccess(), "Sell should be rejected when user holds insufficient shares"); | ||
| 244 | assertTrue(sellRes.getMessage().contains("Insufficient shares")); | ||
| 245 | } | ||
| 246 | |||
| 247 | @Test | ||
| 248 | public void testRiskEngineBlocksInsufficientFundsBuy() { | ||
| 249 | catalog.openEvent("2"); | ||
| 250 | |||
| 251 | // Menash initial cash is 100. Attempting to buy 500 shares @ 0.50 ($250) must fail. | ||
| 252 | var buyRes = | ||
| 253 | guessMarket.placeOrder( | ||
| 254 | "Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 500); | ||
| 255 | assertFalse(buyRes.isSuccess(), "Buy should be rejected when user has insufficient funds"); | ||
| 256 | assertTrue(buyRes.getMessage().contains("Insufficient balance")); | ||
| 257 | } | ||
| 258 | |||
| 259 | @Test | ||
| 260 | public void testRestingOrdersReserveCashAndShares() { | ||
| 261 | catalog.openEvent("2"); | ||
| 262 | |||
| 263 | // Menash has $100: a resting $90 bid leaves only $10 for anything else. | ||
| 264 | assertTrue(guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.60"), 150).isSuccess()); | ||
| 265 | var overBudget = guessMarket.placeOrder("Menash", "2", "2:1", "BUY", new BigDecimal("0.60"), 20); | ||
| 266 | assertFalse(overBudget.isSuccess()); | ||
| 267 | assertTrue(overBudget.getMessage().contains("Insufficient balance")); | ||
| 268 | |||
| 269 | // Avrum holds 100 Argentina: 60 already offered leaves 40 to offer again. | ||
| 270 | assertTrue(guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.70"), 60).isSuccess()); | ||
| 271 | var overSold = guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.80"), 50); | ||
| 272 | assertFalse(overSold.isSuccess()); | ||
| 273 | assertTrue(overSold.getMessage().contains("Insufficient shares")); | ||
| 274 | } | ||
| 275 | |||
| 276 | @Test | ||
| 277 | public void testNoSelfTrade() { | ||
| 278 | catalog.openEvent("2"); | ||
| 279 | assertTrue(guessMarket.placeOrder("Avrum", "2", "2:0", "SELL", new BigDecimal("0.55"), 10).isSuccess()); | ||
| 280 | var res = guessMarket.placeOrder("Avrum", "2", "2:0", "BUY", new BigDecimal("0.60"), 10); | ||
| 281 | assertTrue(res.isSuccess()); | ||
| 282 | assertEquals(0, res.getData().trades().size()); | ||
| 283 | assertEquals(2, catalog.getEvent("2").getData().orderBooks().get(0).orders().size()); | ||
| 284 | } | ||
| 285 | |||
| 286 | @Test | ||
| 287 | public void testOrderBookParticipantsUpdatingOnTrades() { | ||
| 288 | // 1. Initial draft: participants contains Avrum with 0 shares, $0.00 value | ||
| 289 | var draftDetail = catalog.getEvent("2").getData(); | ||
| 290 | assertNotNull(draftDetail.participants()); | ||
| 291 | assertEquals(1, draftDetail.participants().size()); | ||
| 292 | var draftAvrum = draftDetail.participants().get(0); | ||
| 293 | assertEquals("Avrum", draftAvrum.user()); | ||
| 294 | assertEquals("MM", draftAvrum.tag()); | ||
| 295 | assertEquals(0, draftAvrum.yes()); | ||
| 296 | assertEquals(0, draftAvrum.no()); | ||
| 297 | assertEquals("$0.00", draftAvrum.value()); | ||
| 298 | |||
| 299 | // 2. Open event: Avrum minted 100 YES, 100 NO, value $100.00 (100*0.50 + 100*0.50) | ||
| 300 | catalog.openEvent("2"); | ||
| 301 | var openDetail = catalog.getEvent("2").getData(); | ||
| 302 | assertEquals(1, openDetail.participants().size()); | ||
| 303 | var openAvrum = openDetail.participants().get(0); | ||
| 304 | assertEquals("Avrum", openAvrum.user()); | ||
| 305 | assertEquals("MM", openAvrum.tag()); | ||
| 306 | assertEquals(100, openAvrum.yes()); | ||
| 307 | assertEquals(100, openAvrum.no()); | ||
| 308 | assertEquals("$100.00", openAvrum.value()); | ||
| 309 | |||
| 310 | // 3. Avrum quotes, Menash buys 10 YES shares (Argentina) @ 0.50 | ||
| 311 | avrumAsks100At50(); | ||
| 312 | var buyRes = | ||
| 313 | guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 10); | ||
| 314 | assertTrue(buyRes.isSuccess()); | ||
| 315 | |||
| 316 | var afterBuyDetail = catalog.getEvent("2").getData(); | ||
| 317 | assertEquals(2, afterBuyDetail.participants().size()); | ||
| 318 | |||
| 319 | var avrumAfterBuy = | ||
| 320 | afterBuyDetail.participants().stream() | ||
| 321 | .filter(p -> "Avrum".equals(p.user())) | ||
| 322 | .findFirst() | ||
| 323 | .orElseThrow(); | ||
| 324 | assertEquals("MM", avrumAfterBuy.tag()); | ||
| 325 | assertEquals(90, avrumAfterBuy.yes()); | ||
| 326 | assertEquals(100, avrumAfterBuy.no()); | ||
| 327 | assertEquals("$95.00", avrumAfterBuy.value()); | ||
| 328 | |||
| 329 | var menashAfterBuy = | ||
| 330 | afterBuyDetail.participants().stream() | ||
| 331 | .filter(p -> "Menash".equals(p.user())) | ||
| 332 | .findFirst() | ||
| 333 | .orElseThrow(); | ||
| 334 | assertEquals("", menashAfterBuy.tag()); | ||
| 335 | assertEquals(10, menashAfterBuy.yes()); | ||
| 336 | assertEquals(0, menashAfterBuy.no()); | ||
| 337 | assertEquals("$5.00", menashAfterBuy.value()); | ||
| 338 | |||
| 339 | // 4. Avrum places a resting BID for 10 @ 0.40 (below 0.50 ask), and Menash sells 4 YES shares @ 0.40 | ||
| 340 | var avrumBid = | ||
| 341 | guessMarket.placeOrder("Avrum", "2", "2:0", "BUY", new BigDecimal("0.40"), 10); | ||
| 342 | assertTrue(avrumBid.isSuccess()); | ||
| 343 | var menashSell = | ||
| 344 | guessMarket.placeOrder("Menash", "2", "2:0", "SELL", new BigDecimal("0.40"), 4); | ||
| 345 | assertTrue(menashSell.isSuccess()); | ||
| 346 | |||
| 347 | var afterSellDetail = catalog.getEvent("2").getData(); | ||
| 348 | var avrumAfterSell = | ||
| 349 | afterSellDetail.participants().stream() | ||
| 350 | .filter(p -> "Avrum".equals(p.user())) | ||
| 351 | .findFirst() | ||
| 352 | .orElseThrow(); | ||
| 353 | assertEquals(94, avrumAfterSell.yes()); | ||
| 354 | assertEquals(100, avrumAfterSell.no()); | ||
| 355 | assertEquals("$92.30", avrumAfterSell.value()); | ||
| 356 | |||
| 357 | var menashAfterSell = | ||
| 358 | afterSellDetail.participants().stream() | ||
| 359 | .filter(p -> "Menash".equals(p.user())) | ||
| 360 | .findFirst() | ||
| 361 | .orElseThrow(); | ||
| 362 | assertEquals(6, menashAfterSell.yes()); | ||
| 363 | assertEquals(0, menashAfterSell.no()); | ||
| 364 | assertEquals("$2.70", menashAfterSell.value()); | ||
| 365 | } | ||
| 366 | |||
| 367 | @Test | ||
| 368 | public void testSellWalksBidsAndLastTradeIsLastFill() { | ||
| 369 | catalog.openEvent("2"); | ||
| 370 | avrumAsks100At50(); | ||
| 371 | guessMarket.placeOrder("Tikva", "2", "2:0", "BUY", new BigDecimal("0.50"), 100); // Tikva owns the YES | ||
| 372 | guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.45"), 20); | ||
| 373 | guessMarket.placeOrder("Avrum", "2", "2:0", "BUY", new BigDecimal("0.44"), 15); | ||
| 374 | |||
| 375 | var res = guessMarket.placeOrder("Tikva", "2", "2:0", "SELL", new BigDecimal("0.40"), 30); | ||
| 376 | assertTrue(res.isSuccess(), res.getMessage()); | ||
| 377 | assertEquals(2, res.getData().trades().size()); | ||
| 378 | assertEquals("13.40", res.getData().sharesCost()); // 20*0.45 + 10*0.44 | ||
| 379 | |||
| 380 | var ob = (OrderBookTradingMechanism) eventRepo.get("2").orElseThrow().getMechanism(); | ||
| 381 | assertEquals(5, ob.getBids().get(0).get(0).getQuantity()); | ||
| 382 | assertEquals("0.44", ob.getLastTradePrice(0).toPlainString()); // last fill, not the 0.4467 average | ||
| 383 | } | ||
| 384 | |||
| 385 | @Test | ||
| 386 | public void testPriceMustBeStrictlyInsideZeroAndD() { | ||
| 387 | catalog.openEvent("2"); | ||
| 388 | for (var price : new String[] {"1.00", "0.00"}) { | ||
| 389 | var res = guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal(price), 1); | ||
| 390 | assertFalse(res.isSuccess()); | ||
| 391 | assertEquals("Price must be between $0.01 and $0.99.", res.getMessage()); | ||
| 392 | } | ||
| 393 | } | ||
| 394 | |||
| 395 | @Test | ||
| 396 | public void testResolutionCancelsRestingOrdersAndPaysHolders() { | ||
| 397 | catalog.openEvent("2"); | ||
| 398 | avrumAsks100At50(); | ||
| 399 | guessMarket.placeOrder("Menash", "2", "2:0", "BUY", new BigDecimal("0.50"), 20); | ||
| 400 | guessMarket.placeOrder("Tikva", "2", "2:0", "BUY", new BigDecimal("0.40"), 10); | ||
| 401 | // Menash resells 5 to Tikva: only Tikva should be paid for those. | ||
| 402 | guessMarket.placeOrder("Menash", "2", "2:0", "SELL", new BigDecimal("0.40"), 5); | ||
| 403 | |||
| 404 | var menash = userRepo.get("Menash").orElseThrow(); | ||
| 405 | var menashBal = menash.getLedger().getBalance(); | ||
| 406 | assertTrue(guessMarket.settleEvent("Avrum", "2", "2:0").isSuccess()); | ||
| 407 | |||
| 408 | for (var book : catalog.getEvent("2").getData().orderBooks()) { | ||
| 409 | assertTrue(book.orders().isEmpty()); | ||
| 410 | } | ||
| 411 | // 15 shares held * $1, less 15% on-close commission. | ||
| 412 | assertEquals(0, menashBal.add(new BigDecimal("12.75")).compareTo(menash.getLedger().getBalance())); | ||
| 413 | // Pool held exactly the 100 minted pairs, and all 100 winning shares were paid out. | ||
| 414 | assertEquals(0, eventRepo.get("2").orElseThrow().getLedger().getBalance().signum()); | ||
| 415 | } | ||
| 416 | } | ||
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; | |||
| 11 | import market.guess.model.ledger.LedgerDTO; | 11 | import market.guess.model.ledger.LedgerDTO; |
| 12 | import market.guess.model.ledger.LedgerEntryDTO; | 12 | import market.guess.model.ledger.LedgerEntryDTO; |
| 13 | import market.guess.model.ledger.LedgerType; | 13 | import market.guess.model.ledger.LedgerType; |
| 14 | import market.guess.service.helpers.InstantOptions; | ||
| 14 | import market.guess.service.ledger.Ledger; | 15 | import market.guess.service.ledger.Ledger; |
| 15 | import market.guess.service.ledger.LedgerEntry; | 16 | import market.guess.service.ledger.LedgerEntry; |
| 16 | import org.junit.Test; | 17 | import org.junit.Test; |
| @@ -90,24 +91,26 @@ public class LedgerMapperTest { | |||
| 90 | assertNull(entry.balanceAfter()); | 91 | assertNull(entry.balanceAfter()); |
| 91 | } | 92 | } |
| 92 | 93 | ||
| 93 | /** | ||
| 94 | * LedgerEntry.id/time and Ledger's account-blocked state have no matching AccountDTO/ | ||
| 95 | * LedgerEntryDTO source property, so MapStruct leaves them at their Java defaults. This pins that | ||
| 96 | * known gap; once the mapper is taught to fill seq/at/blocked, update these assertions instead of | ||
| 97 | * deleting them. | ||
| 98 | */ | ||
| 99 | @Test | 94 | @Test |
| 100 | public void unmappedTargetPropertiesFallBackToDefaults() { | 95 | public void mapsSeqAndAtFromEntryIdAndTime() { |
| 96 | Instant time = Instant.parse("2026-01-03T10:15:30Z"); | ||
| 101 | Ledger ledger = new Ledger("dave", new BigDecimal("0")); | 97 | Ledger ledger = new Ledger("dave", new BigDecimal("0")); |
| 102 | ledger.record( | 98 | ledger.record( |
| 103 | new LedgerEntry( | 99 | new LedgerEntry(7, time, LedgerType.COMMISSION, BigDecimal.ONE, BigDecimal.ONE, "n")); |
| 104 | 7, Instant.now(), LedgerType.COMMISSION, BigDecimal.ONE, BigDecimal.ONE, "n")); | ||
| 105 | 100 | ||
| 106 | LedgerDTO dto = mapper.ledgerToLedgerDTO(ledger); | 101 | LedgerDTO dto = mapper.ledgerToLedgerDTO(ledger); |
| 107 | 102 | ||
| 108 | assertFalse(dto.blocked()); | 103 | assertFalse(dto.blocked()); |
| 109 | LedgerEntryDTO entry = dto.entries().get(0); | 104 | LedgerEntryDTO entry = dto.entries().get(0); |
| 110 | assertEquals(0L, entry.seq()); | 105 | assertEquals(7L, entry.seq()); |
| 111 | assertNull(entry.at()); | 106 | assertEquals(InstantOptions.humanize(time), entry.at()); |
| 107 | } | ||
| 108 | |||
| 109 | @Test | ||
| 110 | public void nullEntryTimeMapsToNullAt() { | ||
| 111 | Ledger ledger = new Ledger("erin", new BigDecimal("0")); | ||
| 112 | ledger.record(new LedgerEntry(1, null, LedgerType.REFUND, BigDecimal.ONE, BigDecimal.ONE, "n")); | ||
| 113 | |||
| 114 | assertNull(mapper.ledgerToLedgerDTO(ledger).entries().get(0).at()); | ||
| 112 | } | 115 | } |
| 113 | } | 116 | } |
diff --git a/ui-desktop/src/test/java/market/guess/ui/desktop/AnimTest.java b/ui-desktop/src/test/java/market/guess/ui/desktop/AnimTest.java new file mode 100644 index 0000000..1a39ba1 --- /dev/null +++ b/ui-desktop/src/test/java/market/guess/ui/desktop/AnimTest.java | |||
| @@ -0,0 +1,70 @@ | |||
| 1 | package market.guess.ui.desktop; | ||
| 2 | |||
| 3 | import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| 4 | |||
| 5 | import market.guess.ui.desktop.components.graphic.GraphicHelper; | ||
| 6 | import market.guess.ui.desktop.components.graphic.MarketChartGraphic; | ||
| 7 | import market.guess.ui.desktop.components.graphic.Motion; | ||
| 8 | import market.guess.ui.desktop.components.graphic.SparklineGraphic; | ||
| 9 | import org.junit.jupiter.api.Test; | ||
| 10 | |||
| 11 | /** | ||
| 12 | * The handoff requires "animations off" to show the frozen final frame, fully legible and never | ||
| 13 | * blank. Since every graphic renders as a function of progress, that contract is exactly: at | ||
| 14 | * progress 1, every driver lands on its designed end value. | ||
| 15 | */ | ||
| 16 | class AnimTest { | ||
| 17 | private static final double[] BAR1 = {0, .35, .30, 1, .60, .6, 1, .35}; | ||
| 18 | |||
| 19 | @Test | ||
| 20 | void keyframesHoldTheirEndValue() { | ||
| 21 | assertEquals(.35, GraphicHelper.at(0, BAR1), 1e-9); | ||
| 22 | assertEquals(1.0, GraphicHelper.at(.30, BAR1), 1e-9); | ||
| 23 | assertEquals(.35, GraphicHelper.at(1, BAR1), 1e-9); | ||
| 24 | assertEquals(.675, GraphicHelper.at(.15, BAR1), 1e-9); // interpolates linearly between stops | ||
| 25 | assertEquals(.35, Motion.at(0, BAR1), 1e-9); | ||
| 26 | } | ||
| 27 | |||
| 28 | @Test | ||
| 29 | void ridersEndOnTheLastPointOfTheirPath() { | ||
| 30 | assertEquals( | ||
| 31 | MarketChartGraphic.YES_CURVE[6], | ||
| 32 | MarketChartGraphic.rideCurve(MarketChartGraphic.YES_CURVE, 1)[0], | ||
| 33 | 1e-6); | ||
| 34 | assertEquals( | ||
| 35 | MarketChartGraphic.YES_CURVE[7], | ||
| 36 | MarketChartGraphic.rideCurve(MarketChartGraphic.YES_CURVE, 1)[1], | ||
| 37 | 1e-6); | ||
| 38 | |||
| 39 | int last = SparklineGraphic.SPARK.length; | ||
| 40 | assertEquals( | ||
| 41 | SparklineGraphic.SPARK[last - 2], | ||
| 42 | SparklineGraphic.ridePolyline(SparklineGraphic.SPARK, 1)[0], | ||
| 43 | 1e-6); | ||
| 44 | assertEquals( | ||
| 45 | SparklineGraphic.SPARK[last - 1], | ||
| 46 | SparklineGraphic.ridePolyline(SparklineGraphic.SPARK, 1)[1], | ||
| 47 | 1e-6); | ||
| 48 | } | ||
| 49 | |||
| 50 | @Test | ||
| 51 | void ridersStartOnTheFirstPointOfTheirPath() { | ||
| 52 | assertEquals( | ||
| 53 | MarketChartGraphic.YES_CURVE[0], | ||
| 54 | MarketChartGraphic.rideCurve(MarketChartGraphic.YES_CURVE, 0)[0], | ||
| 55 | 1e-6); | ||
| 56 | assertEquals( | ||
| 57 | MarketChartGraphic.YES_CURVE[1], | ||
| 58 | MarketChartGraphic.rideCurve(MarketChartGraphic.YES_CURVE, 0)[1], | ||
| 59 | 1e-6); | ||
| 60 | |||
| 61 | assertEquals( | ||
| 62 | SparklineGraphic.SPARK[0], | ||
| 63 | SparklineGraphic.ridePolyline(SparklineGraphic.SPARK, 0)[0], | ||
| 64 | 1e-6); | ||
| 65 | assertEquals( | ||
| 66 | SparklineGraphic.SPARK[1], | ||
| 67 | SparklineGraphic.ridePolyline(SparklineGraphic.SPARK, 0)[1], | ||
| 68 | 1e-6); | ||
| 69 | } | ||
| 70 | } | ||
diff --git a/ui-desktop/src/test/java/market/guess/ui/desktop/AppStateTest.java b/ui-desktop/src/test/java/market/guess/ui/desktop/AppStateTest.java new file mode 100644 index 0000000..dbb1762 --- /dev/null +++ b/ui-desktop/src/test/java/market/guess/ui/desktop/AppStateTest.java | |||
| @@ -0,0 +1,499 @@ | |||
| 1 | package market.guess.ui.desktop; | ||
| 2 | |||
| 3 | import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| 4 | import static org.junit.jupiter.api.Assertions.assertNotNull; | ||
| 5 | import static org.junit.jupiter.api.Assertions.assertTrue; | ||
| 6 | |||
| 7 | import java.math.BigDecimal; | ||
| 8 | import java.nio.file.Path; | ||
| 9 | import java.util.concurrent.atomic.AtomicBoolean; | ||
| 10 | import market.guess.api.CatalogContext; | ||
| 11 | import market.guess.model.event.CommissionTiming; | ||
| 12 | import market.guess.model.event.CreateEventRequest; | ||
| 13 | import market.guess.model.event.MechanismType; | ||
| 14 | import market.guess.ui.desktop.components.AppTab; | ||
| 15 | import market.guess.ui.desktop.components.ModalDialog; | ||
| 16 | import org.junit.jupiter.api.BeforeEach; | ||
| 17 | import org.junit.jupiter.api.Test; | ||
| 18 | |||
| 19 | class AppStateTest { | ||
| 20 | private AppState state; | ||
| 21 | private CatalogContext catalog; | ||
| 22 | private ServiceEngine engine; | ||
| 23 | |||
| 24 | @BeforeEach | ||
| 25 | void setUp() { | ||
| 26 | engine = new ServiceEngine(); | ||
| 27 | var path = Path.of("test-data/ex-2/small.xml"); | ||
| 28 | var res = engine.getCatalogContext().loadEvents(path); | ||
| 29 | assertTrue(res.isSuccess(), "Loading small.xml should succeed"); | ||
| 30 | catalog = engine.getCatalogContext(); | ||
| 31 | state = new AppState(engine); | ||
| 32 | state.setLoadedFile(path.toString()); | ||
| 33 | state.refreshData(); | ||
| 34 | } | ||
| 35 | |||
| 36 | @Test | ||
| 37 | void reactivePropertiesNotifyListeners() { | ||
| 38 | AtomicBoolean skinChanged = new AtomicBoolean(false); | ||
| 39 | state.skinProperty().addListener((obs, oldVal, newVal) -> skinChanged.set(true)); | ||
| 40 | state.setSkin(Skin.CATPPUCCIN); | ||
| 41 | assertTrue(skinChanged.get()); | ||
| 42 | assertEquals(Skin.CATPPUCCIN, state.getSkin()); | ||
| 43 | |||
| 44 | AtomicBoolean tabChanged = new AtomicBoolean(false); | ||
| 45 | state.activeTabProperty().addListener((obs, oldVal, newVal) -> tabChanged.set(true)); | ||
| 46 | state.setActiveTab(AppTab.USERS); | ||
| 47 | assertTrue(tabChanged.get()); | ||
| 48 | assertEquals(AppTab.USERS, state.getActiveTab()); | ||
| 49 | |||
| 50 | AtomicBoolean dialogChanged = new AtomicBoolean(false); | ||
| 51 | state.dialogProperty().addListener((obs, oldVal, newVal) -> dialogChanged.set(true)); | ||
| 52 | state.setDialog(ModalDialog.CREATE_EVENT); | ||
| 53 | assertTrue(dialogChanged.get()); | ||
| 54 | assertEquals(ModalDialog.CREATE_EVENT, state.getDialog()); | ||
| 55 | } | ||
| 56 | |||
| 57 | @Test | ||
| 58 | void selectedEventUpdatesWhenIdChanges() { | ||
| 59 | assertNotNull(state.getSelectedEvent()); | ||
| 60 | |||
| 61 | // Add a new event | ||
| 62 | var req = | ||
| 63 | new CreateEventRequest( | ||
| 64 | "New Test Event", | ||
| 65 | "Test Description", | ||
| 66 | MechanismType.LMSR, | ||
| 67 | 5, | ||
| 68 | CommissionTiming.ON_PURCHASE, | ||
| 69 | "Avrum", | ||
| 70 | 150, | ||
| 71 | null, | ||
| 72 | java.util.List.of("YES", "NO"), | ||
| 73 | true); | ||
| 74 | var res = catalog.createEvent(req); | ||
| 75 | assertTrue(res.isSuccess(), "Create event should succeed: " + res.getMessage()); | ||
| 76 | state.refreshData(); | ||
| 77 | |||
| 78 | String newId = res.getData().summary().key(); | ||
| 79 | state.setSelectedEventId(newId); | ||
| 80 | assertEquals(newId, state.getSelectedEvent().id); | ||
| 81 | assertEquals("New Test Event", state.getSelectedEvent().name); | ||
| 82 | } | ||
| 83 | |||
| 84 | @Test | ||
| 85 | void actingUserUpdatesWhenNameChanges() { | ||
| 86 | assertNotNull(state.getActingUser()); | ||
| 87 | assertEquals("Avrum", state.getActingUserName()); | ||
| 88 | |||
| 89 | state.setActingUserName("Menash"); | ||
| 90 | assertNotNull(state.getActingUser()); | ||
| 91 | assertEquals("Menash", state.getActingUser().name); | ||
| 92 | } | ||
| 93 | |||
| 94 | @Test | ||
| 95 | void createEventWithFullParameters() { | ||
| 96 | var req = | ||
| 97 | new CreateEventRequest( | ||
| 98 | "Summit 2026", | ||
| 99 | "Will the summit take place?", | ||
| 100 | MechanismType.ORDER_BOOK, | ||
| 101 | 10, | ||
| 102 | CommissionTiming.ON_CLOSE, | ||
| 103 | "Avrum", | ||
| 104 | null, | ||
| 105 | new BigDecimal("2.50"), | ||
| 106 | java.util.List.of("YES", "NO"), | ||
| 107 | false); | ||
| 108 | var res = catalog.createEvent(req); | ||
| 109 | assertTrue(res.isSuccess(), "Create event should succeed: " + res.getMessage()); | ||
| 110 | var detail = res.getData(); | ||
| 111 | assertEquals("Summit 2026", detail.summary().name()); | ||
| 112 | assertEquals(MechanismType.ORDER_BOOK, detail.summary().mechanism()); | ||
| 113 | assertEquals(10, detail.summary().commissionPercent()); | ||
| 114 | assertEquals(CommissionTiming.ON_CLOSE, detail.summary().commissionTiming()); | ||
| 115 | } | ||
| 116 | |||
| 117 | @Test | ||
| 118 | void testServiceEngineAndXmlLoad() { | ||
| 119 | assertEquals(2, state.getEvents().size()); | ||
| 120 | assertEquals(3, state.getUsers().size()); | ||
| 121 | assertNotNull(state.getActingUser()); | ||
| 122 | } | ||
| 123 | |||
| 124 | @Test | ||
| 125 | void testLmsrTradingAndUpdate() { | ||
| 126 | var openRes = catalog.openEvent("1"); | ||
| 127 | assertTrue(openRes.isSuccess(), "Opening event 1 should succeed: " + openRes.getMessage()); | ||
| 128 | state.refreshData(); | ||
| 129 | |||
| 130 | var evBefore = state.event("1"); | ||
| 131 | assertNotNull(evBefore); | ||
| 132 | System.out.println( | ||
| 133 | "Before trade: yesPrice=" + evBefore.yesPrice + ", noPrice=" + evBefore.noPrice); | ||
| 134 | |||
| 135 | var orderRes = | ||
| 136 | engine.getMarketContext().placeOrder("Avrum", "1", "YES", "BUY", evBefore.yesPrice, 20); | ||
| 137 | assertTrue(orderRes.isSuccess(), "Place order should succeed: " + orderRes.getMessage()); | ||
| 138 | |||
| 139 | state.refreshData(); | ||
| 140 | var evAfter = state.event("1"); | ||
| 141 | assertEquals(new BigDecimal("0.55"), evAfter.yesPrice); | ||
| 142 | assertEquals(new BigDecimal("0.45"), evAfter.noPrice); | ||
| 143 | assertEquals(new BigDecimal("0.55"), state.getSelectedEvent().yesPrice); | ||
| 144 | |||
| 145 | System.out.println("=== LMSR EVENT AFTER TRADE ==="); | ||
| 146 | System.out.println("evAfter.trades.size() = " + evAfter.trades.size()); | ||
| 147 | for (var t : evAfter.trades) { | ||
| 148 | System.out.println( | ||
| 149 | " Trade: n=" | ||
| 150 | + t.n() | ||
| 151 | + ", user=" | ||
| 152 | + t.user() | ||
| 153 | + ", option=" | ||
| 154 | + t.option() | ||
| 155 | + ", yes=" | ||
| 156 | + t.yesOption() | ||
| 157 | + ", shares=" | ||
| 158 | + t.shares() | ||
| 159 | + ", paid=" | ||
| 160 | + t.paid()); | ||
| 161 | } | ||
| 162 | System.out.println("evAfter.participants.size() = " + evAfter.participants.size()); | ||
| 163 | for (var p : evAfter.participants) { | ||
| 164 | System.out.println( | ||
| 165 | " Participant: user=" | ||
| 166 | + p.user() | ||
| 167 | + ", tag=" | ||
| 168 | + p.tag() | ||
| 169 | + ", yes=" | ||
| 170 | + p.yes() | ||
| 171 | + ", no=" | ||
| 172 | + p.no() | ||
| 173 | + ", val=" | ||
| 174 | + p.value() | ||
| 175 | + ", fees=" | ||
| 176 | + p.fees()); | ||
| 177 | } | ||
| 178 | var avrum = state.user("Avrum"); | ||
| 179 | System.out.println("Avrum events size: " + avrum.events.size()); | ||
| 180 | for (var ue : avrum.events) { | ||
| 181 | System.out.println( | ||
| 182 | " userEvent: name=" | ||
| 183 | + ue.eventName() | ||
| 184 | + ", role=" | ||
| 185 | + ue.role() | ||
| 186 | + ", yes=" | ||
| 187 | + ue.yes() | ||
| 188 | + ", no=" | ||
| 189 | + ue.no()); | ||
| 190 | } | ||
| 191 | System.out.println("Avrum balanceHistory size: " + avrum.balanceHistory.size()); | ||
| 192 | for (var cp : avrum.balanceHistory) { | ||
| 193 | System.out.println(" BalancePoint: x=" + cp.x() + ", y=" + cp.y()); | ||
| 194 | } | ||
| 195 | assertTrue(avrum.balanceHistory.size() >= 2); | ||
| 196 | assertTrue(avrum.balanceHistory.get(0).x() > 1_000_000_000); | ||
| 197 | |||
| 198 | // Avrum sells 10 YES shares in LMSR | ||
| 199 | var sellRes = engine.getMarketContext().placeOrder("Avrum", "1", "YES", "SELL", null, 10); | ||
| 200 | assertTrue( | ||
| 201 | sellRes.isSuccess(), "Place LMSR sell order should succeed: " + sellRes.getMessage()); | ||
| 202 | state.refreshData(); | ||
| 203 | |||
| 204 | var evAfterSell = state.event("1"); | ||
| 205 | var avrumParticipant = | ||
| 206 | evAfterSell.participants.stream() | ||
| 207 | .filter(p -> p.user().equals("Avrum")) | ||
| 208 | .findFirst() | ||
| 209 | .orElseThrow(); | ||
| 210 | assertEquals(10, avrumParticipant.yes(), "Avrum should hold 10 YES shares after selling 10"); | ||
| 211 | assertEquals(10, evAfterSell.yesShares, "Event circulating YES shares should be 10"); | ||
| 212 | assertEquals(2, evAfterSell.trades.size(), "Event should have 2 trades recorded"); | ||
| 213 | |||
| 214 | var avrumAfterSell = state.user("Avrum"); | ||
| 215 | var avrumEv = | ||
| 216 | avrumAfterSell.events.stream() | ||
| 217 | .filter(e -> e.eventId().equals("1")) | ||
| 218 | .findFirst() | ||
| 219 | .orElseThrow(); | ||
| 220 | assertEquals(10, avrumEv.yes(), "Avrum user event row should show 10 shares"); | ||
| 221 | } | ||
| 222 | |||
| 223 | @Test | ||
| 224 | void testOrderBookTradingAndParticipants() { | ||
| 225 | var openRes = catalog.openEvent("2"); | ||
| 226 | assertTrue(openRes.isSuccess()); | ||
| 227 | state.refreshData(); | ||
| 228 | |||
| 229 | var ev2 = state.event("2"); | ||
| 230 | System.out.println("=== ORDER BOOK EVENT BEFORE TRADE ==="); | ||
| 231 | System.out.println("ev2.participants.size() = " + ev2.participants.size()); | ||
| 232 | for (var p : ev2.participants) { | ||
| 233 | System.out.println( | ||
| 234 | " Participant: user=" | ||
| 235 | + p.user() | ||
| 236 | + ", tag=" | ||
| 237 | + p.tag() | ||
| 238 | + ", yes=" | ||
| 239 | + p.yes() | ||
| 240 | + ", no=" | ||
| 241 | + p.no() | ||
| 242 | + ", val=" | ||
| 243 | + p.value() | ||
| 244 | + ", fees=" | ||
| 245 | + p.fees()); | ||
| 246 | } | ||
| 247 | |||
| 248 | // Avrum (MM) quotes his minted YES shares; opening the event doesn't do it for him. | ||
| 249 | var quote = | ||
| 250 | engine | ||
| 251 | .getMarketContext() | ||
| 252 | .placeOrder("Avrum", "2", "YES", "SELL", new BigDecimal("0.50"), 100); | ||
| 253 | assertTrue(quote.isSuccess(), "Avrum quote should succeed: " + quote.getMessage()); | ||
| 254 | |||
| 255 | // Menash buys 20 YES @ 0.50 on event 2 | ||
| 256 | var res = | ||
| 257 | engine | ||
| 258 | .getMarketContext() | ||
| 259 | .placeOrder("Menash", "2", "YES", "BUY", new BigDecimal("0.50"), 20); | ||
| 260 | assertTrue(res.isSuccess(), "Menash place order should succeed: " + res.getMessage()); | ||
| 261 | state.refreshData(); | ||
| 262 | |||
| 263 | var ev2After = state.event("2"); | ||
| 264 | System.out.println("=== ORDER BOOK EVENT AFTER TRADE ==="); | ||
| 265 | System.out.println("ev2After.trades.size() = " + ev2After.trades.size()); | ||
| 266 | for (var t : ev2After.trades) { | ||
| 267 | System.out.println( | ||
| 268 | " Trade: n=" | ||
| 269 | + t.n() | ||
| 270 | + ", user=" | ||
| 271 | + t.user() | ||
| 272 | + ", option=" | ||
| 273 | + t.option() | ||
| 274 | + ", yes=" | ||
| 275 | + t.yesOption() | ||
| 276 | + ", shares=" | ||
| 277 | + t.shares() | ||
| 278 | + ", paid=" | ||
| 279 | + t.paid()); | ||
| 280 | } | ||
| 281 | System.out.println("ev2After.participants.size() = " + ev2After.participants.size()); | ||
| 282 | for (var p : ev2After.participants) { | ||
| 283 | System.out.println( | ||
| 284 | " Participant: user=" | ||
| 285 | + p.user() | ||
| 286 | + ", tag=" | ||
| 287 | + p.tag() | ||
| 288 | + ", yes=" | ||
| 289 | + p.yes() | ||
| 290 | + ", no=" | ||
| 291 | + p.no() | ||
| 292 | + ", val=" | ||
| 293 | + p.value() | ||
| 294 | + ", fees=" | ||
| 295 | + p.fees()); | ||
| 296 | } | ||
| 297 | var menash = state.user("Menash"); | ||
| 298 | System.out.println("Menash events size: " + menash.events.size()); | ||
| 299 | for (var ue : menash.events) { | ||
| 300 | System.out.println( | ||
| 301 | " Menash userEvent: name=" | ||
| 302 | + ue.eventName() | ||
| 303 | + ", role=" | ||
| 304 | + ue.role() | ||
| 305 | + ", yes=" | ||
| 306 | + ue.yes() | ||
| 307 | + ", no=" | ||
| 308 | + ue.no()); | ||
| 309 | } | ||
| 310 | |||
| 311 | // Menash SELLS 10 YES @ 0.40 (better price than Avrum's 0.50) | ||
| 312 | var sellRes = | ||
| 313 | engine | ||
| 314 | .getMarketContext() | ||
| 315 | .placeOrder("Menash", "2", "YES", "SELL", new BigDecimal("0.40"), 10); | ||
| 316 | assertTrue(sellRes.isSuccess(), "Menash sell order should succeed: " + sellRes.getMessage()); | ||
| 317 | state.refreshData(); | ||
| 318 | |||
| 319 | // Tikva BUYS 10 YES @ 0.50 (crosses Menash's 0.40 ask!) | ||
| 320 | var tikvaBuy = | ||
| 321 | engine | ||
| 322 | .getMarketContext() | ||
| 323 | .placeOrder("Tikva", "2", "YES", "BUY", new BigDecimal("0.50"), 10); | ||
| 324 | assertTrue(tikvaBuy.isSuccess()); | ||
| 325 | state.refreshData(); | ||
| 326 | |||
| 327 | var ev2AfterSell = state.event("2"); | ||
| 328 | System.out.println("=== ORDER BOOK EVENT AFTER SELL TRADE ==="); | ||
| 329 | System.out.println("ev2AfterSell.trades.size() = " + ev2AfterSell.trades.size()); | ||
| 330 | for (var t : ev2AfterSell.trades) { | ||
| 331 | System.out.println( | ||
| 332 | " Trade: n=" | ||
| 333 | + t.n() | ||
| 334 | + ", user=" | ||
| 335 | + t.user() | ||
| 336 | + ", option=" | ||
| 337 | + t.option() | ||
| 338 | + ", yes=" | ||
| 339 | + t.yesOption() | ||
| 340 | + ", shares=" | ||
| 341 | + t.shares() | ||
| 342 | + ", paid=" | ||
| 343 | + t.paid()); | ||
| 344 | } | ||
| 345 | var domainEv2 = engine.getCatalogContext().getEvent("2"); | ||
| 346 | System.out.println("Domain event 2 trades count: " + domainEv2.getData()); | ||
| 347 | long mShares0 = | ||
| 348 | engine.getCatalogContext().getEvent("2").getData().participants().stream() | ||
| 349 | .filter(p -> p.user().equals("Menash")) | ||
| 350 | .findFirst() | ||
| 351 | .get() | ||
| 352 | .yes(); | ||
| 353 | System.out.println("Menash YES shares in ParticipantDTO: " + mShares0); | ||
| 354 | System.out.println("ev2AfterSell.participants.size() = " + ev2AfterSell.participants.size()); | ||
| 355 | for (var p : ev2AfterSell.participants) { | ||
| 356 | System.out.println( | ||
| 357 | " Participant: user=" | ||
| 358 | + p.user() | ||
| 359 | + ", tag=" | ||
| 360 | + p.tag() | ||
| 361 | + ", yes=" | ||
| 362 | + p.yes() | ||
| 363 | + ", no=" | ||
| 364 | + p.no() | ||
| 365 | + ", val=" | ||
| 366 | + p.value() | ||
| 367 | + ", fees=" | ||
| 368 | + p.fees()); | ||
| 369 | } | ||
| 370 | menash = state.user("Menash"); | ||
| 371 | for (var ue : menash.events) { | ||
| 372 | System.out.println( | ||
| 373 | " Menash userEvent after sell: name=" | ||
| 374 | + ue.eventName() | ||
| 375 | + ", role=" | ||
| 376 | + ue.role() | ||
| 377 | + ", yes=" | ||
| 378 | + ue.yes() | ||
| 379 | + ", no=" | ||
| 380 | + ue.no()); | ||
| 381 | } | ||
| 382 | |||
| 383 | var menashP = | ||
| 384 | ev2AfterSell.participants.stream() | ||
| 385 | .filter(p -> p.user().equals("Menash")) | ||
| 386 | .findFirst() | ||
| 387 | .orElseThrow(); | ||
| 388 | assertEquals(10, menashP.yes(), "Menash should have 10 YES shares after selling 10 of 20"); | ||
| 389 | var tikvaP = | ||
| 390 | ev2AfterSell.participants.stream() | ||
| 391 | .filter(p -> p.user().equals("Tikva")) | ||
| 392 | .findFirst() | ||
| 393 | .orElseThrow(); | ||
| 394 | assertEquals(10, tikvaP.yes(), "Tikva should have 10 YES shares after buying 10"); | ||
| 395 | var avrumP = | ||
| 396 | ev2AfterSell.participants.stream() | ||
| 397 | .filter(p -> p.user().equals("Avrum")) | ||
| 398 | .findFirst() | ||
| 399 | .orElseThrow(); | ||
| 400 | assertEquals(80, avrumP.yes(), "Avrum (MM) should have 80 YES shares"); | ||
| 401 | assertEquals(2, ev2AfterSell.trades.size(), "Order book event should have 2 trades"); | ||
| 402 | } | ||
| 403 | |||
| 404 | @Test | ||
| 405 | void testEventsTabControllerProgressBar() throws Exception { | ||
| 406 | try { | ||
| 407 | javafx.application.Platform.startup(() -> {}); | ||
| 408 | } catch (IllegalStateException ignored) { | ||
| 409 | } | ||
| 410 | |||
| 411 | var loader = new javafx.fxml.FXMLLoader(getClass().getResource("controllers/events_tab.fxml")); | ||
| 412 | javafx.scene.Parent root = loader.load(); | ||
| 413 | market.guess.ui.desktop.controllers.EventsTabController ctrl = loader.getController(); | ||
| 414 | |||
| 415 | ctrl.init(state); | ||
| 416 | ctrl.refresh(); | ||
| 417 | |||
| 418 | var field = | ||
| 419 | market.guess.ui.desktop.controllers.EventsTabController.class.getDeclaredField( | ||
| 420 | "lmsrYesProgressBar"); | ||
| 421 | field.setAccessible(true); | ||
| 422 | javafx.scene.control.ProgressBar pb = (javafx.scene.control.ProgressBar) field.get(ctrl); | ||
| 423 | |||
| 424 | assertEquals(0.5, pb.getProgress(), 0.001); | ||
| 425 | |||
| 426 | // Switch to event 2 (Order Book) | ||
| 427 | state.setSelectedEventId("2"); | ||
| 428 | // Switch back to event 1 (LMSR) | ||
| 429 | state.setSelectedEventId("1"); | ||
| 430 | assertEquals(0.5, pb.getProgress(), 0.001); | ||
| 431 | |||
| 432 | // Open event 1 | ||
| 433 | catalog.openEvent("1"); | ||
| 434 | state.refreshData(); | ||
| 435 | assertEquals(0.5, pb.getProgress(), 0.001); | ||
| 436 | |||
| 437 | // Trade 20 YES on event 1 | ||
| 438 | var res = | ||
| 439 | engine | ||
| 440 | .getMarketContext() | ||
| 441 | .placeOrder("Avrum", "1", "YES", "BUY", state.event("1").yesPrice, 20); | ||
| 442 | assertTrue(res.isSuccess()); | ||
| 443 | state.refreshData(); | ||
| 444 | assertEquals(0.55, pb.getProgress(), 0.001); | ||
| 445 | |||
| 446 | root.getStyleClass().add("gm-root"); | ||
| 447 | javafx.scene.Scene scene = new javafx.scene.Scene(root); | ||
| 448 | scene.getStylesheets().add(getClass().getResource("theme.css").toExternalForm()); | ||
| 449 | root.applyCss(); | ||
| 450 | root.layout(); | ||
| 451 | |||
| 452 | javafx.scene.Node track = pb.lookup(".track"); | ||
| 453 | javafx.scene.Node bar = pb.lookup(".bar"); | ||
| 454 | assertNotNull(track); | ||
| 455 | assertNotNull(bar); | ||
| 456 | if (bar instanceof javafx.scene.layout.Region rBar) { | ||
| 457 | assertEquals(0.0, rBar.getPadding().getTop(), 0.001); | ||
| 458 | assertEquals(0.0, rBar.getPadding().getBottom(), 0.001); | ||
| 459 | assertNotNull(rBar.getBackground()); | ||
| 460 | assertEquals(1, rBar.getBackground().getFills().size()); | ||
| 461 | assertEquals(0.0, rBar.getBackground().getFills().get(0).getInsets().getTop(), 0.001); | ||
| 462 | } | ||
| 463 | if (track instanceof javafx.scene.layout.Region rTrack) { | ||
| 464 | assertEquals(0.0, rTrack.getPadding().getTop(), 0.001); | ||
| 465 | assertEquals(0.0, rTrack.getPadding().getBottom(), 0.001); | ||
| 466 | assertNotNull(rTrack.getBackground()); | ||
| 467 | } | ||
| 468 | } | ||
| 469 | |||
| 470 | @Test | ||
| 471 | void testEventDataAndUserDataEquals() { | ||
| 472 | var ev1 = state.event("1"); | ||
| 473 | assertNotNull(ev1); | ||
| 474 | var ev1Copy = | ||
| 475 | new market.guess.ui.desktop.model.EventData( | ||
| 476 | ev1.id, | ||
| 477 | ev1.num, | ||
| 478 | ev1.name, | ||
| 479 | ev1.desc, | ||
| 480 | ev1.type, | ||
| 481 | ev1.status, | ||
| 482 | ev1.mm, | ||
| 483 | ev1.feePercent, | ||
| 484 | ev1.feeMode, | ||
| 485 | ev1.contract.doubleValue(), | ||
| 486 | ev1.liquidityB, | ||
| 487 | ev1.baseValueD); | ||
| 488 | assertEquals(ev1, ev1Copy); | ||
| 489 | assertEquals(ev1.hashCode(), ev1Copy.hashCode()); | ||
| 490 | |||
| 491 | var u1 = state.user("Avrum"); | ||
| 492 | assertNotNull(u1); | ||
| 493 | var u1Copy = | ||
| 494 | new market.guess.ui.desktop.model.UserData( | ||
| 495 | "Avrum", u1.role, u1.balance.doubleValue(), u1.blocked, u1.isMm); | ||
| 496 | assertEquals(u1, u1Copy); | ||
| 497 | assertEquals(u1.hashCode(), u1Copy.hashCode()); | ||
| 498 | } | ||
| 499 | } | ||
diff --git a/ui-desktop/src/test/java/market/guess/ui/desktop/ChartsTest.java b/ui-desktop/src/test/java/market/guess/ui/desktop/ChartsTest.java new file mode 100644 index 0000000..c5cb280 --- /dev/null +++ b/ui-desktop/src/test/java/market/guess/ui/desktop/ChartsTest.java | |||
| @@ -0,0 +1,32 @@ | |||
| 1 | package market.guess.ui.desktop; | ||
| 2 | |||
| 3 | import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| 4 | import static org.junit.jupiter.api.Assertions.assertTrue; | ||
| 5 | |||
| 6 | import market.guess.ui.desktop.util.Charts; | ||
| 7 | import org.junit.jupiter.api.Test; | ||
| 8 | |||
| 9 | class ChartsTest { | ||
| 10 | |||
| 11 | @Test | ||
| 12 | void testToEpochSecondFromIndex() { | ||
| 13 | double t0 = Charts.toEpochSecond(0); | ||
| 14 | double t1 = Charts.toEpochSecond(1); | ||
| 15 | double t2 = Charts.toEpochSecond(2); | ||
| 16 | |||
| 17 | assertEquals(Charts.BASE_TIME, t0, 1e-6); | ||
| 18 | assertEquals(Charts.BASE_TIME + 1800, t1, 1e-6); | ||
| 19 | assertEquals(Charts.BASE_TIME + 3600, t2, 1e-6); | ||
| 20 | } | ||
| 21 | |||
| 22 | @Test | ||
| 23 | void testToEpochSecondIdempotentForTimestamps() { | ||
| 24 | long epoch = 1_700_000_000L; | ||
| 25 | assertEquals(epoch, Charts.toEpochSecond(epoch), 1e-6); | ||
| 26 | } | ||
| 27 | |||
| 28 | @Test | ||
| 29 | void testBaseTimeIsReasonable() { | ||
| 30 | assertTrue(Charts.BASE_TIME > 1_700_000_000L); | ||
| 31 | } | ||
| 32 | } | ||
diff --git a/ui-desktop/src/test/java/market/guess/ui/desktop/FormatTest.java b/ui-desktop/src/test/java/market/guess/ui/desktop/FormatTest.java new file mode 100644 index 0000000..956a322 --- /dev/null +++ b/ui-desktop/src/test/java/market/guess/ui/desktop/FormatTest.java | |||
| @@ -0,0 +1,29 @@ | |||
| 1 | package market.guess.ui.desktop; | ||
| 2 | |||
| 3 | import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| 4 | |||
| 5 | import java.math.BigDecimal; | ||
| 6 | import market.guess.ui.desktop.util.Format; | ||
| 7 | import org.junit.jupiter.api.Test; | ||
| 8 | |||
| 9 | class FormatTest { | ||
| 10 | |||
| 11 | @Test | ||
| 12 | void testMoneyFormatting() { | ||
| 13 | assertEquals("$0.00", Format.money((BigDecimal) null)); | ||
| 14 | assertEquals("$0.00", Format.money(BigDecimal.ZERO)); | ||
| 15 | assertEquals("$0.50", Format.money(new BigDecimal("0.50"))); | ||
| 16 | assertEquals("$123.45", Format.money(new BigDecimal("123.45"))); | ||
| 17 | assertEquals("-$5.00", Format.money(new BigDecimal("-5.00"))); | ||
| 18 | assertEquals("$100.00", Format.money(100.0)); | ||
| 19 | assertEquals("-$2.50", Format.money(-2.50)); | ||
| 20 | } | ||
| 21 | |||
| 22 | @Test | ||
| 23 | void testProbFormatting() { | ||
| 24 | assertEquals("0.50", Format.prob(0.50)); | ||
| 25 | assertEquals("0.75", Format.prob(0.751)); | ||
| 26 | assertEquals("0.00", Format.prob(0.00)); | ||
| 27 | assertEquals("1.00", Format.prob(1.00)); | ||
| 28 | } | ||
| 29 | } | ||