diff options
| author | Kostya <mail@sartin.in> | 2026-09-07 16:03:14 +0300 |
|---|---|---|
| committer | Kostya <mail@sartin.in> | 2026-09-07 16:03:14 +0300 |
| commit | 8d7cecd9cf9fb2b0b45607b8efd7ca8f19a89fc6 (patch) | |
| tree | d88cfe032a7e8f632077ab439cfd0618c1e1421f /service/src/main/java | |
| parent | 1cf436ccd3efd45d67749102363b9a7f0d242c54 (diff) | |
| download | guess-market-8d7cecd9cf9fb2b0b45607b8efd7ca8f19a89fc6.tar.gz guess-market-8d7cecd9cf9fb2b0b45607b8efd7ca8f19a89fc6.tar.xz guess-market-8d7cecd9cf9fb2b0b45607b8efd7ca8f19a89fc6.zip | |
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.
Diffstat (limited to 'service/src/main/java')
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; | |||
| 9 | import market.guess.model.market.TradeRowDTO; | 9 | import market.guess.model.market.TradeRowDTO; |
| 10 | import market.guess.service.catalog.infrastructure.MarketContext; | 10 | import market.guess.service.catalog.infrastructure.MarketContext; |
| 11 | import market.guess.service.domain.Order; | 11 | import market.guess.service.domain.Order; |
| 12 | import market.guess.service.domain.Trade; | ||
| 12 | import market.guess.service.fulfillment.FulfillmentContext; | 13 | import market.guess.service.fulfillment.FulfillmentContext; |
| 13 | import market.guess.service.helpers.BigDecimalOptions; | 14 | import market.guess.service.helpers.BigDecimalOptions; |
| 14 | import market.guess.service.helpers.InstantOptions; | 15 | import 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 @@ | |||
| 1 | package market.guess.service.catalog; | 1 | package market.guess.service.catalog; |
| 2 | 2 | ||
| 3 | import java.math.BigDecimal; | ||
| 3 | import java.nio.file.Path; | 4 | import java.nio.file.Path; |
| 5 | import java.time.Instant; | ||
| 6 | import java.util.ArrayList; | ||
| 4 | import java.util.List; | 7 | import java.util.List; |
| 8 | import java.util.Optional; | ||
| 5 | import market.guess.api.CatalogContext; | 9 | import market.guess.api.CatalogContext; |
| 6 | import market.guess.api.LoadResult; | 10 | import market.guess.api.LoadResult; |
| 7 | import market.guess.api.Result; | 11 | import market.guess.api.Result; |
| 12 | import market.guess.model.event.CommissionTiming; | ||
| 13 | import market.guess.model.event.CreateEventRequest; | ||
| 8 | import market.guess.model.event.EventDetailDTO; | 14 | import market.guess.model.event.EventDetailDTO; |
| 15 | import market.guess.model.event.EventStatus; | ||
| 9 | import market.guess.model.event.EventSummaryDTO; | 16 | import market.guess.model.event.EventSummaryDTO; |
| 17 | import market.guess.model.event.MechanismType; | ||
| 18 | import market.guess.model.ledger.LedgerType; | ||
| 10 | import market.guess.service.catalog.infrastructure.MarketContext; | 19 | import market.guess.service.catalog.infrastructure.MarketContext; |
| 11 | import market.guess.service.catalog.infrastructure.provider.Loader; | 20 | import market.guess.service.catalog.infrastructure.provider.Loader; |
| 12 | import market.guess.service.catalog.infrastructure.repository.EventRepository; | 21 | import market.guess.service.catalog.infrastructure.repository.EventRepository; |
| 13 | import market.guess.service.domain.Event; | 22 | import market.guess.service.domain.Event; |
| 23 | import market.guess.service.domain.Market; | ||
| 24 | import market.guess.service.domain.User; | ||
| 25 | import market.guess.service.ledger.LedgerEntry; | ||
| 26 | import market.guess.service.mechanism.LmsrTradingMechanism; | ||
| 27 | import market.guess.service.mechanism.OrderBookTradingMechanism; | ||
| 28 | import market.guess.service.mechanism.TradingMechanism; | ||
| 14 | 29 | ||
| 15 | public final class LocalCatalogContext implements CatalogContext { | 30 | public 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; | |||
| 3 | import java.math.BigDecimal; | 3 | import java.math.BigDecimal; |
| 4 | import java.time.Instant; | 4 | import java.time.Instant; |
| 5 | import java.util.ArrayList; | 5 | import java.util.ArrayList; |
| 6 | import java.util.LinkedHashSet; | ||
| 6 | import java.util.List; | 7 | import java.util.List; |
| 8 | import java.util.Map; | ||
| 9 | import java.util.TreeMap; | ||
| 7 | import market.guess.model.event.CommissionTiming; | 10 | import market.guess.model.event.CommissionTiming; |
| 11 | import market.guess.model.event.EventDetailDTO; | ||
| 8 | import market.guess.model.event.EventStatus; | 12 | import market.guess.model.event.EventStatus; |
| 9 | import market.guess.model.event.EventSummaryDTO; | 13 | import market.guess.model.event.EventSummaryDTO; |
| 14 | import market.guess.model.event.MechanismType; | ||
| 15 | import market.guess.model.event.ParticipantDTO; | ||
| 10 | import market.guess.model.ledger.LedgerType; | 16 | import market.guess.model.ledger.LedgerType; |
| 11 | import market.guess.model.market.MarketDTO; | 17 | import market.guess.model.market.MarketDTO; |
| 12 | import market.guess.model.market.MarketStateDTO; | 18 | import market.guess.model.market.MarketStateDTO; |
| 19 | import market.guess.model.market.OrderBookDTO; | ||
| 13 | import market.guess.model.market.TradeRowDTO; | 20 | import market.guess.model.market.TradeRowDTO; |
| 14 | import market.guess.service.helpers.BigDecimalOptions; | 21 | import market.guess.service.helpers.BigDecimalOptions; |
| 15 | import market.guess.service.helpers.InstantOptions; | 22 | import market.guess.service.helpers.InstantOptions; |
| 16 | import market.guess.service.ledger.Ledger; | 23 | import market.guess.service.ledger.Ledger; |
| 17 | import market.guess.service.ledger.LedgerEntry; | 24 | import market.guess.service.ledger.LedgerEntry; |
| 25 | import market.guess.service.mechanism.OrderBookTradingMechanism; | ||
| 18 | import market.guess.service.mechanism.TradingMechanism; | 26 | import market.guess.service.mechanism.TradingMechanism; |
| 19 | 27 | ||
| 20 | public final class Event { | 28 | public 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; | |||
| 4 | import java.util.ArrayList; | 4 | import java.util.ArrayList; |
| 5 | import java.util.List; | 5 | import java.util.List; |
| 6 | import market.guess.model.event.CommissionTiming; | 6 | import market.guess.model.event.CommissionTiming; |
| 7 | import market.guess.model.event.MechanismType; | ||
| 7 | import market.guess.model.ledger.LedgerType; | 8 | import market.guess.model.ledger.LedgerType; |
| 8 | import market.guess.service.domain.Order; | 9 | import market.guess.service.domain.Order; |
| 9 | import market.guess.service.domain.Trade; | 10 | import 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 @@ | |||
| 1 | package market.guess.service.ledger; | ||
| 2 | |||
| 3 | import java.util.ArrayList; | ||
| 4 | import java.util.List; | ||
| 5 | import market.guess.api.AccountContext; | ||
| 6 | import market.guess.api.Result; | ||
| 7 | import market.guess.model.ledger.LedgerDTO; | ||
| 8 | import market.guess.model.ledger.LedgerEntryDTO; | ||
| 9 | import market.guess.service.catalog.infrastructure.repository.UserRepository; | ||
| 10 | import market.guess.service.domain.User; | ||
| 11 | import market.guess.service.helpers.InstantOptions; | ||
| 12 | |||
| 13 | public 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; | |||
| 6 | import java.util.ArrayList; | 6 | import java.util.ArrayList; |
| 7 | import java.util.List; | 7 | import java.util.List; |
| 8 | import market.guess.model.event.MechanismType; | 8 | import market.guess.model.event.MechanismType; |
| 9 | import market.guess.model.market.BookOrderDTO; | ||
| 10 | import market.guess.model.market.OrderBookDTO; | ||
| 9 | import market.guess.service.domain.Event; | 11 | import market.guess.service.domain.Event; |
| 10 | import market.guess.service.domain.Market; | 12 | import market.guess.service.domain.Market; |
| 11 | import market.guess.service.domain.Trade; | 13 | import 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 @@ | |||
| 1 | package market.guess.service.risk; | 1 | package market.guess.service.risk; |
| 2 | 2 | ||
| 3 | import java.math.BigDecimal; | 3 | import java.math.BigDecimal; |
| 4 | import java.util.List; | ||
| 4 | import market.guess.api.Result; | 5 | import market.guess.api.Result; |
| 6 | import market.guess.model.event.CommissionTiming; | ||
| 7 | import market.guess.model.event.EventStatus; | ||
| 8 | import market.guess.service.catalog.infrastructure.repository.EventRepository; | ||
| 9 | import market.guess.service.domain.Event; | ||
| 5 | import market.guess.service.domain.Order; | 10 | import market.guess.service.domain.Order; |
| 6 | import market.guess.service.domain.User; | 11 | import market.guess.service.domain.User; |
| 12 | import market.guess.service.mechanism.OrderBookTradingMechanism; | ||
| 7 | 13 | ||
| 8 | public final class LocalRiskEngine implements RiskEngine { | 14 | public 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 @@ | |||
| 1 | package market.guess.service.settlement; | 1 | package market.guess.service.settlement; |
| 2 | 2 | ||
| 3 | import java.math.BigDecimal; | 3 | import java.math.BigDecimal; |
| 4 | import java.util.HashMap; | 4 | import java.util.TreeSet; |
| 5 | import market.guess.model.event.CommissionTiming; | 5 | import market.guess.model.event.CommissionTiming; |
| 6 | import market.guess.model.event.EventStatus; | 6 | import market.guess.model.event.EventStatus; |
| 7 | import market.guess.model.ledger.LedgerType; | 7 | import market.guess.model.ledger.LedgerType; |
| 8 | import market.guess.service.domain.Event; | 8 | import market.guess.service.domain.Event; |
| 9 | import market.guess.service.helpers.BigDecimalOptions; | 9 | import market.guess.service.helpers.BigDecimalOptions; |
| 10 | import market.guess.service.ledger.LedgerContext; | 10 | import market.guess.service.ledger.LedgerContext; |
| 11 | import market.guess.service.mechanism.OrderBookTradingMechanism; | ||
| 11 | 12 | ||
| 12 | public final class LocalSettlementContext implements SettlementContext { | 13 | public 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 | } |