diff options
Diffstat (limited to 'service/src/main/java/market')
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 | } |