aboutsummaryrefslogtreecommitdiffstats
path: root/service/src/test/java/market/guess
diff options
context:
space:
mode:
authorKostya <mail@sartin.in>2026-09-10 11:55:50 +0300
committerKostya <mail@sartin.in>2026-09-10 11:55:50 +0300
commit1a8b75f96f0c6855581be3a38069f09eca37d1f5 (patch)
treede174d70987afa21fd0588c551c111e028ff020f /service/src/test/java/market/guess
parentf23c20c1c66b39fb1f49307e08e8593fa708e026 (diff)
downloadguess-market-master.tar.gz
guess-market-master.tar.xz
guess-market-master.zip
tests: cover the ex-2 loader, order book trading and desktop stateHEAD2.0master
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.
Diffstat (limited to 'service/src/test/java/market/guess')
-rw-r--r--service/src/test/java/market/guess/service/Ex2LoaderTest.java135
-rw-r--r--service/src/test/java/market/guess/service/OrderBookSimulationTest.java235
-rw-r--r--service/src/test/java/market/guess/service/OrderBookTradingTest.java416
-rw-r--r--service/src/test/java/market/guess/service/catalog/infrastructure/adapter/LedgerMapperTest.java25
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 @@
1package market.guess.service;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4import static org.junit.jupiter.api.Assertions.assertFalse;
5import static org.junit.jupiter.api.Assertions.assertNotNull;
6import static org.junit.jupiter.api.Assertions.assertTrue;
7
8import java.nio.file.Files;
9import java.nio.file.Path;
10import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2;
11import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2;
12import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2;
13import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository;
14import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository;
15import org.junit.jupiter.api.BeforeEach;
16import org.junit.jupiter.api.Test;
17import org.junit.jupiter.api.io.TempDir;
18
19class 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 @@
1package market.guess.service;
2
3import static org.junit.jupiter.api.Assertions.*;
4
5import java.math.BigDecimal;
6import java.math.RoundingMode;
7import java.util.List;
8import market.guess.model.event.CommissionTiming;
9import market.guess.model.event.CreateEventRequest;
10import market.guess.model.event.MechanismType;
11import market.guess.model.market.OrderBookDTO;
12import market.guess.model.market.PurchaseReceiptDTO;
13import market.guess.service.catalog.LocalCatalogContext;
14import market.guess.service.catalog.infrastructure.MarketContext;
15import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2;
16import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2;
17import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2;
18import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository;
19import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository;
20import market.guess.service.domain.Event;
21import market.guess.service.domain.User;
22import market.guess.service.fulfillment.LocalFulfillmentContext;
23import market.guess.service.ledger.LedgerContext;
24import market.guess.service.matching.LocalMatchingEngine;
25import market.guess.service.mechanism.OrderBookTradingMechanism;
26import market.guess.service.mechanism.OrderBookTradingMechanism.RestingOrder;
27import market.guess.service.risk.LocalRiskEngine;
28import market.guess.service.settlement.LocalSettlementContext;
29import org.junit.jupiter.api.BeforeEach;
30import org.junit.jupiter.params.ParameterizedTest;
31import 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 */
37public 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 @@
1package market.guess.service;
2
3import static org.junit.jupiter.api.Assertions.*;
4
5import java.math.BigDecimal;
6import java.nio.file.Path;
7import market.guess.service.catalog.LocalCatalogContext;
8import market.guess.service.catalog.infrastructure.MarketContext;
9import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2;
10import market.guess.service.catalog.infrastructure.provider.v2.XMLLoaderV2;
11import market.guess.service.catalog.infrastructure.provider.v2.XMLValidatorV2;
12import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository;
13import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository;
14import market.guess.service.fulfillment.LocalFulfillmentContext;
15import market.guess.service.ledger.LedgerContext;
16import market.guess.service.matching.LocalMatchingEngine;
17import market.guess.service.mechanism.OrderBookTradingMechanism;
18import market.guess.service.risk.LocalRiskEngine;
19import market.guess.service.settlement.LocalSettlementContext;
20import org.junit.jupiter.api.BeforeEach;
21import org.junit.jupiter.api.Test;
22
23public 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;
11import market.guess.model.ledger.LedgerDTO; 11import market.guess.model.ledger.LedgerDTO;
12import market.guess.model.ledger.LedgerEntryDTO; 12import market.guess.model.ledger.LedgerEntryDTO;
13import market.guess.model.ledger.LedgerType; 13import market.guess.model.ledger.LedgerType;
14import market.guess.service.helpers.InstantOptions;
14import market.guess.service.ledger.Ledger; 15import market.guess.service.ledger.Ledger;
15import market.guess.service.ledger.LedgerEntry; 16import market.guess.service.ledger.LedgerEntry;
16import org.junit.Test; 17import 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}