diff options
Diffstat (limited to 'service/src')
4 files changed, 800 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 | } |