summaryrefslogtreecommitdiffstats
path: root/service/src/test/java/market
diff options
context:
space:
mode:
Diffstat (limited to 'service/src/test/java/market')
-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}