aboutsummaryrefslogtreecommitdiffstats
path: root/service/src/main/java/market/guess
diff options
context:
space:
mode:
authorKostya <mail@sartin.in>2026-09-06 15:42:59 +0300
committerKostya <mail@sartin.in>2026-09-06 15:42:59 +0300
commit1cf436ccd3efd45d67749102363b9a7f0d242c54 (patch)
tree9332676f5a5353172a0c54384f960dd66c105aed /service/src/main/java/market/guess
parent5bc7c4ed9d052668af66d3116a6b7afbe62c6eec (diff)
downloadguess-market-1cf436ccd3efd45d67749102363b9a7f0d242c54.tar.gz
guess-market-1cf436ccd3efd45d67749102363b9a7f0d242c54.tar.xz
guess-market-1cf436ccd3efd45d67749102363b9a7f0d242c54.zip
service: add ex-2 XML loader and carry order side through matching
Ex-2 documents declare users and their market-maker assignments next to the events, and the loader has to reject files that are schema-valid but logically inconsistent, which the v1 loader has no notion of. Add XMLLoaderV2, which checks the extension, unmarshals the document, runs it through XMLValidatorV2 and only then replaces the event and user repositories, so a rejected file leaves the previous catalog intact. The validator enforces unique positive event ids, non-empty names and descriptions, commission within 0..90 with a known type, at least two distinct options, a positive LMSR b or order-book d with non-negative initial inventory, unique user names, positive initial cash, and exactly one existing event per market-maker reference. Let EventMapperV2 take the resolved market maker's name so it lands on the domain event. Give Order a side and the placing user, and Trade the seller, so an order book can match a buyer against a seller. Keep the previous constructors, defaulting to a BUY with no user or seller, so existing LMSR call sites stay unchanged. Route LocalMatchingEngine through the mechanism's placeOrder with side, user and limit price instead of buy(), and have cancelOrder take the order to cancel. Also add an isBlocked query to User and Ledger for accounts that went negative.
Diffstat (limited to 'service/src/main/java/market/guess')
-rw-r--r--service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v2/EventMapperV2.java6
-rw-r--r--service/src/main/java/market/guess/service/catalog/infrastructure/provider/v2/XMLLoaderV2.java119
-rw-r--r--service/src/main/java/market/guess/service/catalog/infrastructure/provider/v2/XMLValidatorV2.java219
-rw-r--r--service/src/main/java/market/guess/service/domain/Order.java14
-rw-r--r--service/src/main/java/market/guess/service/domain/Trade.java15
-rw-r--r--service/src/main/java/market/guess/service/domain/User.java4
-rw-r--r--service/src/main/java/market/guess/service/ledger/Ledger.java5
-rw-r--r--service/src/main/java/market/guess/service/matching/LocalMatchingEngine.java15
-rw-r--r--service/src/main/java/market/guess/service/matching/MatchingEngine.java2
9 files changed, 391 insertions, 8 deletions
diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v2/EventMapperV2.java b/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v2/EventMapperV2.java
index bcdce94..dc13d16 100644
--- a/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v2/EventMapperV2.java
+++ b/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v2/EventMapperV2.java
@@ -18,6 +18,10 @@ public final class EventMapperV2 implements Mapper<GMEvent, Event> {
18 18
19 @Override 19 @Override
20 public Event toDomain(GMEvent source) { 20 public Event toDomain(GMEvent source) {
21 return toDomain(source, "");
22 }
23
24 public Event toDomain(GMEvent source, String marketMaker) {
21 var options = getOptions(source); 25 var options = getOptions(source);
22 26
23 return new Event( 27 return new Event(
@@ -30,7 +34,7 @@ public final class EventMapperV2 implements Mapper<GMEvent, Event> {
30 getMechanism(source, options.size()), 34 getMechanism(source, options.size()),
31 EventStatus.DRAFT, 35 EventStatus.DRAFT,
32 options, 36 options,
33 ""); 37 marketMaker != null ? marketMaker : "");
34 } 38 }
35 39
36 private static String getEventKey(GMEvent event) { 40 private static String getEventKey(GMEvent event) {
diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v2/XMLLoaderV2.java b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v2/XMLLoaderV2.java
new file mode 100644
index 0000000..7ca141f
--- /dev/null
+++ b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v2/XMLLoaderV2.java
@@ -0,0 +1,119 @@
1package market.guess.service.catalog.infrastructure.provider.v2;
2
3import jakarta.xml.bind.JAXBContext;
4import jakarta.xml.bind.JAXBException;
5import java.math.BigDecimal;
6import java.nio.file.Files;
7import java.nio.file.Path;
8import java.util.ArrayList;
9import java.util.HashMap;
10import java.util.Map;
11import market.guess.api.CatalogContext;
12import market.guess.api.LoadResult;
13import market.guess.api.Result;
14import market.guess.service.catalog.infrastructure.mapper.v2.EventMapperV2;
15import market.guess.service.catalog.infrastructure.provider.Loader;
16import market.guess.service.catalog.infrastructure.repository.EventRepository;
17import market.guess.service.catalog.infrastructure.repository.UserRepository;
18import market.guess.service.catalog.model.v2.Event;
19import market.guess.service.catalog.model.v2.GMEvent;
20import market.guess.service.catalog.model.v2.GMUser;
21import market.guess.service.catalog.model.v2.GuessMarket;
22import market.guess.service.domain.User;
23
24public final class XMLLoaderV2 implements Loader {
25 private final JAXBContext contextV2;
26 private final EventRepository eventRepository;
27 private final UserRepository userRepository;
28 private final EventMapperV2 mapperV2;
29 private final XMLValidatorV2 validatorV2;
30
31 public XMLLoaderV2(
32 EventRepository eventRepository,
33 UserRepository userRepository,
34 EventMapperV2 mapperV2,
35 XMLValidatorV2 validatorV2) {
36 this.eventRepository = eventRepository;
37 this.userRepository = userRepository;
38 this.mapperV2 = mapperV2;
39 this.validatorV2 = validatorV2;
40 try {
41 this.contextV2 = JAXBContext.newInstance(GuessMarket.class);
42 } catch (JAXBException e) {
43 throw new IllegalStateException("Failed to initialize JAXB context: " + e.getMessage(), e);
44 }
45 }
46
47 @Override
48 public Result<LoadResult> load(Path path) {
49 if (path == null) {
50 return Result.error("INVALID_PATH", "File path cannot be null.");
51 }
52
53 if (!Files.isRegularFile(path)) {
54 return Result.error("FILE_NOT_FOUND", "No such file: " + path);
55 }
56
57 if (path.getFileName() == null
58 || !path.getFileName().toString().toLowerCase().endsWith(".xml")) {
59 return Result.error("NOT_XML", "The file must have a .xml extension.");
60 }
61
62 try (var stream = Files.newInputStream(path)) {
63 var seed = (GuessMarket) contextV2.createUnmarshaller().unmarshal(stream);
64
65 var validation = validatorV2.validate(seed);
66 if (!validation.isSuccess()) {
67 return Result.error(validation.getMessage(), validation.getDetails());
68 }
69
70 // Map market makers
71 Map<Integer, String> mmByEventId = new HashMap<>();
72 for (GMUser u : seed.getGMUsers().getGMUser()) {
73 if (u.getGMMarketMaker() != null && u.getGMMarketMaker().getEvent() != null) {
74 for (Event mmEvent : u.getGMMarketMaker().getEvent()) {
75 mmByEventId.put(mmEvent.getId(), u.getName());
76 }
77 }
78 }
79
80 var domainEvents = new ArrayList<market.guess.service.domain.Event>();
81 for (GMEvent e : seed.getGMEvents().getGMEvent()) {
82 String mm = mmByEventId.getOrDefault(e.getId(), "");
83 domainEvents.add(mapperV2.toDomain(e, mm));
84 }
85
86 var domainUsers = new ArrayList<User>();
87 for (GMUser u : seed.getGMUsers().getGMUser()) {
88 domainUsers.add(new User(u.getName(), new BigDecimal(u.getInitialCash())));
89 }
90
91 // Ingestion succeeds atomically: update repositories
92 eventRepository.clear();
93 userRepository.clear();
94
95 for (var u : domainUsers) {
96 userRepository.add(u);
97 }
98
99 for (var e : domainEvents) {
100 eventRepository.add(e);
101 }
102
103 return Result.ok(new LoadResult(path.toString(), eventRepository.getAll().size()));
104 } catch (JAXBException e) {
105 var cause =
106 e.getLinkedException() != null ? e.getLinkedException().getMessage() : e.getMessage();
107 return Result.error(
108 "XML_PARSE_ERROR",
109 "The XML file is malformed or invalid: " + (cause != null ? cause : e.getMessage()));
110 } catch (Exception e) {
111 return Result.error("UNREADABLE", "The file could not be read: " + e.getMessage());
112 }
113 }
114
115 @Override
116 public Result<LoadResult> seed(CatalogContext context) {
117 throw new UnsupportedOperationException("Unimplemented method 'seed'");
118 }
119}
diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v2/XMLValidatorV2.java b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v2/XMLValidatorV2.java
new file mode 100644
index 0000000..0146025
--- /dev/null
+++ b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v2/XMLValidatorV2.java
@@ -0,0 +1,219 @@
1package market.guess.service.catalog.infrastructure.provider.v2;
2
3import java.util.HashMap;
4import java.util.HashSet;
5import market.guess.api.Result;
6import market.guess.service.catalog.model.v2.Event;
7import market.guess.service.catalog.model.v2.GMEvent;
8import market.guess.service.catalog.model.v2.GMLMSR;
9import market.guess.service.catalog.model.v2.GMOrderBook;
10import market.guess.service.catalog.model.v2.GMUser;
11import market.guess.service.catalog.model.v2.GuessMarket;
12
13public final class XMLValidatorV2 {
14 private static final int MIN_COMMISSION = 0;
15 private static final int MAX_COMMISSION = 90;
16
17 public Result<Void> validate(GuessMarket seed) {
18 if (seed == null) {
19 return Result.error("INVALID_XML", "The XML file contains no data.");
20 }
21
22 if (seed.getGMEvents() == null || seed.getGMEvents().getGMEvent() == null) {
23 return Result.error("INVALID_XML", "The XML file contains no GM-events element.");
24 }
25
26 var events = seed.getGMEvents().getGMEvent();
27 if (events.isEmpty()) {
28 return Result.error("EMPTY_EVENTS", "The XML file does not contain any events.");
29 }
30
31 var seenEventIds = new HashSet<Integer>();
32
33 for (GMEvent e : events) {
34 // 1. Event ID must be positive integer (> 0)
35 if (e.getId() <= 0) {
36 return Result.error(
37 "INVALID_ID", "Event ID must be a positive integer (> 0), found: " + e.getId());
38 }
39 if (!seenEventIds.add(e.getId())) {
40 return Result.error(
41 "DUPLICATE_ID",
42 "Event ID " + e.getId() + " appears more than once. Every event ID must be unique.");
43 }
44
45 // 2. Event name must not be empty
46 if (e.getName() == null || e.getName().isBlank()) {
47 return Result.error("MISSING_NAME", "Event ID " + e.getId() + " has an empty name.");
48 }
49
50 // 3. Event description must not be empty
51 if (e.getDescription() == null || e.getDescription().isBlank()) {
52 return Result.error(
53 "MISSING_DESCRIPTION", "Event ID " + e.getId() + " has an empty description.");
54 }
55
56 // 4. Commission validation
57 if (e.getCommission() == null) {
58 return Result.error(
59 "MISSING_COMMISSION", "Event ID " + e.getId() + " is missing commission.");
60 }
61 var pct = e.getCommission().getValue();
62 if (pct < MIN_COMMISSION || pct > MAX_COMMISSION) {
63 return Result.error(
64 "COMMISSION_OUT_OF_RANGE",
65 "Event ID "
66 + e.getId()
67 + " commission is "
68 + pct
69 + "%. It must be between "
70 + MIN_COMMISSION
71 + "% and "
72 + MAX_COMMISSION
73 + "%.");
74 }
75
76 var type = e.getCommission().getType();
77 if (type == null
78 || (!type.equalsIgnoreCase("on-close") && !type.equalsIgnoreCase("on-purchase"))) {
79 return Result.error(
80 "INVALID_COMMISSION_TYPE",
81 "Event ID "
82 + e.getId()
83 + " has invalid commission type '"
84 + type
85 + "'. Must be 'on-close' or 'on-purchase'.");
86 }
87
88 // 5. GM-options validation (at least 2 options)
89 if (e.getGMOptions() == null || e.getGMOptions().getGMOption() == null) {
90 return Result.error("MISSING_OPTIONS", "Event ID " + e.getId() + " is missing GM-options.");
91 }
92 var options = e.getGMOptions().getGMOption();
93 if (options.size() < 2) {
94 return Result.error(
95 "INVALID_OPTIONS_COUNT",
96 "Event ID "
97 + e.getId()
98 + " must have at least 2 options, but found "
99 + options.size()
100 + ".");
101 }
102 var seenOptions = new HashSet<String>();
103 for (String opt : options) {
104 if (opt == null || opt.trim().isEmpty()) {
105 return Result.error(
106 "EMPTY_OPTION", "Event ID " + e.getId() + " has an empty option name.");
107 }
108 if (!seenOptions.add(opt.trim().toLowerCase())) {
109 return Result.error(
110 "DUPLICATE_OPTION",
111 "Event ID " + e.getId() + " has duplicate option names: '" + opt.trim() + "'.");
112 }
113 }
114
115 // 6. GM-method validation (LMSR or Order Book)
116 if (e.getGMMethod() == null) {
117 return Result.error("MISSING_METHOD", "Event ID " + e.getId() + " is missing GM-method.");
118 }
119 if (e.getGMMethod().getGMLMSR() instanceof GMLMSR lmsr) {
120 if (lmsr.getB() <= 0) {
121 return Result.error(
122 "INVALID_LIQUIDITY",
123 "Event ID "
124 + e.getId()
125 + " has invalid liquidity b="
126 + lmsr.getB()
127 + ". b must be a positive integer (> 0).");
128 }
129 } else if (e.getGMMethod().getGMOrderBook() instanceof GMOrderBook ob) {
130 if (ob.getD() <= 0) {
131 return Result.error(
132 "INVALID_DENOMINATOR",
133 "Event ID "
134 + e.getId()
135 + " has invalid denominator d="
136 + ob.getD()
137 + ". d must be a positive integer (> 0).");
138 }
139 if (ob.getInitial() < 0) {
140 return Result.error(
141 "INVALID_INITIAL",
142 "Event ID "
143 + e.getId()
144 + " has invalid initial inventory: "
145 + ob.getInitial()
146 + ". Must be >= 0.");
147 }
148 } else {
149 return Result.error(
150 "INVALID_METHOD",
151 "Event ID " + e.getId() + " has unknown or missing trading method.");
152 }
153 }
154
155 // 7. Users validation
156 if (seed.getGMUsers() == null || seed.getGMUsers().getGMUser() == null) {
157 return Result.error("MISSING_USERS", "The XML file contains no GM-users element.");
158 }
159 var users = seed.getGMUsers().getGMUser();
160 if (users.isEmpty()) {
161 return Result.error("EMPTY_USERS", "The XML file does not contain any users.");
162 }
163
164 var seenUserNames = new HashSet<String>();
165 var mmByEventId = new HashMap<Integer, String>();
166 for (GMUser u : users) {
167 if (u.getName() == null || u.getName().isBlank()) {
168 return Result.error("MISSING_USER_NAME", "A user has an empty name.");
169 }
170 if (!seenUserNames.add(u.getName().trim().toLowerCase())) {
171 return Result.error(
172 "DUPLICATE_USER", "Duplicate user name found: '" + u.getName().trim() + "'.");
173 }
174 if (u.getInitialCash() <= 0) {
175 return Result.error(
176 "INVALID_INITIAL_CASH",
177 "User '"
178 + u.getName()
179 + "' initial cash must be greater than 0, found: "
180 + u.getInitialCash());
181 }
182 if (u.getGMMarketMaker() != null && u.getGMMarketMaker().getEvent() != null) {
183 for (Event mmEvent : u.getGMMarketMaker().getEvent()) {
184 if (!seenEventIds.contains(mmEvent.getId())) {
185 return Result.error(
186 "UNKNOWN_MM_EVENT",
187 "Market maker '"
188 + u.getName()
189 + "' references non-existent event ID "
190 + mmEvent.getId());
191 }
192 var previous = mmByEventId.putIfAbsent(mmEvent.getId(), u.getName());
193 if (previous != null) {
194 return Result.error(
195 "DUPLICATE_MM",
196 "Event ID "
197 + mmEvent.getId()
198 + " has more than one market maker: '"
199 + previous
200 + "' and '"
201 + u.getName()
202 + "'. Every event needs exactly one.");
203 }
204 }
205 }
206 }
207
208 // 8. Every event has exactly one market maker (duplicates were rejected above)
209 for (GMEvent e : events) {
210 if (!mmByEventId.containsKey(e.getId())) {
211 return Result.error(
212 "MISSING_MM",
213 "Event ID " + e.getId() + " has no market maker. Every event needs exactly one.");
214 }
215 }
216
217 return Result.ok();
218 }
219}
diff --git a/service/src/main/java/market/guess/service/domain/Order.java b/service/src/main/java/market/guess/service/domain/Order.java
index 9d54020..89f9fcc 100644
--- a/service/src/main/java/market/guess/service/domain/Order.java
+++ b/service/src/main/java/market/guess/service/domain/Order.java
@@ -3,4 +3,16 @@ package market.guess.service.domain;
3import java.math.BigDecimal; 3import java.math.BigDecimal;
4import java.time.Instant; 4import java.time.Instant;
5 5
6public record Order(Instant at, Event event, Market market, BigDecimal price, long quantity) {} 6public record Order(
7 Instant at,
8 Event event,
9 Market market,
10 String side,
11 BigDecimal price,
12 long quantity,
13 String userName) {
14
15 public Order(Instant at, Event event, Market market, BigDecimal price, long quantity) {
16 this(at, event, market, "BUY", price, quantity, null);
17 }
18}
diff --git a/service/src/main/java/market/guess/service/domain/Trade.java b/service/src/main/java/market/guess/service/domain/Trade.java
index 271582b..ac53b01 100644
--- a/service/src/main/java/market/guess/service/domain/Trade.java
+++ b/service/src/main/java/market/guess/service/domain/Trade.java
@@ -11,7 +11,20 @@ public record Trade(
11 String marketName, 11 String marketName,
12 long quantity, 12 long quantity,
13 BigDecimal sharesCost, 13 BigDecimal sharesCost,
14 BigDecimal commission) { 14 BigDecimal commission,
15 String sellerUserName) {
16
17 public Trade(
18 int id,
19 Instant at,
20 String buyerUserName,
21 String marketKey,
22 String marketName,
23 long quantity,
24 BigDecimal sharesCost,
25 BigDecimal commission) {
26 this(id, at, buyerUserName, marketKey, marketName, quantity, sharesCost, commission, null);
27 }
15 28
16 public BigDecimal totalPaid() { 29 public BigDecimal totalPaid() {
17 return sharesCost.add(commission); 30 return sharesCost.add(commission);
diff --git a/service/src/main/java/market/guess/service/domain/User.java b/service/src/main/java/market/guess/service/domain/User.java
index 86ba5af..18f751e 100644
--- a/service/src/main/java/market/guess/service/domain/User.java
+++ b/service/src/main/java/market/guess/service/domain/User.java
@@ -30,4 +30,8 @@ public final class User {
30 public Ledger getLedger() { 30 public Ledger getLedger() {
31 return account; 31 return account;
32 } 32 }
33
34 public boolean isBlocked() {
35 return account.getBalance().signum() < 0;
36 }
33} 37}
diff --git a/service/src/main/java/market/guess/service/ledger/Ledger.java b/service/src/main/java/market/guess/service/ledger/Ledger.java
index 72bf387..0a6f66f 100644
--- a/service/src/main/java/market/guess/service/ledger/Ledger.java
+++ b/service/src/main/java/market/guess/service/ledger/Ledger.java
@@ -9,6 +9,7 @@ import market.guess.service.helpers.BigDecimalOptions;
9public final class Ledger { 9public final class Ledger {
10 private final String owner; 10 private final String owner;
11 private BigDecimal balance; 11 private BigDecimal balance;
12 private boolean blocked;
12 private final List<LedgerEntry> entries = new ArrayList<>(); 13 private final List<LedgerEntry> entries = new ArrayList<>();
13 14
14 public Ledger(String owner, BigDecimal initialBalance) { 15 public Ledger(String owner, BigDecimal initialBalance) {
@@ -25,6 +26,10 @@ public final class Ledger {
25 return balance; 26 return balance;
26 } 27 }
27 28
29 public boolean isBlocked() {
30 return blocked;
31 }
32
28 public List<LedgerEntry> getEntries() { 33 public List<LedgerEntry> getEntries() {
29 return Collections.unmodifiableList(entries); 34 return Collections.unmodifiableList(entries);
30 } 35 }
diff --git a/service/src/main/java/market/guess/service/matching/LocalMatchingEngine.java b/service/src/main/java/market/guess/service/matching/LocalMatchingEngine.java
index 22e5332..9241265 100644
--- a/service/src/main/java/market/guess/service/matching/LocalMatchingEngine.java
+++ b/service/src/main/java/market/guess/service/matching/LocalMatchingEngine.java
@@ -16,12 +16,19 @@ public final class LocalMatchingEngine implements MatchingEngine {
16 @Override 16 @Override
17 public List<Trade> match(Order order) { 17 public List<Trade> match(Order order) {
18 var mechanism = order.event().getMechanism(); 18 var mechanism = order.event().getMechanism();
19 return mechanism.buy(order.event(), order.market(), clock.instant(), order.quantity()); 19 return mechanism.placeOrder(
20 order.event(),
21 order.market(),
22 order.userName(),
23 order.side(),
24 clock.instant(),
25 order.quantity(),
26 order.price());
20 } 27 }
21 28
22 @Override 29 @Override
23 public boolean cancelOrder() { 30 public boolean cancelOrder(Order order) {
24 // TODO Auto-generated method stub 31 var mechanism = order.event().getMechanism();
25 throw new UnsupportedOperationException("Unimplemented method 'cancelOrder'"); 32 return mechanism.cancel();
26 } 33 }
27} 34}
diff --git a/service/src/main/java/market/guess/service/matching/MatchingEngine.java b/service/src/main/java/market/guess/service/matching/MatchingEngine.java
index a8fc0c4..5a6e450 100644
--- a/service/src/main/java/market/guess/service/matching/MatchingEngine.java
+++ b/service/src/main/java/market/guess/service/matching/MatchingEngine.java
@@ -7,5 +7,5 @@ import market.guess.service.domain.Trade;
7public interface MatchingEngine { 7public interface MatchingEngine {
8 List<Trade> match(Order order); 8 List<Trade> match(Order order);
9 9
10 boolean cancelOrder(); 10 boolean cancelOrder(Order order);
11} 11}