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