summaryrefslogtreecommitdiffstats
path: root/service/src/main/java/market
diff options
context:
space:
mode:
Diffstat (limited to 'service/src/main/java/market')
-rw-r--r--service/src/main/java/market/guess/service/LocalGuessMarketContext.java61
-rw-r--r--service/src/main/java/market/guess/service/catalog/LocalCatalogContext.java117
-rw-r--r--service/src/main/java/market/guess/service/domain/Event.java183
-rw-r--r--service/src/main/java/market/guess/service/fulfillment/LocalFulfillmentContext.java79
-rw-r--r--service/src/main/java/market/guess/service/ledger/LocalAccountContext.java51
-rw-r--r--service/src/main/java/market/guess/service/mechanism/LmsrTradingMechanism.java43
-rw-r--r--service/src/main/java/market/guess/service/mechanism/OrderBookTradingMechanism.java275
-rw-r--r--service/src/main/java/market/guess/service/mechanism/TradingMechanism.java14
-rw-r--r--service/src/main/java/market/guess/service/risk/LocalRiskEngine.java76
-rw-r--r--service/src/main/java/market/guess/service/settlement/LocalSettlementContext.java58
10 files changed, 853 insertions, 104 deletions
diff --git a/service/src/main/java/market/guess/service/LocalGuessMarketContext.java b/service/src/main/java/market/guess/service/LocalGuessMarketContext.java
index c85dadf..b76d36f 100644
--- a/service/src/main/java/market/guess/service/LocalGuessMarketContext.java
+++ b/service/src/main/java/market/guess/service/LocalGuessMarketContext.java
@@ -9,6 +9,7 @@ import market.guess.model.market.PurchaseReceiptDTO;
9import market.guess.model.market.TradeRowDTO; 9import market.guess.model.market.TradeRowDTO;
10import market.guess.service.catalog.infrastructure.MarketContext; 10import market.guess.service.catalog.infrastructure.MarketContext;
11import market.guess.service.domain.Order; 11import market.guess.service.domain.Order;
12import market.guess.service.domain.Trade;
12import market.guess.service.fulfillment.FulfillmentContext; 13import market.guess.service.fulfillment.FulfillmentContext;
13import market.guess.service.helpers.BigDecimalOptions; 14import market.guess.service.helpers.BigDecimalOptions;
14import market.guess.service.helpers.InstantOptions; 15import market.guess.service.helpers.InstantOptions;
@@ -39,17 +40,26 @@ public final class LocalGuessMarketContext implements GuessMarketContext {
39 40
40 @Override 41 @Override
41 public Result<PurchaseReceiptDTO> placeOrder( 42 public Result<PurchaseReceiptDTO> placeOrder(
42 String userName, String eventKey, String optionKey, BigDecimal price, long quantity) { 43 String userName,
44 String eventKey,
45 String optionKey,
46 String side,
47 BigDecimal price,
48 long quantity) {
43 var optional = context.getEvents().get(eventKey); 49 var optional = context.getEvents().get(eventKey);
44
45 var userOptional = context.getUsers().get(userName); 50 var userOptional = context.getUsers().get(userName);
46 if (optional.isEmpty() || userOptional.isEmpty()) 51 if (optional.isEmpty() || userOptional.isEmpty()) {
47 return Result.error("Unable to find user/event."); 52 return Result.error("Unable to find user/event.");
53 }
48 54
49 var event = optional.get(); 55 var event = optional.get();
50 var user = userOptional.get(); 56 var user = userOptional.get();
51 var option = event.getOption(optionKey); 57 var option = event.getOption(optionKey);
52 var order = new Order(Instant.now(), event, option, price, quantity); 58 if (option == null) {
59 return Result.error("Unknown option: " + optionKey);
60 }
61 String normalizedSide = ("SELL".equalsIgnoreCase(side)) ? "SELL" : "BUY";
62 var order = new Order(Instant.now(), event, option, normalizedSide, price, quantity, userName);
53 63
54 var riskResult = risk.check(user, order); 64 var riskResult = risk.check(user, order);
55 if (!riskResult.isSuccess()) return Result.error(riskResult.getMessage()); 65 if (!riskResult.isSuccess()) return Result.error(riskResult.getMessage());
@@ -59,28 +69,38 @@ public final class LocalGuessMarketContext implements GuessMarketContext {
59 69
60 var totalSharesCost = 70 var totalSharesCost =
61 trades.stream() 71 trades.stream()
62 .map(t -> t.sharesCost()) 72 .map(Trade::sharesCost)
63 .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); 73 .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add);
64 var totalCommission = 74 var totalCommission =
65 trades.stream() 75 trades.stream()
66 .map(t -> t.commission()) 76 .map(Trade::commission)
67 .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); 77 .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add);
68 var totalPaid = 78 var totalPaid =
69 trades.stream() 79 trades.stream()
70 .map(t -> t.totalPaid()) 80 .map(Trade::totalPaid)
71 .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); 81 .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add);
72 82
73 return Result.ok( 83 return Result.ok(
74 new PurchaseReceiptDTO( 84 new PurchaseReceiptDTO(
75 trades.stream() 85 trades.stream()
76 .map( 86 .map(
77 t -> 87 t -> {
78 new TradeRowDTO( 88 String traderUser = t.buyerUserName();
79 InstantOptions.humanize(t.at()), 89 if ((traderUser == null
80 t.buyerUserName(), 90 || traderUser.isBlank()
81 t.marketName(), 91 || (event.getMarketMaker() != null
82 String.valueOf(t.quantity()), 92 && event.getMarketMaker().equalsIgnoreCase(traderUser)))
83 t.sharesCost().toPlainString())) 93 && t.sellerUserName() != null
94 && !t.sellerUserName().isBlank()) {
95 traderUser = t.sellerUserName();
96 }
97 return new TradeRowDTO(
98 InstantOptions.humanize(t.at()),
99 traderUser,
100 t.marketName(),
101 String.valueOf(t.quantity()),
102 t.sharesCost().toPlainString());
103 })
84 .toList(), 104 .toList(),
85 totalSharesCost.toPlainString(), 105 totalSharesCost.toPlainString(),
86 totalCommission.toPlainString(), 106 totalCommission.toPlainString(),
@@ -89,6 +109,12 @@ public final class LocalGuessMarketContext implements GuessMarketContext {
89 } 109 }
90 110
91 @Override 111 @Override
112 public Result<PurchaseReceiptDTO> placeOrder(
113 String userName, String eventKey, String optionKey, BigDecimal price, long quantity) {
114 return placeOrder(userName, eventKey, optionKey, "BUY", price, quantity);
115 }
116
117 @Override
92 public Result<EventDetailDTO> settleEvent( 118 public Result<EventDetailDTO> settleEvent(
93 String userName, String eventKey, String winningOptionKey) { 119 String userName, String eventKey, String winningOptionKey) {
94 var optional = context.getEvents().get(eventKey); 120 var optional = context.getEvents().get(eventKey);
@@ -99,12 +125,7 @@ public final class LocalGuessMarketContext implements GuessMarketContext {
99 if (event.canSettle(userName)) { 125 if (event.canSettle(userName)) {
100 settlement.settle(event, winningOptionKey); 126 settlement.settle(event, winningOptionKey);
101 127
102 return Result.ok( 128 return Result.ok(event.toEventDetail());
103 new EventDetailDTO(
104 event.toEventSummary(),
105 event.toMarketState(),
106 event.getHistory(),
107 event.getWinningOptionName()));
108 } 129 }
109 130
110 return Result.error("Couldn't settle event."); 131 return Result.error("Couldn't settle event.");
diff --git a/service/src/main/java/market/guess/service/catalog/LocalCatalogContext.java b/service/src/main/java/market/guess/service/catalog/LocalCatalogContext.java
index ea26107..d767fd1 100644
--- a/service/src/main/java/market/guess/service/catalog/LocalCatalogContext.java
+++ b/service/src/main/java/market/guess/service/catalog/LocalCatalogContext.java
@@ -1,16 +1,31 @@
1package market.guess.service.catalog; 1package market.guess.service.catalog;
2 2
3import java.math.BigDecimal;
3import java.nio.file.Path; 4import java.nio.file.Path;
5import java.time.Instant;
6import java.util.ArrayList;
4import java.util.List; 7import java.util.List;
8import java.util.Optional;
5import market.guess.api.CatalogContext; 9import market.guess.api.CatalogContext;
6import market.guess.api.LoadResult; 10import market.guess.api.LoadResult;
7import market.guess.api.Result; 11import market.guess.api.Result;
12import market.guess.model.event.CommissionTiming;
13import market.guess.model.event.CreateEventRequest;
8import market.guess.model.event.EventDetailDTO; 14import market.guess.model.event.EventDetailDTO;
15import market.guess.model.event.EventStatus;
9import market.guess.model.event.EventSummaryDTO; 16import market.guess.model.event.EventSummaryDTO;
17import market.guess.model.event.MechanismType;
18import market.guess.model.ledger.LedgerType;
10import market.guess.service.catalog.infrastructure.MarketContext; 19import market.guess.service.catalog.infrastructure.MarketContext;
11import market.guess.service.catalog.infrastructure.provider.Loader; 20import market.guess.service.catalog.infrastructure.provider.Loader;
12import market.guess.service.catalog.infrastructure.repository.EventRepository; 21import market.guess.service.catalog.infrastructure.repository.EventRepository;
13import market.guess.service.domain.Event; 22import market.guess.service.domain.Event;
23import market.guess.service.domain.Market;
24import market.guess.service.domain.User;
25import market.guess.service.ledger.LedgerEntry;
26import market.guess.service.mechanism.LmsrTradingMechanism;
27import market.guess.service.mechanism.OrderBookTradingMechanism;
28import market.guess.service.mechanism.TradingMechanism;
14 29
15public final class LocalCatalogContext implements CatalogContext { 30public final class LocalCatalogContext implements CatalogContext {
16 private final MarketContext context; 31 private final MarketContext context;
@@ -41,12 +56,7 @@ public final class LocalCatalogContext implements CatalogContext {
41 if (optional.isEmpty()) return Result.error("Unable to find event."); 56 if (optional.isEmpty()) return Result.error("Unable to find event.");
42 var event = optional.get(); 57 var event = optional.get();
43 58
44 return Result.ok( 59 return Result.ok(event.toEventDetail());
45 new EventDetailDTO(
46 event.toEventSummary(),
47 event.toMarketState(),
48 event.getHistory(),
49 event.getWinningOptionName()));
50 } 60 }
51 61
52 @Override 62 @Override
@@ -68,4 +78,99 @@ public final class LocalCatalogContext implements CatalogContext {
68 } 78 }
69 return Result.ok(new LoadResult(path.toString(), context.getEvents().getAll().size())); 79 return Result.ok(new LoadResult(path.toString(), context.getEvents().getAll().size()));
70 } 80 }
81
82 @Override
83 public Result<EventDetailDTO> openEvent(String eventKey) {
84 var optional = events.get(eventKey);
85 if (optional.isEmpty()) {
86 return Result.error("Unable to find event.");
87 }
88 var event = optional.get();
89 if (event.getState() != EventStatus.DRAFT) {
90 return Result.error("Event is not in DRAFT status.");
91 }
92 var mmName = event.getMarketMaker();
93 var mmOpt =
94 (mmName != null && !mmName.isEmpty())
95 ? context.getUsers().get(mmName)
96 : Optional.<User>empty();
97
98 if (mmOpt.isPresent()) {
99 var mm = mmOpt.get();
100 BigDecimal required = BigDecimal.ZERO;
101 if (event.getMechanism() instanceof LmsrTradingMechanism lmsr) {
102 required = lmsr.maxLoss();
103 } else if (event.getMechanism() instanceof OrderBookTradingMechanism ob) {
104 required = BigDecimal.valueOf((long) ob.getInitialCollateral() * ob.getD());
105 }
106 if (mm.getLedger().getBalance().compareTo(required) < 0) {
107 return Result.error("Market maker has insufficient funds to open event.");
108 }
109 if (required.signum() > 0) {
110 mm.getLedger()
111 .record(
112 new LedgerEntry(
113 0,
114 Instant.now(),
115 LedgerType.SUBSIDY,
116 required.negate(),
117 mm.getLedger().getBalance().subtract(required),
118 "Market maker subsidy for event #" + event.getDisplayId()));
119 event
120 .getLedger()
121 .record(
122 new LedgerEntry(
123 0,
124 Instant.now(),
125 LedgerType.SUBSIDY,
126 required,
127 event.getLedger().getBalance().add(required),
128 "Market maker subsidy (max loss)"));
129 event.addCashFlow(mmName, required.negate());
130 }
131 }
132 event.setState(EventStatus.ACTIVE);
133 return getEvent(eventKey);
134 }
135
136 @Override
137 public Result<EventDetailDTO> createEvent(CreateEventRequest request) {
138 if (request == null || request.name() == null || request.name().isBlank()) {
139 return Result.error("Event name cannot be empty.");
140 }
141 int newId = events.getAll().stream().mapToInt(Event::getDisplayId).max().orElse(0) + 1;
142 String key = String.valueOf(newId);
143 var optionsList =
144 request.options() != null && !request.options().isEmpty()
145 ? request.options()
146 : List.of("YES", "NO");
147 var markets = new ArrayList<Market>();
148 for (int i = 0; i < optionsList.size(); i++) {
149 markets.add(new Market(key + ":" + i, optionsList.get(i)));
150 }
151 TradingMechanism mechanism;
152 if (request.mechanism() == MechanismType.LMSR) {
153 int b = request.liquidityB() != null ? request.liquidityB() : 100;
154 mechanism = new LmsrTradingMechanism(b, markets.size());
155 } else {
156 int d = request.baseValueD() != null ? request.baseValueD().intValue() : 1;
157 mechanism = new OrderBookTradingMechanism(request.allowMinting(), 100, d, markets.size());
158 }
159 var event =
160 new Event(
161 key,
162 newId,
163 request.name(),
164 request.description() != null ? request.description() : "",
165 request.commissionPercent(),
166 request.commissionTiming() != null
167 ? request.commissionTiming()
168 : CommissionTiming.ON_PURCHASE,
169 mechanism,
170 EventStatus.DRAFT,
171 markets,
172 request.mmUserName() != null ? request.mmUserName() : "");
173 events.add(event);
174 return getEvent(key);
175 }
71} 176}
diff --git a/service/src/main/java/market/guess/service/domain/Event.java b/service/src/main/java/market/guess/service/domain/Event.java
index 4517a4e..6c3dd0c 100644
--- a/service/src/main/java/market/guess/service/domain/Event.java
+++ b/service/src/main/java/market/guess/service/domain/Event.java
@@ -3,18 +3,26 @@ package market.guess.service.domain;
3import java.math.BigDecimal; 3import java.math.BigDecimal;
4import java.time.Instant; 4import java.time.Instant;
5import java.util.ArrayList; 5import java.util.ArrayList;
6import java.util.LinkedHashSet;
6import java.util.List; 7import java.util.List;
8import java.util.Map;
9import java.util.TreeMap;
7import market.guess.model.event.CommissionTiming; 10import market.guess.model.event.CommissionTiming;
11import market.guess.model.event.EventDetailDTO;
8import market.guess.model.event.EventStatus; 12import market.guess.model.event.EventStatus;
9import market.guess.model.event.EventSummaryDTO; 13import market.guess.model.event.EventSummaryDTO;
14import market.guess.model.event.MechanismType;
15import market.guess.model.event.ParticipantDTO;
10import market.guess.model.ledger.LedgerType; 16import market.guess.model.ledger.LedgerType;
11import market.guess.model.market.MarketDTO; 17import market.guess.model.market.MarketDTO;
12import market.guess.model.market.MarketStateDTO; 18import market.guess.model.market.MarketStateDTO;
19import market.guess.model.market.OrderBookDTO;
13import market.guess.model.market.TradeRowDTO; 20import market.guess.model.market.TradeRowDTO;
14import market.guess.service.helpers.BigDecimalOptions; 21import market.guess.service.helpers.BigDecimalOptions;
15import market.guess.service.helpers.InstantOptions; 22import market.guess.service.helpers.InstantOptions;
16import market.guess.service.ledger.Ledger; 23import market.guess.service.ledger.Ledger;
17import market.guess.service.ledger.LedgerEntry; 24import market.guess.service.ledger.LedgerEntry;
25import market.guess.service.mechanism.OrderBookTradingMechanism;
18import market.guess.service.mechanism.TradingMechanism; 26import market.guess.service.mechanism.TradingMechanism;
19 27
20public final class Event { 28public final class Event {
@@ -34,6 +42,7 @@ public final class Event {
34 private EventStatus state; 42 private EventStatus state;
35 private String winningOptionKey; 43 private String winningOptionKey;
36 private BigDecimal settlementCommission = BigDecimalOptions.ZERO_MONEY; 44 private BigDecimal settlementCommission = BigDecimalOptions.ZERO_MONEY;
45 private final Map<String, BigDecimal> cashFlow = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
37 46
38 public Event( 47 public Event(
39 String eventKey, 48 String eventKey,
@@ -57,7 +66,7 @@ public final class Event {
57 this.marketMaker = marketMaker; 66 this.marketMaker = marketMaker;
58 this.ledger = new Ledger(eventKey, BigDecimalOptions.ZERO_MONEY); 67 this.ledger = new Ledger(eventKey, BigDecimalOptions.ZERO_MONEY);
59 var seed = mechanism.maxLoss(); 68 var seed = mechanism.maxLoss();
60 if (seed.signum() > 0) { 69 if (seed.signum() > 0 && status != EventStatus.DRAFT) {
61 ledger.record( 70 ledger.record(
62 new LedgerEntry( 71 new LedgerEntry(
63 0, Instant.now(), LedgerType.SUBSIDY, seed, seed, "Market maker subsidy (max loss)")); 72 0, Instant.now(), LedgerType.SUBSIDY, seed, seed, "Market maker subsidy (max loss)"));
@@ -65,7 +74,6 @@ public final class Event {
65 this.state = status; 74 this.state = status;
66 } 75 }
67 76
68 /** Reconstructs an event with its full accumulated state, e.g. from a saved snapshot. */
69 public Event( 77 public Event(
70 String eventKey, 78 String eventKey,
71 int displayId, 79 int displayId,
@@ -174,9 +182,28 @@ public final class Event {
174 long quantity, 182 long quantity,
175 BigDecimal cost, 183 BigDecimal cost,
176 BigDecimal commission) { 184 BigDecimal commission) {
185 return recordTrade(time, user, option, quantity, cost, commission, null);
186 }
187
188 public Trade recordTrade(
189 Instant time,
190 String user,
191 Market option,
192 long quantity,
193 BigDecimal cost,
194 BigDecimal commission,
195 String seller) {
177 var trade = 196 var trade =
178 new Trade( 197 new Trade(
179 runningId++, time, user, option.getKey(), option.getName(), quantity, cost, commission); 198 runningId++,
199 time,
200 user,
201 option.getKey(),
202 option.getName(),
203 quantity,
204 cost,
205 commission,
206 seller);
180 trades.add(trade); 207 trades.add(trade);
181 208
182 return trade; 209 return trade;
@@ -199,10 +226,18 @@ public final class Event {
199 public List<TradeRowDTO> getHistory() { 226 public List<TradeRowDTO> getHistory() {
200 var history = new ArrayList<TradeRowDTO>(); 227 var history = new ArrayList<TradeRowDTO>();
201 for (var trade : trades.reversed()) { 228 for (var trade : trades.reversed()) {
229 String user = trade.buyerUserName();
230 if ((user == null
231 || user.isBlank()
232 || (marketMaker != null && marketMaker.equalsIgnoreCase(user)))
233 && trade.sellerUserName() != null
234 && !trade.sellerUserName().isBlank()) {
235 user = trade.sellerUserName();
236 }
202 history.add( 237 history.add(
203 new TradeRowDTO( 238 new TradeRowDTO(
204 InstantOptions.humanize(trade.at()), 239 InstantOptions.humanize(trade.at()),
205 trade.buyerUserName(), 240 user,
206 trade.marketName(), 241 trade.marketName(),
207 String.valueOf(trade.quantity()), 242 String.valueOf(trade.quantity()),
208 trade.sharesCost().toPlainString())); 243 trade.sharesCost().toPlainString()));
@@ -221,7 +256,8 @@ public final class Event {
221 mechanism.getType(), 256 mechanism.getType(),
222 state, 257 state,
223 getOptionsNames(), 258 getOptionsNames(),
224 ledger.getBalance().toPlainString()); 259 ledger.getBalance().toPlainString(),
260 marketMaker);
225 } 261 }
226 262
227 public MarketStateDTO toMarketState() { 263 public MarketStateDTO toMarketState() {
@@ -241,14 +277,24 @@ public final class Event {
241 277
242 public Market getOption(String optionKey) { 278 public Market getOption(String optionKey) {
243 return markets.stream() 279 return markets.stream()
244 .filter(option -> option.getKey().equalsIgnoreCase(optionKey)) 280 .filter(
281 option ->
282 option.getKey().equalsIgnoreCase(optionKey)
283 || option.getName().equalsIgnoreCase(optionKey)
284 || ("YES".equalsIgnoreCase(optionKey)
285 && !markets.isEmpty()
286 && option == markets.get(0))
287 || ("NO".equalsIgnoreCase(optionKey)
288 && markets.size() > 1
289 && option == markets.get(1)))
245 .findFirst() 290 .findFirst()
246 .orElseThrow(() -> new IllegalArgumentException("No such option.")); 291 .orElseThrow(() -> new IllegalArgumentException("No such option: " + optionKey));
247 } 292 }
248 293
249 public boolean canSettle(String userName) { 294 public boolean canSettle(String userName) {
250 if (state != EventStatus.ACTIVE) return false; 295 if (state != EventStatus.ACTIVE) return false;
251 if (marketMaker.equalsIgnoreCase(userName)) return true; 296 if (marketMaker == null || marketMaker.isEmpty() || marketMaker.equalsIgnoreCase(userName))
297 return true;
252 298
253 return false; 299 return false;
254 } 300 }
@@ -256,4 +302,125 @@ public final class Event {
256 public void addSettlementCommission(BigDecimal amount) { 302 public void addSettlementCommission(BigDecimal amount) {
257 this.settlementCommission = settlementCommission.add(amount); 303 this.settlementCommission = settlementCommission.add(amount);
258 } 304 }
305
306 public void addCashFlow(String userName, BigDecimal amount) {
307 if (userName == null || userName.isBlank()) return;
308 cashFlow.merge(userName, amount, BigDecimal::add);
309 }
310
311 public EventDetailDTO toEventDetail() {
312 List<OrderBookDTO> obDtos = List.of();
313 if (mechanism instanceof OrderBookTradingMechanism ob) {
314 obDtos = ob.toOrderBookDTOs(markets);
315 }
316 List<ParticipantDTO> pDtos = getParticipants();
317 return new EventDetailDTO(
318 toEventSummary(), toMarketState(), getHistory(), getWinningOptionName(), obDtos, pDtos);
319 }
320
321 public List<ParticipantDTO> getParticipants() {
322 var userSet = new LinkedHashSet<String>();
323 if (marketMaker != null && !marketMaker.isBlank()) {
324 userSet.add(marketMaker);
325 }
326 for (var t : trades) {
327 if (t.buyerUserName() != null && !t.buyerUserName().isBlank()) {
328 userSet.add(t.buyerUserName());
329 }
330 if (t.sellerUserName() != null && !t.sellerUserName().isBlank()) {
331 userSet.add(t.sellerUserName());
332 }
333 }
334 if (mechanism instanceof OrderBookTradingMechanism ob) {
335 for (var book : ob.getAsks()) {
336 for (var o : book) {
337 if (o.getUser() != null && !o.getUser().isBlank()) userSet.add(o.getUser());
338 }
339 }
340 for (var book : ob.getBids()) {
341 for (var o : book) {
342 if (o.getUser() != null && !o.getUser().isBlank()) userSet.add(o.getUser());
343 }
344 }
345 }
346
347 var prices = mechanism.prices();
348 BigDecimal p0 = (prices != null && prices.length > 0) ? prices[0] : BigDecimal.valueOf(0.50);
349 BigDecimal p1 = (prices != null && prices.length > 1) ? prices[1] : BigDecimal.valueOf(0.50);
350
351 String opt0Key = !markets.isEmpty() ? markets.get(0).getKey() : "0";
352 String opt1Key = markets.size() > 1 ? markets.get(1).getKey() : "1";
353
354 var list = new ArrayList<ParticipantDTO>();
355 for (var u : userSet) {
356 boolean isMm = (marketMaker != null && marketMaker.equalsIgnoreCase(u));
357 String tag = isMm ? "MM" : "";
358 int yes = (int) getSharesHeldBy(u, opt0Key);
359 int no = (int) getSharesHeldBy(u, opt1Key);
360
361 BigDecimal val;
362 if (state == EventStatus.SETTLED) {
363 val = BigDecimal.ZERO;
364 } else if (isMm && mechanism.getType() == MechanismType.LMSR && yes == 0 && no == 0) {
365 val = ledger.getBalance();
366 } else {
367 val = p0.multiply(BigDecimal.valueOf(yes)).add(p1.multiply(BigDecimal.valueOf(no)));
368 }
369
370 BigDecimal userFees =
371 trades.stream()
372 .filter(t -> u.equalsIgnoreCase(t.buyerUserName()))
373 .map(Trade::commission)
374 .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add);
375
376 String valStr = "$" + BigDecimalOptions.toMoney(val).toPlainString();
377 String feeStr = "$" + BigDecimalOptions.toMoney(userFees).toPlainString();
378
379 String pnlStr =
380 "$"
381 + BigDecimalOptions.toMoney(cashFlow.getOrDefault(u, BigDecimal.ZERO))
382 .toPlainString();
383
384 list.add(new ParticipantDTO(u, tag, yes, no, valStr, feeStr, pnlStr));
385 }
386 return list;
387 }
388
389 public long getSharesHeldBy(String userName, String optionKey) {
390 if (userName == null || optionKey == null) return 0;
391 String optKey = optionKey;
392 try {
393 var opt = getOption(optionKey);
394 if (opt != null) optKey = opt.getKey();
395 } catch (Exception ignored) {
396 }
397 final String targetKey = optKey;
398 long bought =
399 trades.stream()
400 .filter(
401 t ->
402 (t.marketKey().equalsIgnoreCase(targetKey)
403 || t.marketName().equalsIgnoreCase(optionKey))
404 && userName.equalsIgnoreCase(t.buyerUserName()))
405 .mapToLong(Trade::quantity)
406 .sum();
407 long sold =
408 trades.stream()
409 .filter(
410 t ->
411 (t.marketKey().equalsIgnoreCase(targetKey)
412 || t.marketName().equalsIgnoreCase(optionKey))
413 && userName.equalsIgnoreCase(t.sellerUserName()))
414 .mapToLong(Trade::quantity)
415 .sum();
416 long initialHeld = 0;
417 if (state != EventStatus.DRAFT
418 && marketMaker != null
419 && marketMaker.equalsIgnoreCase(userName)) {
420 if (mechanism instanceof OrderBookTradingMechanism ob) {
421 initialHeld = ob.getInitialCollateral();
422 }
423 }
424 return Math.max(0, initialHeld + bought - sold);
425 }
259} 426}
diff --git a/service/src/main/java/market/guess/service/fulfillment/LocalFulfillmentContext.java b/service/src/main/java/market/guess/service/fulfillment/LocalFulfillmentContext.java
index d1006bc..fba3c76 100644
--- a/service/src/main/java/market/guess/service/fulfillment/LocalFulfillmentContext.java
+++ b/service/src/main/java/market/guess/service/fulfillment/LocalFulfillmentContext.java
@@ -4,6 +4,7 @@ import java.math.BigDecimal;
4import java.util.ArrayList; 4import java.util.ArrayList;
5import java.util.List; 5import java.util.List;
6import market.guess.model.event.CommissionTiming; 6import market.guess.model.event.CommissionTiming;
7import market.guess.model.event.MechanismType;
7import market.guess.model.ledger.LedgerType; 8import market.guess.model.ledger.LedgerType;
8import market.guess.service.domain.Order; 9import market.guess.service.domain.Order;
9import market.guess.service.domain.Trade; 10import market.guess.service.domain.Trade;
@@ -23,32 +24,74 @@ public final class LocalFulfillmentContext implements FulfillmentContext {
23 var event = order.event(); 24 var event = order.event();
24 var chargeNow = event.getCommissionTiming() == CommissionTiming.ON_PURCHASE; 25 var chargeNow = event.getCommissionTiming() == CommissionTiming.ON_PURCHASE;
25 var rate = BigDecimal.valueOf(event.getCommissionPercent()).divide(BigDecimal.valueOf(100)); 26 var rate = BigDecimal.valueOf(event.getCommissionPercent()).divide(BigDecimal.valueOf(100));
27 boolean isLmsr = event.getMechanism().getType() == MechanismType.LMSR;
28 boolean isSell = "SELL".equalsIgnoreCase(order.side());
29 var marketMaker = event.getMarketMaker();
30 boolean feesToMarketMaker = !isLmsr && marketMaker != null && !marketMaker.isBlank();
26 31
27 var recorded = new ArrayList<Trade>(); 32 var recorded = new ArrayList<Trade>();
28 for (var raw : rawTrades) { 33 for (var raw : rawTrades) {
34 var market = event.getOption(raw.marketKey());
35 boolean merge = !isLmsr && isSell && raw.buyerUserName() == null;
36 String buyer =
37 merge ? null : raw.buyerUserName() != null ? raw.buyerUserName() : user.getName();
38 String seller = raw.sellerUserName();
29 var commission = 39 var commission =
30 chargeNow 40 chargeNow && !merge
31 ? BigDecimalOptions.toMoney(raw.sharesCost().multiply(rate)) 41 ? BigDecimalOptions.toMoney(raw.sharesCost().multiply(rate))
32 : BigDecimalOptions.ZERO_MONEY; 42 : BigDecimalOptions.ZERO_MONEY;
43
33 var trade = 44 var trade =
34 event.recordTrade( 45 event.recordTrade(
35 order.at(), 46 order.at(), buyer, market, raw.quantity(), raw.sharesCost(), commission, seller);
36 user.getName(), 47
37 order.market(), 48 if (isLmsr && isSell) {
38 raw.quantity(), 49 ledger.debit(
39 raw.sharesCost(), 50 event.getLedger(),
40 commission); 51 LedgerType.PAYOUT,
41 ledger.debit( 52 trade.sharesCost(),
42 user.getName(), 53 "Refund: " + market.getName());
43 LedgerType.PURCHASE, 54 ledger.credit(
44 trade.totalPaid(), 55 user.getName(), LedgerType.PAYOUT, trade.sharesCost(), "Sale: " + market.getName());
45 "Purchase: " + order.market().getName()); 56 event.addCashFlow(user.getName(), trade.sharesCost());
46 ledger.credit( 57 market.addVolume(-trade.quantity());
47 event.getLedger(), 58 } else if (merge) {
48 LedgerType.PURCHASE, 59 ledger.debit(
49 trade.totalPaid(), 60 event.getLedger(), LedgerType.PAYOUT, trade.sharesCost(), "Merge: " + market.getName());
50 "Purchase: " + order.market().getName()); 61 ledger.credit(seller, LedgerType.PAYOUT, trade.sharesCost(), "Merge: " + market.getName());
51 order.market().addVolume(trade.quantity()); 62 event.addCashFlow(seller, trade.sharesCost());
63 } else {
64 ledger.debit(
65 buyer, LedgerType.PURCHASE, trade.totalPaid(), "Purchase: " + market.getName());
66 event.addCashFlow(buyer, trade.totalPaid().negate());
67
68 if (seller != null && !seller.isBlank()) {
69 ledger.credit(seller, LedgerType.PAYOUT, trade.sharesCost(), "Sale: " + market.getName());
70 event.addCashFlow(seller, trade.sharesCost());
71 } else {
72 ledger.credit(
73 event.getLedger(),
74 LedgerType.PURCHASE,
75 trade.sharesCost(),
76 "Purchase: " + market.getName());
77 }
78
79 if (commission.signum() > 0) {
80 if (feesToMarketMaker) {
81 ledger.credit(
82 marketMaker, LedgerType.COMMISSION, commission, "Commission: " + market.getName());
83 event.addCashFlow(marketMaker, commission);
84 } else {
85 ledger.credit(
86 event.getLedger(),
87 LedgerType.COMMISSION,
88 commission,
89 "Commission: " + market.getName());
90 }
91 }
92
93 market.addVolume(trade.quantity());
94 }
52 recorded.add(trade); 95 recorded.add(trade);
53 } 96 }
54 return recorded; 97 return recorded;
diff --git a/service/src/main/java/market/guess/service/ledger/LocalAccountContext.java b/service/src/main/java/market/guess/service/ledger/LocalAccountContext.java
new file mode 100644
index 0000000..84d23b4
--- /dev/null
+++ b/service/src/main/java/market/guess/service/ledger/LocalAccountContext.java
@@ -0,0 +1,51 @@
1package market.guess.service.ledger;
2
3import java.util.ArrayList;
4import java.util.List;
5import market.guess.api.AccountContext;
6import market.guess.api.Result;
7import market.guess.model.ledger.LedgerDTO;
8import market.guess.model.ledger.LedgerEntryDTO;
9import market.guess.service.catalog.infrastructure.repository.UserRepository;
10import market.guess.service.domain.User;
11import market.guess.service.helpers.InstantOptions;
12
13public final class LocalAccountContext implements AccountContext {
14 private final UserRepository users;
15
16 public LocalAccountContext(UserRepository users) {
17 this.users = users;
18 }
19
20 @Override
21 public Result<List<LedgerDTO>> getAllAccounts() {
22 var list = users.getAll().stream().map(this::toDTO).toList();
23 return Result.ok(list);
24 }
25
26 @Override
27 public Result<LedgerDTO> getAccount(String owner) {
28 var opt = users.get(owner);
29 if (opt.isEmpty()) {
30 return Result.error("Account not found: " + owner);
31 }
32 return Result.ok(toDTO(opt.get()));
33 }
34
35 private LedgerDTO toDTO(User user) {
36 var ledger = user.getLedger();
37 var entries = new ArrayList<LedgerEntryDTO>();
38 for (var entry : ledger.getEntries()) {
39 entries.add(
40 new LedgerEntryDTO(
41 entry.id(),
42 InstantOptions.humanize(entry.time()),
43 entry.type(),
44 entry.amount().toPlainString(),
45 entry.balanceAfter().toPlainString(),
46 entry.note()));
47 }
48 boolean blocked = ledger.getBalance().signum() < 0;
49 return new LedgerDTO(user.getName(), ledger.getBalance().toPlainString(), blocked, entries);
50 }
51}
diff --git a/service/src/main/java/market/guess/service/mechanism/LmsrTradingMechanism.java b/service/src/main/java/market/guess/service/mechanism/LmsrTradingMechanism.java
index c133ac7..07def01 100644
--- a/service/src/main/java/market/guess/service/mechanism/LmsrTradingMechanism.java
+++ b/service/src/main/java/market/guess/service/mechanism/LmsrTradingMechanism.java
@@ -63,6 +63,44 @@ public final class LmsrTradingMechanism implements TradingMechanism {
63 } 63 }
64 64
65 @Override 65 @Override
66 public List<Trade> placeOrder(
67 Event event,
68 Market market,
69 String user,
70 String side,
71 Instant at,
72 long quantity,
73 BigDecimal price) {
74 if ("SELL".equalsIgnoreCase(side)) {
75 return sell(event, market, user, at, quantity);
76 }
77 return buy(event, market, at, quantity);
78 }
79
80 public List<Trade> sell(Event event, Market market, String user, Instant at, long quantity) {
81 var optionIndex = event.getMarkets().indexOf(market);
82 var before = cost(q);
83
84 var after = q.clone();
85 after[optionIndex] -= quantity;
86 var delta = before - cost(after);
87
88 q[optionIndex] -= quantity;
89
90 return List.of(
91 new Trade(
92 0,
93 at,
94 event.getMarketMaker(),
95 market.getKey(),
96 market.getName(),
97 quantity,
98 BigDecimalOptions.toMoney(delta),
99 BigDecimal.ZERO,
100 user));
101 }
102
103 @Override
66 public BigDecimal maxLoss() { 104 public BigDecimal maxLoss() {
67 return BigDecimalOptions.toMoney(cost(new int[q.length])); 105 return BigDecimalOptions.toMoney(cost(new int[q.length]));
68 } 106 }
@@ -94,4 +132,9 @@ public final class LmsrTradingMechanism implements TradingMechanism {
94 } 132 }
95 return liquidity * (max + Math.log(sumExp)); 133 return liquidity * (max + Math.log(sumExp));
96 } 134 }
135
136 @Override
137 public boolean cancel() {
138 return false;
139 }
97} 140}
diff --git a/service/src/main/java/market/guess/service/mechanism/OrderBookTradingMechanism.java b/service/src/main/java/market/guess/service/mechanism/OrderBookTradingMechanism.java
index ba88d06..0b93e7c 100644
--- a/service/src/main/java/market/guess/service/mechanism/OrderBookTradingMechanism.java
+++ b/service/src/main/java/market/guess/service/mechanism/OrderBookTradingMechanism.java
@@ -6,6 +6,8 @@ import java.time.Instant;
6import java.util.ArrayList; 6import java.util.ArrayList;
7import java.util.List; 7import java.util.List;
8import market.guess.model.event.MechanismType; 8import market.guess.model.event.MechanismType;
9import market.guess.model.market.BookOrderDTO;
10import market.guess.model.market.OrderBookDTO;
9import market.guess.service.domain.Event; 11import market.guess.service.domain.Event;
10import market.guess.service.domain.Market; 12import market.guess.service.domain.Market;
11import market.guess.service.domain.Trade; 13import market.guess.service.domain.Trade;
@@ -17,23 +19,41 @@ public final class OrderBookTradingMechanism implements TradingMechanism {
17 SELL 19 SELL
18 } 20 }
19 21
20 private static final class RestingOrder { 22 public static final class RestingOrder {
23 private final String user;
21 private final BigDecimal price; 24 private final BigDecimal price;
22 private long quantity; 25 private long quantity;
26 private final Side side;
23 27
24 private RestingOrder(BigDecimal price, long quantity) { 28 public RestingOrder(String user, BigDecimal price, long quantity, Side side) {
29 this.user = user != null ? user : "Unknown";
25 this.price = price; 30 this.price = price;
26 this.quantity = quantity; 31 this.quantity = quantity;
32 this.side = side;
33 }
34
35 public String getUser() {
36 return user;
37 }
38
39 public BigDecimal getPrice() {
40 return price;
41 }
42
43 public long getQuantity() {
44 return quantity;
45 }
46
47 public Side getSide() {
48 return side;
27 } 49 }
28 } 50 }
29 51
30 private final boolean allowMint; 52 private final boolean allowMint;
31 private final int initial; 53 private final int initial;
32 private final int d; 54 private final int d;
33 private final List<List<RestingOrder>> 55 private final List<List<RestingOrder>> asks; // per option, ascending price (best = lowest, index 0)
34 asks; // per option, ascending price (best = lowest, index 0) 56 private final List<List<RestingOrder>> bids; // per option, descending price (best = highest, index 0)
35 private final List<List<RestingOrder>>
36 bids; // per option, descending price (best = highest, index 0)
37 private final BigDecimal[] lastTradePrice; 57 private final BigDecimal[] lastTradePrice;
38 58
39 public OrderBookTradingMechanism(boolean allowMint, int initial, int d, int optionCount) { 59 public OrderBookTradingMechanism(boolean allowMint, int initial, int d, int optionCount) {
@@ -58,16 +78,43 @@ public final class OrderBookTradingMechanism implements TradingMechanism {
58 78
59 @Override 79 @Override
60 public List<Trade> buy(Event event, Market market, Instant at, long quantity) { 80 public List<Trade> buy(Event event, Market market, Instant at, long quantity) {
61 var optionIndex = event.getMarkets().indexOf(market); 81 return placeOrder(event, market, null, "BUY", at, quantity, null);
62 return List.of(match(optionIndex, Side.BUY, at, quantity, null, false));
63 } 82 }
64 83
65 /** Priced limit order — not on the shared interface, no engine call site yet. */ 84 @Override
66 public Trade placeOrder(int optionIndex, Side side, Instant at, long quantity, BigDecimal price) { 85 public synchronized List<Trade> placeOrder(
67 if (price.signum() < 0 || price.compareTo(BigDecimal.valueOf(d)) > 0) { 86 Event event,
68 throw new IllegalArgumentException("Price must be between 0 and " + d); 87 Market market,
88 String user,
89 String side,
90 Instant at,
91 long quantity,
92 BigDecimal price) {
93 var optionIndex = event != null && event.getMarkets() != null ? event.getMarkets().indexOf(market) : 0;
94 if (optionIndex < 0) {
95 optionIndex = 0;
96 }
97 Side s = "SELL".equalsIgnoreCase(side) ? Side.SELL : Side.BUY;
98 if (quantity <= 0) {
99 throw new IllegalArgumentException("Quantity must be positive");
100 }
101 if (price == null) {
102 price = priceFor(optionIndex);
103 }
104 var error = priceError(price);
105 if (error != null) {
106 throw new IllegalArgumentException(error);
69 } 107 }
70 return match(optionIndex, side, at, quantity, price, true); 108 return match(optionIndex, s, user, at, quantity, price, event, market);
109 }
110
111 /** A share pays out at most d, so a price of 0 or d (or beyond) is never a real trade. */
112 public String priceError(BigDecimal price) {
113 if (price == null || (price.signum() > 0 && price.compareTo(BigDecimal.valueOf(d)) < 0)) {
114 return null;
115 }
116 var max = BigDecimal.valueOf(d).subtract(new BigDecimal("0.01"));
117 return "Price must be between $0.01 and $" + BigDecimalOptions.toMoney(max).toPlainString() + ".";
71 } 118 }
72 119
73 @Override 120 @Override
@@ -89,7 +136,7 @@ public final class OrderBookTradingMechanism implements TradingMechanism {
89 if (bestAsk != null) return BigDecimalOptions.toMoney(bestAsk); 136 if (bestAsk != null) return BigDecimalOptions.toMoney(bestAsk);
90 if (lastTradePrice[optionIndex] != null) return lastTradePrice[optionIndex]; 137 if (lastTradePrice[optionIndex] != null) return lastTradePrice[optionIndex];
91 138
92 // No information yet: neutral 50/50 prior, same as LMSR's day-one price. 139 // Neutral 50/50 prior
93 return BigDecimalOptions.toMoney(d / 2.0); 140 return BigDecimalOptions.toMoney(d / 2.0);
94 } 141 }
95 142
@@ -97,52 +144,158 @@ public final class OrderBookTradingMechanism implements TradingMechanism {
97 return book.isEmpty() ? null : book.get(0).price; 144 return book.isEmpty() ? null : book.get(0).price;
98 } 145 }
99 146
100 private Trade match( 147 public synchronized BigDecimal getLastTradePrice(int optionIndex) {
148 return lastTradePrice[optionIndex];
149 }
150
151 /** Shares this user already promised in resting asks on one option. */
152 public synchronized long restingAskQuantity(String user, int optionIndex) {
153 return asks.get(optionIndex).stream()
154 .filter(o -> o.user.equalsIgnoreCase(user))
155 .mapToLong(o -> o.quantity)
156 .sum();
157 }
158
159 /** Cash this user already promised in resting bids on every option, before commission. */
160 public synchronized BigDecimal restingBidValue(String user) {
161 return bids.stream()
162 .flatMap(List::stream)
163 .filter(o -> o.user.equalsIgnoreCase(user))
164 .map(o -> o.price.multiply(BigDecimal.valueOf(o.quantity)))
165 .reduce(BigDecimal.ZERO, BigDecimal::add);
166 }
167
168 private List<Trade> match(
101 int optionIndex, 169 int optionIndex,
102 Side side, 170 Side side,
171 String user,
103 Instant at, 172 Instant at,
104 long quantity, 173 long quantity,
105 BigDecimal limitPrice, 174 BigDecimal limitPrice,
106 boolean restRemainder) { 175 Event event,
107 var opposing = side == Side.BUY ? asks.get(optionIndex) : bids.get(optionIndex); 176 Market market) {
177 var direct = side == Side.BUY ? asks.get(optionIndex) : bids.get(optionIndex);
178 // In a binary market the other option's book is a counterparty too, since a YES and a NO
179 // together are worth exactly d: a bid there can mint a new pair with a buy, and an ask there
180 // can merge a pair back into the pool with a sell.
181 // ponytail: binary only; N-option markets would need an order on every other option.
182 int other = 1 - optionIndex;
183 var complement =
184 allowMint && asks.size() == 2
185 ? (side == Side.BUY ? bids.get(other) : asks.get(other))
186 : List.<RestingOrder>of();
187 var dValue = BigDecimal.valueOf(d);
188 var trades = new ArrayList<Trade>();
108 189
109 var filled = 0; 190 long filled = 0;
110 var proceeds = BigDecimal.ZERO; 191 while (filled < quantity) {
111 while (filled < quantity 192 int di = firstCounterparty(direct, user);
112 && !opposing.isEmpty() 193 int ci = firstCounterparty(complement, user);
113 && crosses(side, opposing.get(0).price, limitPrice)) { 194 var directPrice = di < 0 ? null : direct.get(di).price;
114 var top = opposing.get(0); 195 var pairPrice = ci < 0 ? null : dValue.subtract(complement.get(ci).price);
115 var take = Math.min(quantity - filled, top.quantity); 196 boolean useDirect = directPrice != null && crosses(side, directPrice, limitPrice);
197 boolean usePair = pairPrice != null && crosses(side, pairPrice, limitPrice);
198 if (useDirect && usePair) {
199 // Better price for the taker first; a plain resale wins ties.
200 int cmp = directPrice.compareTo(pairPrice);
201 useDirect = side == Side.BUY ? cmp <= 0 : cmp >= 0;
202 usePair = !useDirect;
203 }
204 if (!useDirect && !usePair) break;
116 205
206 var book = useDirect ? direct : complement;
207 int index = useDirect ? di : ci;
208 var top = book.get(index);
209 long take = Math.min(quantity - filled, top.quantity);
117 filled += take; 210 filled += take;
118 proceeds = proceeds.add(top.price.multiply(BigDecimal.valueOf(take)));
119 top.quantity -= take; 211 top.quantity -= take;
120 if (top.quantity == 0) opposing.remove(0); 212 if (top.quantity == 0) book.remove(index);
121 }
122 213
123 if (filled > 0) { 214 if (useDirect) {
124 lastTradePrice[optionIndex] = 215 String buyer = side == Side.BUY ? user : top.user;
125 BigDecimalOptions.toMoney( 216 String seller = side == Side.BUY ? top.user : user;
126 proceeds.divide(BigDecimal.valueOf(filled), 10, RoundingMode.HALF_EVEN)); 217 trades.add(trade(at, event, market, optionIndex, buyer, take, top.price, seller));
218 lastTradePrice[optionIndex] = top.price;
219 continue;
220 }
221 // The resting price is honored; the taker gets the rest of d.
222 if (side == Side.BUY) {
223 // Mint: no seller, both payments go into the pool as collateral.
224 trades.add(trade(at, event, market, optionIndex, user, take, pairPrice, null));
225 trades.add(trade(at, event, null, other, top.user, take, top.price, null));
226 } else {
227 // Merge: no buyer, the pool pays both sellers out of the pair's collateral.
228 trades.add(trade(at, event, market, optionIndex, null, take, pairPrice, user));
229 trades.add(trade(at, event, null, other, null, take, top.price, top.user));
230 }
231 lastTradePrice[optionIndex] = BigDecimalOptions.toMoney(pairPrice);
232 lastTradePrice[other] = top.price;
127 } 233 }
128 if (restRemainder && filled < quantity) { 234
235 if (filled < quantity) {
129 var restingBook = side == Side.BUY ? bids.get(optionIndex) : asks.get(optionIndex); 236 var restingBook = side == Side.BUY ? bids.get(optionIndex) : asks.get(optionIndex);
130 restingBook.add( 237 restingBook.add(
131 insertionIndex(restingBook, side, limitPrice), 238 insertionIndex(restingBook, side, limitPrice),
132 new RestingOrder(limitPrice, quantity - filled)); 239 new RestingOrder(user, limitPrice, quantity - filled, side));
240 }
241
242 return trades;
243 }
244
245 /** Self-trade prevention: a user's own resting orders are skipped, never matched. */
246 private static int firstCounterparty(List<RestingOrder> book, String user) {
247 for (var i = 0; i < book.size(); i++) {
248 if (user == null || !book.get(i).user.equalsIgnoreCase(user)) return i;
133 } 249 }
250 return -1;
251 }
134 252
253 private static Trade trade(
254 Instant at,
255 Event event,
256 Market market,
257 int optionIndex,
258 String buyer,
259 long qty,
260 BigDecimal unitPrice,
261 String seller) {
262 if (market == null
263 && event != null
264 && event.getMarkets() != null
265 && optionIndex < event.getMarkets().size()) {
266 market = event.getMarkets().get(optionIndex);
267 }
135 return new Trade( 268 return new Trade(
136 filled, at, null, null, null, filled, BigDecimalOptions.toMoney(proceeds), proceeds); 269 0,
270 at,
271 buyer,
272 market != null ? market.getKey() : String.valueOf(optionIndex),
273 market != null ? market.getName() : "Option " + optionIndex,
274 qty,
275 BigDecimalOptions.toMoney(unitPrice.multiply(BigDecimal.valueOf(qty))),
276 BigDecimal.ZERO,
277 seller);
137 } 278 }
138 279
139 private static boolean crosses(Side side, BigDecimal restingPrice, BigDecimal limitPrice) { 280 private static boolean crosses(Side side, BigDecimal restingPrice, BigDecimal limitPrice) {
140 if (limitPrice == null) return true; // market order: any price crosses 281 if (limitPrice == null) return true;
141 return side == Side.BUY 282 return side == Side.BUY
142 ? restingPrice.compareTo(limitPrice) <= 0 283 ? restingPrice.compareTo(limitPrice) <= 0
143 : restingPrice.compareTo(limitPrice) >= 0; 284 : restingPrice.compareTo(limitPrice) >= 0;
144 } 285 }
145 286
287 public boolean canMint() {
288 return this.allowMint;
289 }
290
291 public int getInitialCollateral() {
292 return this.initial;
293 }
294
295 public int getD() {
296 return this.d;
297 }
298
146 private static int insertionIndex(List<RestingOrder> book, Side side, BigDecimal price) { 299 private static int insertionIndex(List<RestingOrder> book, Side side, BigDecimal price) {
147 var i = 0; 300 var i = 0;
148 while (i < book.size() && betterOrEqual(side, book.get(i).price, price)) i++; 301 while (i < book.size() && betterOrEqual(side, book.get(i).price, price)) i++;
@@ -151,9 +304,53 @@ public final class OrderBookTradingMechanism implements TradingMechanism {
151 304
152 private static boolean betterOrEqual(Side side, BigDecimal existing, BigDecimal incoming) { 305 private static boolean betterOrEqual(Side side, BigDecimal existing, BigDecimal incoming) {
153 return side == Side.BUY 306 return side == Side.BUY
154 ? existing.compareTo(incoming) 307 ? existing.compareTo(incoming) >= 0 // bids: descending, FIFO for ties
155 >= 0 // bids: descending, ties keep earlier order first (FIFO) 308 : existing.compareTo(incoming) <= 0; // asks: ascending, FIFO for ties
156 : existing.compareTo(incoming) 309 }
157 <= 0; // asks: ascending, ties keep earlier order first (FIFO) 310
311 public List<OrderBookDTO> toOrderBookDTOs(List<Market> markets) {
312 var dtos = new ArrayList<OrderBookDTO>();
313 for (int i = 0; i < asks.size(); i++) {
314 String key = (markets != null && i < markets.size()) ? markets.get(i).getKey() : String.valueOf(i);
315 String name = (markets != null && i < markets.size()) ? markets.get(i).getName() : "Option " + i;
316
317 var bestBid = bestPrice(bids.get(i));
318 var bestAsk = bestPrice(asks.get(i));
319 String bidStr = bestBid != null ? "$" + bestBid.toPlainString() : null;
320 String askStr = bestAsk != null ? "$" + bestAsk.toPlainString() : null;
321 String spreadStr = (bestBid != null && bestAsk != null)
322 ? "$" + BigDecimalOptions.toMoney(bestAsk.subtract(bestBid)).toPlainString()
323 : null;
324
325 var rows = new ArrayList<BookOrderDTO>();
326 for (var o : asks.get(i)) {
327 rows.add(new BookOrderDTO("ASK", o.getUser(), o.getQuantity(), "$" + o.getPrice().toPlainString(), false));
328 }
329 for (var o : bids.get(i)) {
330 rows.add(new BookOrderDTO("BID", o.getUser(), o.getQuantity(), "$" + o.getPrice().toPlainString(), true));
331 }
332
333 dtos.add(new OrderBookDTO(key, name, bidStr, askStr, spreadStr, rows));
334 }
335 return dtos;
336 }
337
338 public List<List<RestingOrder>> getAsks() {
339 return asks;
340 }
341
342 public List<List<RestingOrder>> getBids() {
343 return bids;
344 }
345
346 @Override
347 public boolean cancel() {
348 return true;
349 }
350
351 /** At resolution every unmatched order is simply dropped. */
352 public synchronized void cancelAll() {
353 asks.forEach(List::clear);
354 bids.forEach(List::clear);
158 } 355 }
159} 356}
diff --git a/service/src/main/java/market/guess/service/mechanism/TradingMechanism.java b/service/src/main/java/market/guess/service/mechanism/TradingMechanism.java
index 90e696d..eb32d83 100644
--- a/service/src/main/java/market/guess/service/mechanism/TradingMechanism.java
+++ b/service/src/main/java/market/guess/service/mechanism/TradingMechanism.java
@@ -15,10 +15,22 @@ public sealed interface TradingMechanism permits LmsrTradingMechanism, OrderBook
15 15
16 List<Trade> buy(Event event, Market market, Instant at, long quantity); 16 List<Trade> buy(Event event, Market market, Instant at, long quantity);
17 17
18 default List<Trade> placeOrder(
19 Event event,
20 Market market,
21 String user,
22 String side,
23 Instant at,
24 long quantity,
25 BigDecimal price) {
26 return buy(event, market, at, quantity);
27 }
28
18 BigDecimal[] prices(); 29 BigDecimal[] prices();
19 30
20 /** The subsidy needed to cover the market maker's worst-case loss, if any. */
21 default BigDecimal maxLoss() { 31 default BigDecimal maxLoss() {
22 return BigDecimalOptions.ZERO_MONEY; 32 return BigDecimalOptions.ZERO_MONEY;
23 } 33 }
34
35 boolean cancel();
24} 36}
diff --git a/service/src/main/java/market/guess/service/risk/LocalRiskEngine.java b/service/src/main/java/market/guess/service/risk/LocalRiskEngine.java
index d3e46e2..1cb9976 100644
--- a/service/src/main/java/market/guess/service/risk/LocalRiskEngine.java
+++ b/service/src/main/java/market/guess/service/risk/LocalRiskEngine.java
@@ -1,18 +1,88 @@
1package market.guess.service.risk; 1package market.guess.service.risk;
2 2
3import java.math.BigDecimal; 3import java.math.BigDecimal;
4import java.util.List;
4import market.guess.api.Result; 5import market.guess.api.Result;
6import market.guess.model.event.CommissionTiming;
7import market.guess.model.event.EventStatus;
8import market.guess.service.catalog.infrastructure.repository.EventRepository;
9import market.guess.service.domain.Event;
5import market.guess.service.domain.Order; 10import market.guess.service.domain.Order;
6import market.guess.service.domain.User; 11import market.guess.service.domain.User;
12import market.guess.service.mechanism.OrderBookTradingMechanism;
7 13
8public final class LocalRiskEngine implements RiskEngine { 14public final class LocalRiskEngine implements RiskEngine {
15 private final EventRepository events;
16
17 public LocalRiskEngine() {
18 this(null);
19 }
20
21 public LocalRiskEngine(EventRepository events) {
22 this.events = events;
23 }
9 24
10 @Override 25 @Override
11 public Result<Void> check(User user, Order order) { 26 public Result<Void> check(User user, Order order) {
12 var cost = order.price().multiply(BigDecimal.valueOf(order.quantity())); 27 if (user.isBlocked()) {
13 if (user.getLedger().getBalance().compareTo(cost) < 0) { 28 return Result.error("Account is blocked.");
14 return Result.error("Insufficient balance."); 29 }
30 if (order.event().getState() != EventStatus.ACTIVE) {
31 return Result.error("Event is not ACTIVE.");
32 }
33 if (order.quantity() <= 0) {
34 return Result.error("Quantity must be positive.");
35 }
36 var book = order.event().getMechanism() instanceof OrderBookTradingMechanism ob ? ob : null;
37 if (book != null) {
38 var error = book.priceError(order.price());
39 if (error != null) return Result.error(error);
40 }
41
42 if ("SELL".equalsIgnoreCase(order.side())) {
43 long held = order.event().getSharesHeldBy(user.getName(), order.market().getKey());
44 if (book != null) {
45 held -=
46 book.restingAskQuantity(
47 user.getName(), order.event().getMarkets().indexOf(order.market()));
48 }
49 if (held < order.quantity()) {
50 return Result.error(
51 "Insufficient shares to sell. You hold "
52 + held
53 + " available shares, but requested to sell "
54 + order.quantity()
55 + ".");
56 }
57 } else {
58 BigDecimal unitPrice = order.price() != null ? order.price() : BigDecimal.valueOf(0.50);
59 var cost =
60 withCommission(order.event(), unitPrice.multiply(BigDecimal.valueOf(order.quantity())))
61 .add(reservedCash(user.getName(), order.event()));
62 if (user.getLedger().getBalance().compareTo(cost) < 0) {
63 return Result.error("Insufficient balance.");
64 }
15 } 65 }
16 return Result.ok(); 66 return Result.ok();
17 } 67 }
68
69 private BigDecimal reservedCash(String userName, Event current) {
70 Iterable<Event> scope = events != null ? events.getAll() : List.of(current);
71 var total = BigDecimal.ZERO;
72 for (var event : scope) {
73 if (event.getState() == EventStatus.ACTIVE
74 && event.getMechanism() instanceof OrderBookTradingMechanism ob) {
75 total = total.add(withCommission(event, ob.restingBidValue(userName)));
76 }
77 }
78 return total;
79 }
80
81 private static BigDecimal withCommission(Event event, BigDecimal value) {
82 if (event.getCommissionTiming() != CommissionTiming.ON_PURCHASE) return value;
83 return value.add(
84 value
85 .multiply(BigDecimal.valueOf(event.getCommissionPercent()))
86 .divide(BigDecimal.valueOf(100)));
87 }
18} 88}
diff --git a/service/src/main/java/market/guess/service/settlement/LocalSettlementContext.java b/service/src/main/java/market/guess/service/settlement/LocalSettlementContext.java
index d970b03..a028ec3 100644
--- a/service/src/main/java/market/guess/service/settlement/LocalSettlementContext.java
+++ b/service/src/main/java/market/guess/service/settlement/LocalSettlementContext.java
@@ -1,13 +1,14 @@
1package market.guess.service.settlement; 1package market.guess.service.settlement;
2 2
3import java.math.BigDecimal; 3import java.math.BigDecimal;
4import java.util.HashMap; 4import java.util.TreeSet;
5import market.guess.model.event.CommissionTiming; 5import market.guess.model.event.CommissionTiming;
6import market.guess.model.event.EventStatus; 6import market.guess.model.event.EventStatus;
7import market.guess.model.ledger.LedgerType; 7import market.guess.model.ledger.LedgerType;
8import market.guess.service.domain.Event; 8import market.guess.service.domain.Event;
9import market.guess.service.helpers.BigDecimalOptions; 9import market.guess.service.helpers.BigDecimalOptions;
10import market.guess.service.ledger.LedgerContext; 10import market.guess.service.ledger.LedgerContext;
11import market.guess.service.mechanism.OrderBookTradingMechanism;
11 12
12public final class LocalSettlementContext implements SettlementContext { 13public final class LocalSettlementContext implements SettlementContext {
13 private final LedgerContext ledger; 14 private final LedgerContext ledger;
@@ -20,32 +21,71 @@ public final class LocalSettlementContext implements SettlementContext {
20 public BigDecimal settle(Event event, String winningOptionKey) { 21 public BigDecimal settle(Event event, String winningOptionKey) {
21 event.setWinningOptionKey(winningOptionKey); 22 event.setWinningOptionKey(winningOptionKey);
22 23
23 // TODO: abstract the commission charge away
24 var chargeCommission = event.getCommissionTiming() == CommissionTiming.ON_CLOSE; 24 var chargeCommission = event.getCommissionTiming() == CommissionTiming.ON_CLOSE;
25 var rate = BigDecimal.valueOf(event.getCommissionPercent()).divide(BigDecimal.valueOf(100)); 25 var rate = BigDecimal.valueOf(event.getCommissionPercent()).divide(BigDecimal.valueOf(100));
26 var winningOption = event.getOption(winningOptionKey); 26 var winningOption = event.getOption(winningOptionKey);
27 27
28 var sharesByUser = new HashMap<String, Long>(); 28 var book = event.getMechanism() instanceof OrderBookTradingMechanism ob ? ob : null;
29 if (book != null) {
30 book.cancelAll();
31 }
32 var perShare = BigDecimal.valueOf(book != null ? book.getD() : 1);
33 var marketMaker = event.getMarketMaker();
34 boolean hasMarketMaker = marketMaker != null && !marketMaker.isBlank();
35 boolean feesToMarketMaker = hasMarketMaker;
36
37 // Pay what each user still holds, not what they ever bought: resold shares were paid for
38 // by someone else, and the market maker's minted shares never appear as a purchase.
39 var holders = new TreeSet<String>(String.CASE_INSENSITIVE_ORDER);
40 if (hasMarketMaker) holders.add(marketMaker);
29 for (var trade : event.getTrades()) { 41 for (var trade : event.getTrades()) {
30 if (!trade.marketKey().equalsIgnoreCase(winningOptionKey)) continue; 42 if (trade.buyerUserName() != null && !trade.buyerUserName().isBlank()) {
31 sharesByUser.merge(trade.buyerUserName(), trade.quantity(), Long::sum); 43 holders.add(trade.buyerUserName());
44 }
45 if (trade.sellerUserName() != null && !trade.sellerUserName().isBlank()) {
46 holders.add(trade.sellerUserName());
47 }
32 } 48 }
33 49
34 var totalPayout = BigDecimalOptions.ZERO_MONEY; 50 var totalPayout = BigDecimalOptions.ZERO_MONEY;
35 for (var entry : sharesByUser.entrySet()) { 51 for (var holder : holders) {
36 var gross = BigDecimalOptions.toMoney(BigDecimal.valueOf(entry.getValue())); 52 long shares = event.getSharesHeldBy(holder, winningOption.getKey());
53 if (shares <= 0) continue;
54
55 var gross = BigDecimalOptions.toMoney(perShare.multiply(BigDecimal.valueOf(shares)));
37 var commission = 56 var commission =
38 chargeCommission 57 chargeCommission
39 ? BigDecimalOptions.toMoney(gross.multiply(rate)) 58 ? BigDecimalOptions.toMoney(gross.multiply(rate))
40 : BigDecimalOptions.ZERO_MONEY; 59 : BigDecimalOptions.ZERO_MONEY;
41 var net = gross.subtract(commission); 60 var net = gross.subtract(commission);
42 61
43 ledger.credit(entry.getKey(), LedgerType.PAYOUT, net, "Payout: " + winningOption.getName()); 62 ledger.credit(holder, LedgerType.PAYOUT, net, "Payout: " + winningOption.getName());
44 ledger.debit(event.getLedger(), LedgerType.PAYOUT, net, "Payout: " + winningOption.getName()); 63 event.addCashFlow(holder, net);
64 ledger.debit(
65 event.getLedger(),
66 LedgerType.PAYOUT,
67 feesToMarketMaker ? gross : net,
68 "Payout: " + winningOption.getName());
69 if (feesToMarketMaker && commission.signum() > 0) {
70 ledger.credit(
71 marketMaker,
72 LedgerType.COMMISSION,
73 commission,
74 "Commission: " + winningOption.getName());
75 event.addCashFlow(marketMaker, commission);
76 }
45 event.addSettlementCommission(commission); 77 event.addSettlementCommission(commission);
46 totalPayout = totalPayout.add(net); 78 totalPayout = totalPayout.add(net);
47 } 79 }
48 80
81 // LMSR: whatever the subsidy didn't lose goes back to the market maker who paid it.
82 var leftover = event.getLedger().getBalance();
83 if (book == null && hasMarketMaker && leftover.signum() > 0) {
84 ledger.debit(event.getLedger(), LedgerType.REFUND, leftover, "Unused subsidy returned");
85 ledger.credit(marketMaker, LedgerType.REFUND, leftover, "Unused subsidy: " + event.getName());
86 event.addCashFlow(marketMaker, leftover);
87 }
88
49 event.setState(EventStatus.SETTLED); 89 event.setState(EventStatus.SETTLED);
50 return totalPayout; 90 return totalPayout;
51 } 91 }