From 8d7cecd9cf9fb2b0b45607b8efd7ca8f19a89fc6 Mon Sep 17 00:00:00 2001 From: Kostya Date: Mon, 7 Sep 2026 16:03:14 +0300 Subject: service: add sell orders, event lifecycle and order book matching Carry the order side from the context through risk, the trading mechanisms, fulfillment and settlement, so users can sell shares as well as buy them. The order book now matches priced limit orders from named users, rests any unfilled remainder, and prevents self-trades. In a binary market with minting enabled, a buy can also match a bid on the other option to mint a new YES/NO pair, and a sell can match an ask there to merge a pair back into the pool. LMSR gains a sell that refunds the cost difference from the pool. Add createEvent and openEvent to the catalog. Events start as DRAFT and only take the market maker's subsidy when they are opened, which fails if the market maker can't cover it. Add LocalAccountContext to expose user ledgers. Tighten the risk checks: reject orders from blocked accounts, on events that aren't ACTIVE, and at prices outside (0, d). Sells need enough unreserved shares; buys need enough balance to cover commission and cash already committed to resting bids. Settlement now cancels resting orders, pays out on shares still held rather than shares ever bought, sends order book commission to the market maker, and returns unused LMSR subsidy. Events also track per-user cash flow, so the event detail can list participants with their holdings, fees and P&L next to the order books. --- .../guess/service/LocalGuessMarketContext.java | 61 +++-- .../guess/service/catalog/LocalCatalogContext.java | 117 ++++++++- .../java/market/guess/service/domain/Event.java | 183 +++++++++++++- .../fulfillment/LocalFulfillmentContext.java | 79 ++++-- .../guess/service/ledger/LocalAccountContext.java | 51 ++++ .../service/mechanism/LmsrTradingMechanism.java | 43 ++++ .../mechanism/OrderBookTradingMechanism.java | 275 ++++++++++++++++++--- .../guess/service/mechanism/TradingMechanism.java | 14 +- .../market/guess/service/risk/LocalRiskEngine.java | 76 +++++- .../service/settlement/LocalSettlementContext.java | 58 ++++- 10 files changed, 853 insertions(+), 104 deletions(-) create mode 100644 service/src/main/java/market/guess/service/ledger/LocalAccountContext.java (limited to 'service/src/main/java/market/guess') 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; import market.guess.model.market.TradeRowDTO; import market.guess.service.catalog.infrastructure.MarketContext; import market.guess.service.domain.Order; +import market.guess.service.domain.Trade; import market.guess.service.fulfillment.FulfillmentContext; import market.guess.service.helpers.BigDecimalOptions; import market.guess.service.helpers.InstantOptions; @@ -39,17 +40,26 @@ public final class LocalGuessMarketContext implements GuessMarketContext { @Override public Result placeOrder( - String userName, String eventKey, String optionKey, BigDecimal price, long quantity) { + String userName, + String eventKey, + String optionKey, + String side, + BigDecimal price, + long quantity) { var optional = context.getEvents().get(eventKey); - var userOptional = context.getUsers().get(userName); - if (optional.isEmpty() || userOptional.isEmpty()) + if (optional.isEmpty() || userOptional.isEmpty()) { return Result.error("Unable to find user/event."); + } var event = optional.get(); var user = userOptional.get(); var option = event.getOption(optionKey); - var order = new Order(Instant.now(), event, option, price, quantity); + if (option == null) { + return Result.error("Unknown option: " + optionKey); + } + String normalizedSide = ("SELL".equalsIgnoreCase(side)) ? "SELL" : "BUY"; + var order = new Order(Instant.now(), event, option, normalizedSide, price, quantity, userName); var riskResult = risk.check(user, order); if (!riskResult.isSuccess()) return Result.error(riskResult.getMessage()); @@ -59,28 +69,38 @@ public final class LocalGuessMarketContext implements GuessMarketContext { var totalSharesCost = trades.stream() - .map(t -> t.sharesCost()) + .map(Trade::sharesCost) .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); var totalCommission = trades.stream() - .map(t -> t.commission()) + .map(Trade::commission) .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); var totalPaid = trades.stream() - .map(t -> t.totalPaid()) + .map(Trade::totalPaid) .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); return Result.ok( new PurchaseReceiptDTO( trades.stream() .map( - t -> - new TradeRowDTO( - InstantOptions.humanize(t.at()), - t.buyerUserName(), - t.marketName(), - String.valueOf(t.quantity()), - t.sharesCost().toPlainString())) + t -> { + String traderUser = t.buyerUserName(); + if ((traderUser == null + || traderUser.isBlank() + || (event.getMarketMaker() != null + && event.getMarketMaker().equalsIgnoreCase(traderUser))) + && t.sellerUserName() != null + && !t.sellerUserName().isBlank()) { + traderUser = t.sellerUserName(); + } + return new TradeRowDTO( + InstantOptions.humanize(t.at()), + traderUser, + t.marketName(), + String.valueOf(t.quantity()), + t.sharesCost().toPlainString()); + }) .toList(), totalSharesCost.toPlainString(), totalCommission.toPlainString(), @@ -88,6 +108,12 @@ public final class LocalGuessMarketContext implements GuessMarketContext { event.toMarketState())); } + @Override + public Result placeOrder( + String userName, String eventKey, String optionKey, BigDecimal price, long quantity) { + return placeOrder(userName, eventKey, optionKey, "BUY", price, quantity); + } + @Override public Result settleEvent( String userName, String eventKey, String winningOptionKey) { @@ -99,12 +125,7 @@ public final class LocalGuessMarketContext implements GuessMarketContext { if (event.canSettle(userName)) { settlement.settle(event, winningOptionKey); - return Result.ok( - new EventDetailDTO( - event.toEventSummary(), - event.toMarketState(), - event.getHistory(), - event.getWinningOptionName())); + return Result.ok(event.toEventDetail()); } 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 @@ package market.guess.service.catalog; +import java.math.BigDecimal; import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; import market.guess.api.CatalogContext; import market.guess.api.LoadResult; import market.guess.api.Result; +import market.guess.model.event.CommissionTiming; +import market.guess.model.event.CreateEventRequest; import market.guess.model.event.EventDetailDTO; +import market.guess.model.event.EventStatus; import market.guess.model.event.EventSummaryDTO; +import market.guess.model.event.MechanismType; +import market.guess.model.ledger.LedgerType; import market.guess.service.catalog.infrastructure.MarketContext; import market.guess.service.catalog.infrastructure.provider.Loader; import market.guess.service.catalog.infrastructure.repository.EventRepository; import market.guess.service.domain.Event; +import market.guess.service.domain.Market; +import market.guess.service.domain.User; +import market.guess.service.ledger.LedgerEntry; +import market.guess.service.mechanism.LmsrTradingMechanism; +import market.guess.service.mechanism.OrderBookTradingMechanism; +import market.guess.service.mechanism.TradingMechanism; public final class LocalCatalogContext implements CatalogContext { private final MarketContext context; @@ -41,12 +56,7 @@ public final class LocalCatalogContext implements CatalogContext { if (optional.isEmpty()) return Result.error("Unable to find event."); var event = optional.get(); - return Result.ok( - new EventDetailDTO( - event.toEventSummary(), - event.toMarketState(), - event.getHistory(), - event.getWinningOptionName())); + return Result.ok(event.toEventDetail()); } @Override @@ -68,4 +78,99 @@ public final class LocalCatalogContext implements CatalogContext { } return Result.ok(new LoadResult(path.toString(), context.getEvents().getAll().size())); } + + @Override + public Result openEvent(String eventKey) { + var optional = events.get(eventKey); + if (optional.isEmpty()) { + return Result.error("Unable to find event."); + } + var event = optional.get(); + if (event.getState() != EventStatus.DRAFT) { + return Result.error("Event is not in DRAFT status."); + } + var mmName = event.getMarketMaker(); + var mmOpt = + (mmName != null && !mmName.isEmpty()) + ? context.getUsers().get(mmName) + : Optional.empty(); + + if (mmOpt.isPresent()) { + var mm = mmOpt.get(); + BigDecimal required = BigDecimal.ZERO; + if (event.getMechanism() instanceof LmsrTradingMechanism lmsr) { + required = lmsr.maxLoss(); + } else if (event.getMechanism() instanceof OrderBookTradingMechanism ob) { + required = BigDecimal.valueOf((long) ob.getInitialCollateral() * ob.getD()); + } + if (mm.getLedger().getBalance().compareTo(required) < 0) { + return Result.error("Market maker has insufficient funds to open event."); + } + if (required.signum() > 0) { + mm.getLedger() + .record( + new LedgerEntry( + 0, + Instant.now(), + LedgerType.SUBSIDY, + required.negate(), + mm.getLedger().getBalance().subtract(required), + "Market maker subsidy for event #" + event.getDisplayId())); + event + .getLedger() + .record( + new LedgerEntry( + 0, + Instant.now(), + LedgerType.SUBSIDY, + required, + event.getLedger().getBalance().add(required), + "Market maker subsidy (max loss)")); + event.addCashFlow(mmName, required.negate()); + } + } + event.setState(EventStatus.ACTIVE); + return getEvent(eventKey); + } + + @Override + public Result createEvent(CreateEventRequest request) { + if (request == null || request.name() == null || request.name().isBlank()) { + return Result.error("Event name cannot be empty."); + } + int newId = events.getAll().stream().mapToInt(Event::getDisplayId).max().orElse(0) + 1; + String key = String.valueOf(newId); + var optionsList = + request.options() != null && !request.options().isEmpty() + ? request.options() + : List.of("YES", "NO"); + var markets = new ArrayList(); + for (int i = 0; i < optionsList.size(); i++) { + markets.add(new Market(key + ":" + i, optionsList.get(i))); + } + TradingMechanism mechanism; + if (request.mechanism() == MechanismType.LMSR) { + int b = request.liquidityB() != null ? request.liquidityB() : 100; + mechanism = new LmsrTradingMechanism(b, markets.size()); + } else { + int d = request.baseValueD() != null ? request.baseValueD().intValue() : 1; + mechanism = new OrderBookTradingMechanism(request.allowMinting(), 100, d, markets.size()); + } + var event = + new Event( + key, + newId, + request.name(), + request.description() != null ? request.description() : "", + request.commissionPercent(), + request.commissionTiming() != null + ? request.commissionTiming() + : CommissionTiming.ON_PURCHASE, + mechanism, + EventStatus.DRAFT, + markets, + request.mmUserName() != null ? request.mmUserName() : ""); + events.add(event); + return getEvent(key); + } } 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; import java.math.BigDecimal; import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.TreeMap; import market.guess.model.event.CommissionTiming; +import market.guess.model.event.EventDetailDTO; import market.guess.model.event.EventStatus; import market.guess.model.event.EventSummaryDTO; +import market.guess.model.event.MechanismType; +import market.guess.model.event.ParticipantDTO; import market.guess.model.ledger.LedgerType; import market.guess.model.market.MarketDTO; import market.guess.model.market.MarketStateDTO; +import market.guess.model.market.OrderBookDTO; import market.guess.model.market.TradeRowDTO; import market.guess.service.helpers.BigDecimalOptions; import market.guess.service.helpers.InstantOptions; import market.guess.service.ledger.Ledger; import market.guess.service.ledger.LedgerEntry; +import market.guess.service.mechanism.OrderBookTradingMechanism; import market.guess.service.mechanism.TradingMechanism; public final class Event { @@ -34,6 +42,7 @@ public final class Event { private EventStatus state; private String winningOptionKey; private BigDecimal settlementCommission = BigDecimalOptions.ZERO_MONEY; + private final Map cashFlow = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); public Event( String eventKey, @@ -57,7 +66,7 @@ public final class Event { this.marketMaker = marketMaker; this.ledger = new Ledger(eventKey, BigDecimalOptions.ZERO_MONEY); var seed = mechanism.maxLoss(); - if (seed.signum() > 0) { + if (seed.signum() > 0 && status != EventStatus.DRAFT) { ledger.record( new LedgerEntry( 0, Instant.now(), LedgerType.SUBSIDY, seed, seed, "Market maker subsidy (max loss)")); @@ -65,7 +74,6 @@ public final class Event { this.state = status; } - /** Reconstructs an event with its full accumulated state, e.g. from a saved snapshot. */ public Event( String eventKey, int displayId, @@ -174,9 +182,28 @@ public final class Event { long quantity, BigDecimal cost, BigDecimal commission) { + return recordTrade(time, user, option, quantity, cost, commission, null); + } + + public Trade recordTrade( + Instant time, + String user, + Market option, + long quantity, + BigDecimal cost, + BigDecimal commission, + String seller) { var trade = new Trade( - runningId++, time, user, option.getKey(), option.getName(), quantity, cost, commission); + runningId++, + time, + user, + option.getKey(), + option.getName(), + quantity, + cost, + commission, + seller); trades.add(trade); return trade; @@ -199,10 +226,18 @@ public final class Event { public List getHistory() { var history = new ArrayList(); for (var trade : trades.reversed()) { + String user = trade.buyerUserName(); + if ((user == null + || user.isBlank() + || (marketMaker != null && marketMaker.equalsIgnoreCase(user))) + && trade.sellerUserName() != null + && !trade.sellerUserName().isBlank()) { + user = trade.sellerUserName(); + } history.add( new TradeRowDTO( InstantOptions.humanize(trade.at()), - trade.buyerUserName(), + user, trade.marketName(), String.valueOf(trade.quantity()), trade.sharesCost().toPlainString())); @@ -221,7 +256,8 @@ public final class Event { mechanism.getType(), state, getOptionsNames(), - ledger.getBalance().toPlainString()); + ledger.getBalance().toPlainString(), + marketMaker); } public MarketStateDTO toMarketState() { @@ -241,14 +277,24 @@ public final class Event { public Market getOption(String optionKey) { return markets.stream() - .filter(option -> option.getKey().equalsIgnoreCase(optionKey)) + .filter( + option -> + option.getKey().equalsIgnoreCase(optionKey) + || option.getName().equalsIgnoreCase(optionKey) + || ("YES".equalsIgnoreCase(optionKey) + && !markets.isEmpty() + && option == markets.get(0)) + || ("NO".equalsIgnoreCase(optionKey) + && markets.size() > 1 + && option == markets.get(1))) .findFirst() - .orElseThrow(() -> new IllegalArgumentException("No such option.")); + .orElseThrow(() -> new IllegalArgumentException("No such option: " + optionKey)); } public boolean canSettle(String userName) { if (state != EventStatus.ACTIVE) return false; - if (marketMaker.equalsIgnoreCase(userName)) return true; + if (marketMaker == null || marketMaker.isEmpty() || marketMaker.equalsIgnoreCase(userName)) + return true; return false; } @@ -256,4 +302,125 @@ public final class Event { public void addSettlementCommission(BigDecimal amount) { this.settlementCommission = settlementCommission.add(amount); } + + public void addCashFlow(String userName, BigDecimal amount) { + if (userName == null || userName.isBlank()) return; + cashFlow.merge(userName, amount, BigDecimal::add); + } + + public EventDetailDTO toEventDetail() { + List obDtos = List.of(); + if (mechanism instanceof OrderBookTradingMechanism ob) { + obDtos = ob.toOrderBookDTOs(markets); + } + List pDtos = getParticipants(); + return new EventDetailDTO( + toEventSummary(), toMarketState(), getHistory(), getWinningOptionName(), obDtos, pDtos); + } + + public List getParticipants() { + var userSet = new LinkedHashSet(); + if (marketMaker != null && !marketMaker.isBlank()) { + userSet.add(marketMaker); + } + for (var t : trades) { + if (t.buyerUserName() != null && !t.buyerUserName().isBlank()) { + userSet.add(t.buyerUserName()); + } + if (t.sellerUserName() != null && !t.sellerUserName().isBlank()) { + userSet.add(t.sellerUserName()); + } + } + if (mechanism instanceof OrderBookTradingMechanism ob) { + for (var book : ob.getAsks()) { + for (var o : book) { + if (o.getUser() != null && !o.getUser().isBlank()) userSet.add(o.getUser()); + } + } + for (var book : ob.getBids()) { + for (var o : book) { + if (o.getUser() != null && !o.getUser().isBlank()) userSet.add(o.getUser()); + } + } + } + + var prices = mechanism.prices(); + BigDecimal p0 = (prices != null && prices.length > 0) ? prices[0] : BigDecimal.valueOf(0.50); + BigDecimal p1 = (prices != null && prices.length > 1) ? prices[1] : BigDecimal.valueOf(0.50); + + String opt0Key = !markets.isEmpty() ? markets.get(0).getKey() : "0"; + String opt1Key = markets.size() > 1 ? markets.get(1).getKey() : "1"; + + var list = new ArrayList(); + for (var u : userSet) { + boolean isMm = (marketMaker != null && marketMaker.equalsIgnoreCase(u)); + String tag = isMm ? "MM" : ""; + int yes = (int) getSharesHeldBy(u, opt0Key); + int no = (int) getSharesHeldBy(u, opt1Key); + + BigDecimal val; + if (state == EventStatus.SETTLED) { + val = BigDecimal.ZERO; + } else if (isMm && mechanism.getType() == MechanismType.LMSR && yes == 0 && no == 0) { + val = ledger.getBalance(); + } else { + val = p0.multiply(BigDecimal.valueOf(yes)).add(p1.multiply(BigDecimal.valueOf(no))); + } + + BigDecimal userFees = + trades.stream() + .filter(t -> u.equalsIgnoreCase(t.buyerUserName())) + .map(Trade::commission) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + + String valStr = "$" + BigDecimalOptions.toMoney(val).toPlainString(); + String feeStr = "$" + BigDecimalOptions.toMoney(userFees).toPlainString(); + + String pnlStr = + "$" + + BigDecimalOptions.toMoney(cashFlow.getOrDefault(u, BigDecimal.ZERO)) + .toPlainString(); + + list.add(new ParticipantDTO(u, tag, yes, no, valStr, feeStr, pnlStr)); + } + return list; + } + + public long getSharesHeldBy(String userName, String optionKey) { + if (userName == null || optionKey == null) return 0; + String optKey = optionKey; + try { + var opt = getOption(optionKey); + if (opt != null) optKey = opt.getKey(); + } catch (Exception ignored) { + } + final String targetKey = optKey; + long bought = + trades.stream() + .filter( + t -> + (t.marketKey().equalsIgnoreCase(targetKey) + || t.marketName().equalsIgnoreCase(optionKey)) + && userName.equalsIgnoreCase(t.buyerUserName())) + .mapToLong(Trade::quantity) + .sum(); + long sold = + trades.stream() + .filter( + t -> + (t.marketKey().equalsIgnoreCase(targetKey) + || t.marketName().equalsIgnoreCase(optionKey)) + && userName.equalsIgnoreCase(t.sellerUserName())) + .mapToLong(Trade::quantity) + .sum(); + long initialHeld = 0; + if (state != EventStatus.DRAFT + && marketMaker != null + && marketMaker.equalsIgnoreCase(userName)) { + if (mechanism instanceof OrderBookTradingMechanism ob) { + initialHeld = ob.getInitialCollateral(); + } + } + return Math.max(0, initialHeld + bought - sold); + } } 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; import java.util.ArrayList; import java.util.List; import market.guess.model.event.CommissionTiming; +import market.guess.model.event.MechanismType; import market.guess.model.ledger.LedgerType; import market.guess.service.domain.Order; import market.guess.service.domain.Trade; @@ -23,32 +24,74 @@ public final class LocalFulfillmentContext implements FulfillmentContext { var event = order.event(); var chargeNow = event.getCommissionTiming() == CommissionTiming.ON_PURCHASE; var rate = BigDecimal.valueOf(event.getCommissionPercent()).divide(BigDecimal.valueOf(100)); + boolean isLmsr = event.getMechanism().getType() == MechanismType.LMSR; + boolean isSell = "SELL".equalsIgnoreCase(order.side()); + var marketMaker = event.getMarketMaker(); + boolean feesToMarketMaker = !isLmsr && marketMaker != null && !marketMaker.isBlank(); var recorded = new ArrayList(); for (var raw : rawTrades) { + var market = event.getOption(raw.marketKey()); + boolean merge = !isLmsr && isSell && raw.buyerUserName() == null; + String buyer = + merge ? null : raw.buyerUserName() != null ? raw.buyerUserName() : user.getName(); + String seller = raw.sellerUserName(); var commission = - chargeNow + chargeNow && !merge ? BigDecimalOptions.toMoney(raw.sharesCost().multiply(rate)) : BigDecimalOptions.ZERO_MONEY; + var trade = event.recordTrade( - order.at(), - user.getName(), - order.market(), - raw.quantity(), - raw.sharesCost(), - commission); - ledger.debit( - user.getName(), - LedgerType.PURCHASE, - trade.totalPaid(), - "Purchase: " + order.market().getName()); - ledger.credit( - event.getLedger(), - LedgerType.PURCHASE, - trade.totalPaid(), - "Purchase: " + order.market().getName()); - order.market().addVolume(trade.quantity()); + order.at(), buyer, market, raw.quantity(), raw.sharesCost(), commission, seller); + + if (isLmsr && isSell) { + ledger.debit( + event.getLedger(), + LedgerType.PAYOUT, + trade.sharesCost(), + "Refund: " + market.getName()); + ledger.credit( + user.getName(), LedgerType.PAYOUT, trade.sharesCost(), "Sale: " + market.getName()); + event.addCashFlow(user.getName(), trade.sharesCost()); + market.addVolume(-trade.quantity()); + } else if (merge) { + ledger.debit( + event.getLedger(), LedgerType.PAYOUT, trade.sharesCost(), "Merge: " + market.getName()); + ledger.credit(seller, LedgerType.PAYOUT, trade.sharesCost(), "Merge: " + market.getName()); + event.addCashFlow(seller, trade.sharesCost()); + } else { + ledger.debit( + buyer, LedgerType.PURCHASE, trade.totalPaid(), "Purchase: " + market.getName()); + event.addCashFlow(buyer, trade.totalPaid().negate()); + + if (seller != null && !seller.isBlank()) { + ledger.credit(seller, LedgerType.PAYOUT, trade.sharesCost(), "Sale: " + market.getName()); + event.addCashFlow(seller, trade.sharesCost()); + } else { + ledger.credit( + event.getLedger(), + LedgerType.PURCHASE, + trade.sharesCost(), + "Purchase: " + market.getName()); + } + + if (commission.signum() > 0) { + if (feesToMarketMaker) { + ledger.credit( + marketMaker, LedgerType.COMMISSION, commission, "Commission: " + market.getName()); + event.addCashFlow(marketMaker, commission); + } else { + ledger.credit( + event.getLedger(), + LedgerType.COMMISSION, + commission, + "Commission: " + market.getName()); + } + } + + market.addVolume(trade.quantity()); + } recorded.add(trade); } 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 @@ +package market.guess.service.ledger; + +import java.util.ArrayList; +import java.util.List; +import market.guess.api.AccountContext; +import market.guess.api.Result; +import market.guess.model.ledger.LedgerDTO; +import market.guess.model.ledger.LedgerEntryDTO; +import market.guess.service.catalog.infrastructure.repository.UserRepository; +import market.guess.service.domain.User; +import market.guess.service.helpers.InstantOptions; + +public final class LocalAccountContext implements AccountContext { + private final UserRepository users; + + public LocalAccountContext(UserRepository users) { + this.users = users; + } + + @Override + public Result> getAllAccounts() { + var list = users.getAll().stream().map(this::toDTO).toList(); + return Result.ok(list); + } + + @Override + public Result getAccount(String owner) { + var opt = users.get(owner); + if (opt.isEmpty()) { + return Result.error("Account not found: " + owner); + } + return Result.ok(toDTO(opt.get())); + } + + private LedgerDTO toDTO(User user) { + var ledger = user.getLedger(); + var entries = new ArrayList(); + for (var entry : ledger.getEntries()) { + entries.add( + new LedgerEntryDTO( + entry.id(), + InstantOptions.humanize(entry.time()), + entry.type(), + entry.amount().toPlainString(), + entry.balanceAfter().toPlainString(), + entry.note())); + } + boolean blocked = ledger.getBalance().signum() < 0; + return new LedgerDTO(user.getName(), ledger.getBalance().toPlainString(), blocked, entries); + } +} 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 @@ -62,6 +62,44 @@ public final class LmsrTradingMechanism implements TradingMechanism { null)); } + @Override + public List placeOrder( + Event event, + Market market, + String user, + String side, + Instant at, + long quantity, + BigDecimal price) { + if ("SELL".equalsIgnoreCase(side)) { + return sell(event, market, user, at, quantity); + } + return buy(event, market, at, quantity); + } + + public List sell(Event event, Market market, String user, Instant at, long quantity) { + var optionIndex = event.getMarkets().indexOf(market); + var before = cost(q); + + var after = q.clone(); + after[optionIndex] -= quantity; + var delta = before - cost(after); + + q[optionIndex] -= quantity; + + return List.of( + new Trade( + 0, + at, + event.getMarketMaker(), + market.getKey(), + market.getName(), + quantity, + BigDecimalOptions.toMoney(delta), + BigDecimal.ZERO, + user)); + } + @Override public BigDecimal maxLoss() { return BigDecimalOptions.toMoney(cost(new int[q.length])); @@ -94,4 +132,9 @@ public final class LmsrTradingMechanism implements TradingMechanism { } return liquidity * (max + Math.log(sumExp)); } + + @Override + public boolean cancel() { + return false; + } } 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; import java.util.ArrayList; import java.util.List; import market.guess.model.event.MechanismType; +import market.guess.model.market.BookOrderDTO; +import market.guess.model.market.OrderBookDTO; import market.guess.service.domain.Event; import market.guess.service.domain.Market; import market.guess.service.domain.Trade; @@ -17,23 +19,41 @@ public final class OrderBookTradingMechanism implements TradingMechanism { SELL } - private static final class RestingOrder { + public static final class RestingOrder { + private final String user; private final BigDecimal price; private long quantity; + private final Side side; - private RestingOrder(BigDecimal price, long quantity) { + public RestingOrder(String user, BigDecimal price, long quantity, Side side) { + this.user = user != null ? user : "Unknown"; this.price = price; this.quantity = quantity; + this.side = side; + } + + public String getUser() { + return user; + } + + public BigDecimal getPrice() { + return price; + } + + public long getQuantity() { + return quantity; + } + + public Side getSide() { + return side; } } private final boolean allowMint; private final int initial; private final int d; - private final List> - asks; // per option, ascending price (best = lowest, index 0) - private final List> - bids; // per option, descending price (best = highest, index 0) + private final List> asks; // per option, ascending price (best = lowest, index 0) + private final List> bids; // per option, descending price (best = highest, index 0) private final BigDecimal[] lastTradePrice; public OrderBookTradingMechanism(boolean allowMint, int initial, int d, int optionCount) { @@ -58,16 +78,43 @@ public final class OrderBookTradingMechanism implements TradingMechanism { @Override public List buy(Event event, Market market, Instant at, long quantity) { - var optionIndex = event.getMarkets().indexOf(market); - return List.of(match(optionIndex, Side.BUY, at, quantity, null, false)); + return placeOrder(event, market, null, "BUY", at, quantity, null); } - /** Priced limit order — not on the shared interface, no engine call site yet. */ - public Trade placeOrder(int optionIndex, Side side, Instant at, long quantity, BigDecimal price) { - if (price.signum() < 0 || price.compareTo(BigDecimal.valueOf(d)) > 0) { - throw new IllegalArgumentException("Price must be between 0 and " + d); + @Override + public synchronized List placeOrder( + Event event, + Market market, + String user, + String side, + Instant at, + long quantity, + BigDecimal price) { + var optionIndex = event != null && event.getMarkets() != null ? event.getMarkets().indexOf(market) : 0; + if (optionIndex < 0) { + optionIndex = 0; + } + Side s = "SELL".equalsIgnoreCase(side) ? Side.SELL : Side.BUY; + if (quantity <= 0) { + throw new IllegalArgumentException("Quantity must be positive"); + } + if (price == null) { + price = priceFor(optionIndex); + } + var error = priceError(price); + if (error != null) { + throw new IllegalArgumentException(error); } - return match(optionIndex, side, at, quantity, price, true); + return match(optionIndex, s, user, at, quantity, price, event, market); + } + + /** A share pays out at most d, so a price of 0 or d (or beyond) is never a real trade. */ + public String priceError(BigDecimal price) { + if (price == null || (price.signum() > 0 && price.compareTo(BigDecimal.valueOf(d)) < 0)) { + return null; + } + var max = BigDecimal.valueOf(d).subtract(new BigDecimal("0.01")); + return "Price must be between $0.01 and $" + BigDecimalOptions.toMoney(max).toPlainString() + "."; } @Override @@ -89,7 +136,7 @@ public final class OrderBookTradingMechanism implements TradingMechanism { if (bestAsk != null) return BigDecimalOptions.toMoney(bestAsk); if (lastTradePrice[optionIndex] != null) return lastTradePrice[optionIndex]; - // No information yet: neutral 50/50 prior, same as LMSR's day-one price. + // Neutral 50/50 prior return BigDecimalOptions.toMoney(d / 2.0); } @@ -97,52 +144,158 @@ public final class OrderBookTradingMechanism implements TradingMechanism { return book.isEmpty() ? null : book.get(0).price; } - private Trade match( + public synchronized BigDecimal getLastTradePrice(int optionIndex) { + return lastTradePrice[optionIndex]; + } + + /** Shares this user already promised in resting asks on one option. */ + public synchronized long restingAskQuantity(String user, int optionIndex) { + return asks.get(optionIndex).stream() + .filter(o -> o.user.equalsIgnoreCase(user)) + .mapToLong(o -> o.quantity) + .sum(); + } + + /** Cash this user already promised in resting bids on every option, before commission. */ + public synchronized BigDecimal restingBidValue(String user) { + return bids.stream() + .flatMap(List::stream) + .filter(o -> o.user.equalsIgnoreCase(user)) + .map(o -> o.price.multiply(BigDecimal.valueOf(o.quantity))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private List match( int optionIndex, Side side, + String user, Instant at, long quantity, BigDecimal limitPrice, - boolean restRemainder) { - var opposing = side == Side.BUY ? asks.get(optionIndex) : bids.get(optionIndex); + Event event, + Market market) { + var direct = side == Side.BUY ? asks.get(optionIndex) : bids.get(optionIndex); + // In a binary market the other option's book is a counterparty too, since a YES and a NO + // together are worth exactly d: a bid there can mint a new pair with a buy, and an ask there + // can merge a pair back into the pool with a sell. + // ponytail: binary only; N-option markets would need an order on every other option. + int other = 1 - optionIndex; + var complement = + allowMint && asks.size() == 2 + ? (side == Side.BUY ? bids.get(other) : asks.get(other)) + : List.of(); + var dValue = BigDecimal.valueOf(d); + var trades = new ArrayList(); - var filled = 0; - var proceeds = BigDecimal.ZERO; - while (filled < quantity - && !opposing.isEmpty() - && crosses(side, opposing.get(0).price, limitPrice)) { - var top = opposing.get(0); - var take = Math.min(quantity - filled, top.quantity); + long filled = 0; + while (filled < quantity) { + int di = firstCounterparty(direct, user); + int ci = firstCounterparty(complement, user); + var directPrice = di < 0 ? null : direct.get(di).price; + var pairPrice = ci < 0 ? null : dValue.subtract(complement.get(ci).price); + boolean useDirect = directPrice != null && crosses(side, directPrice, limitPrice); + boolean usePair = pairPrice != null && crosses(side, pairPrice, limitPrice); + if (useDirect && usePair) { + // Better price for the taker first; a plain resale wins ties. + int cmp = directPrice.compareTo(pairPrice); + useDirect = side == Side.BUY ? cmp <= 0 : cmp >= 0; + usePair = !useDirect; + } + if (!useDirect && !usePair) break; + var book = useDirect ? direct : complement; + int index = useDirect ? di : ci; + var top = book.get(index); + long take = Math.min(quantity - filled, top.quantity); filled += take; - proceeds = proceeds.add(top.price.multiply(BigDecimal.valueOf(take))); top.quantity -= take; - if (top.quantity == 0) opposing.remove(0); - } + if (top.quantity == 0) book.remove(index); - if (filled > 0) { - lastTradePrice[optionIndex] = - BigDecimalOptions.toMoney( - proceeds.divide(BigDecimal.valueOf(filled), 10, RoundingMode.HALF_EVEN)); + if (useDirect) { + String buyer = side == Side.BUY ? user : top.user; + String seller = side == Side.BUY ? top.user : user; + trades.add(trade(at, event, market, optionIndex, buyer, take, top.price, seller)); + lastTradePrice[optionIndex] = top.price; + continue; + } + // The resting price is honored; the taker gets the rest of d. + if (side == Side.BUY) { + // Mint: no seller, both payments go into the pool as collateral. + trades.add(trade(at, event, market, optionIndex, user, take, pairPrice, null)); + trades.add(trade(at, event, null, other, top.user, take, top.price, null)); + } else { + // Merge: no buyer, the pool pays both sellers out of the pair's collateral. + trades.add(trade(at, event, market, optionIndex, null, take, pairPrice, user)); + trades.add(trade(at, event, null, other, null, take, top.price, top.user)); + } + lastTradePrice[optionIndex] = BigDecimalOptions.toMoney(pairPrice); + lastTradePrice[other] = top.price; } - if (restRemainder && filled < quantity) { + + if (filled < quantity) { var restingBook = side == Side.BUY ? bids.get(optionIndex) : asks.get(optionIndex); restingBook.add( insertionIndex(restingBook, side, limitPrice), - new RestingOrder(limitPrice, quantity - filled)); + new RestingOrder(user, limitPrice, quantity - filled, side)); + } + + return trades; + } + + /** Self-trade prevention: a user's own resting orders are skipped, never matched. */ + private static int firstCounterparty(List book, String user) { + for (var i = 0; i < book.size(); i++) { + if (user == null || !book.get(i).user.equalsIgnoreCase(user)) return i; } + return -1; + } + private static Trade trade( + Instant at, + Event event, + Market market, + int optionIndex, + String buyer, + long qty, + BigDecimal unitPrice, + String seller) { + if (market == null + && event != null + && event.getMarkets() != null + && optionIndex < event.getMarkets().size()) { + market = event.getMarkets().get(optionIndex); + } return new Trade( - filled, at, null, null, null, filled, BigDecimalOptions.toMoney(proceeds), proceeds); + 0, + at, + buyer, + market != null ? market.getKey() : String.valueOf(optionIndex), + market != null ? market.getName() : "Option " + optionIndex, + qty, + BigDecimalOptions.toMoney(unitPrice.multiply(BigDecimal.valueOf(qty))), + BigDecimal.ZERO, + seller); } private static boolean crosses(Side side, BigDecimal restingPrice, BigDecimal limitPrice) { - if (limitPrice == null) return true; // market order: any price crosses + if (limitPrice == null) return true; return side == Side.BUY ? restingPrice.compareTo(limitPrice) <= 0 : restingPrice.compareTo(limitPrice) >= 0; } + public boolean canMint() { + return this.allowMint; + } + + public int getInitialCollateral() { + return this.initial; + } + + public int getD() { + return this.d; + } + private static int insertionIndex(List book, Side side, BigDecimal price) { var i = 0; while (i < book.size() && betterOrEqual(side, book.get(i).price, price)) i++; @@ -151,9 +304,53 @@ public final class OrderBookTradingMechanism implements TradingMechanism { private static boolean betterOrEqual(Side side, BigDecimal existing, BigDecimal incoming) { return side == Side.BUY - ? existing.compareTo(incoming) - >= 0 // bids: descending, ties keep earlier order first (FIFO) - : existing.compareTo(incoming) - <= 0; // asks: ascending, ties keep earlier order first (FIFO) + ? existing.compareTo(incoming) >= 0 // bids: descending, FIFO for ties + : existing.compareTo(incoming) <= 0; // asks: ascending, FIFO for ties + } + + public List toOrderBookDTOs(List markets) { + var dtos = new ArrayList(); + for (int i = 0; i < asks.size(); i++) { + String key = (markets != null && i < markets.size()) ? markets.get(i).getKey() : String.valueOf(i); + String name = (markets != null && i < markets.size()) ? markets.get(i).getName() : "Option " + i; + + var bestBid = bestPrice(bids.get(i)); + var bestAsk = bestPrice(asks.get(i)); + String bidStr = bestBid != null ? "$" + bestBid.toPlainString() : null; + String askStr = bestAsk != null ? "$" + bestAsk.toPlainString() : null; + String spreadStr = (bestBid != null && bestAsk != null) + ? "$" + BigDecimalOptions.toMoney(bestAsk.subtract(bestBid)).toPlainString() + : null; + + var rows = new ArrayList(); + for (var o : asks.get(i)) { + rows.add(new BookOrderDTO("ASK", o.getUser(), o.getQuantity(), "$" + o.getPrice().toPlainString(), false)); + } + for (var o : bids.get(i)) { + rows.add(new BookOrderDTO("BID", o.getUser(), o.getQuantity(), "$" + o.getPrice().toPlainString(), true)); + } + + dtos.add(new OrderBookDTO(key, name, bidStr, askStr, spreadStr, rows)); + } + return dtos; + } + + public List> getAsks() { + return asks; + } + + public List> getBids() { + return bids; + } + + @Override + public boolean cancel() { + return true; + } + + /** At resolution every unmatched order is simply dropped. */ + public synchronized void cancelAll() { + asks.forEach(List::clear); + bids.forEach(List::clear); } } 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 List buy(Event event, Market market, Instant at, long quantity); + default List placeOrder( + Event event, + Market market, + String user, + String side, + Instant at, + long quantity, + BigDecimal price) { + return buy(event, market, at, quantity); + } + BigDecimal[] prices(); - /** The subsidy needed to cover the market maker's worst-case loss, if any. */ default BigDecimal maxLoss() { return BigDecimalOptions.ZERO_MONEY; } + + boolean cancel(); } 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 @@ package market.guess.service.risk; import java.math.BigDecimal; +import java.util.List; import market.guess.api.Result; +import market.guess.model.event.CommissionTiming; +import market.guess.model.event.EventStatus; +import market.guess.service.catalog.infrastructure.repository.EventRepository; +import market.guess.service.domain.Event; import market.guess.service.domain.Order; import market.guess.service.domain.User; +import market.guess.service.mechanism.OrderBookTradingMechanism; public final class LocalRiskEngine implements RiskEngine { + private final EventRepository events; + + public LocalRiskEngine() { + this(null); + } + + public LocalRiskEngine(EventRepository events) { + this.events = events; + } @Override public Result check(User user, Order order) { - var cost = order.price().multiply(BigDecimal.valueOf(order.quantity())); - if (user.getLedger().getBalance().compareTo(cost) < 0) { - return Result.error("Insufficient balance."); + if (user.isBlocked()) { + return Result.error("Account is blocked."); + } + if (order.event().getState() != EventStatus.ACTIVE) { + return Result.error("Event is not ACTIVE."); + } + if (order.quantity() <= 0) { + return Result.error("Quantity must be positive."); + } + var book = order.event().getMechanism() instanceof OrderBookTradingMechanism ob ? ob : null; + if (book != null) { + var error = book.priceError(order.price()); + if (error != null) return Result.error(error); + } + + if ("SELL".equalsIgnoreCase(order.side())) { + long held = order.event().getSharesHeldBy(user.getName(), order.market().getKey()); + if (book != null) { + held -= + book.restingAskQuantity( + user.getName(), order.event().getMarkets().indexOf(order.market())); + } + if (held < order.quantity()) { + return Result.error( + "Insufficient shares to sell. You hold " + + held + + " available shares, but requested to sell " + + order.quantity() + + "."); + } + } else { + BigDecimal unitPrice = order.price() != null ? order.price() : BigDecimal.valueOf(0.50); + var cost = + withCommission(order.event(), unitPrice.multiply(BigDecimal.valueOf(order.quantity()))) + .add(reservedCash(user.getName(), order.event())); + if (user.getLedger().getBalance().compareTo(cost) < 0) { + return Result.error("Insufficient balance."); + } } return Result.ok(); } + + private BigDecimal reservedCash(String userName, Event current) { + Iterable scope = events != null ? events.getAll() : List.of(current); + var total = BigDecimal.ZERO; + for (var event : scope) { + if (event.getState() == EventStatus.ACTIVE + && event.getMechanism() instanceof OrderBookTradingMechanism ob) { + total = total.add(withCommission(event, ob.restingBidValue(userName))); + } + } + return total; + } + + private static BigDecimal withCommission(Event event, BigDecimal value) { + if (event.getCommissionTiming() != CommissionTiming.ON_PURCHASE) return value; + return value.add( + value + .multiply(BigDecimal.valueOf(event.getCommissionPercent())) + .divide(BigDecimal.valueOf(100))); + } } 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 @@ package market.guess.service.settlement; import java.math.BigDecimal; -import java.util.HashMap; +import java.util.TreeSet; import market.guess.model.event.CommissionTiming; import market.guess.model.event.EventStatus; import market.guess.model.ledger.LedgerType; import market.guess.service.domain.Event; import market.guess.service.helpers.BigDecimalOptions; import market.guess.service.ledger.LedgerContext; +import market.guess.service.mechanism.OrderBookTradingMechanism; public final class LocalSettlementContext implements SettlementContext { private final LedgerContext ledger; @@ -20,32 +21,71 @@ public final class LocalSettlementContext implements SettlementContext { public BigDecimal settle(Event event, String winningOptionKey) { event.setWinningOptionKey(winningOptionKey); - // TODO: abstract the commission charge away var chargeCommission = event.getCommissionTiming() == CommissionTiming.ON_CLOSE; var rate = BigDecimal.valueOf(event.getCommissionPercent()).divide(BigDecimal.valueOf(100)); var winningOption = event.getOption(winningOptionKey); - var sharesByUser = new HashMap(); + var book = event.getMechanism() instanceof OrderBookTradingMechanism ob ? ob : null; + if (book != null) { + book.cancelAll(); + } + var perShare = BigDecimal.valueOf(book != null ? book.getD() : 1); + var marketMaker = event.getMarketMaker(); + boolean hasMarketMaker = marketMaker != null && !marketMaker.isBlank(); + boolean feesToMarketMaker = hasMarketMaker; + + // Pay what each user still holds, not what they ever bought: resold shares were paid for + // by someone else, and the market maker's minted shares never appear as a purchase. + var holders = new TreeSet(String.CASE_INSENSITIVE_ORDER); + if (hasMarketMaker) holders.add(marketMaker); for (var trade : event.getTrades()) { - if (!trade.marketKey().equalsIgnoreCase(winningOptionKey)) continue; - sharesByUser.merge(trade.buyerUserName(), trade.quantity(), Long::sum); + if (trade.buyerUserName() != null && !trade.buyerUserName().isBlank()) { + holders.add(trade.buyerUserName()); + } + if (trade.sellerUserName() != null && !trade.sellerUserName().isBlank()) { + holders.add(trade.sellerUserName()); + } } var totalPayout = BigDecimalOptions.ZERO_MONEY; - for (var entry : sharesByUser.entrySet()) { - var gross = BigDecimalOptions.toMoney(BigDecimal.valueOf(entry.getValue())); + for (var holder : holders) { + long shares = event.getSharesHeldBy(holder, winningOption.getKey()); + if (shares <= 0) continue; + + var gross = BigDecimalOptions.toMoney(perShare.multiply(BigDecimal.valueOf(shares))); var commission = chargeCommission ? BigDecimalOptions.toMoney(gross.multiply(rate)) : BigDecimalOptions.ZERO_MONEY; var net = gross.subtract(commission); - ledger.credit(entry.getKey(), LedgerType.PAYOUT, net, "Payout: " + winningOption.getName()); - ledger.debit(event.getLedger(), LedgerType.PAYOUT, net, "Payout: " + winningOption.getName()); + ledger.credit(holder, LedgerType.PAYOUT, net, "Payout: " + winningOption.getName()); + event.addCashFlow(holder, net); + ledger.debit( + event.getLedger(), + LedgerType.PAYOUT, + feesToMarketMaker ? gross : net, + "Payout: " + winningOption.getName()); + if (feesToMarketMaker && commission.signum() > 0) { + ledger.credit( + marketMaker, + LedgerType.COMMISSION, + commission, + "Commission: " + winningOption.getName()); + event.addCashFlow(marketMaker, commission); + } event.addSettlementCommission(commission); totalPayout = totalPayout.add(net); } + // LMSR: whatever the subsidy didn't lose goes back to the market maker who paid it. + var leftover = event.getLedger().getBalance(); + if (book == null && hasMarketMaker && leftover.signum() > 0) { + ledger.debit(event.getLedger(), LedgerType.REFUND, leftover, "Unused subsidy returned"); + ledger.credit(marketMaker, LedgerType.REFUND, leftover, "Unused subsidy: " + event.getName()); + event.addCashFlow(marketMaker, leftover); + } + event.setState(EventStatus.SETTLED); return totalPayout; } -- cgit v1.2.3