diff options
Diffstat (limited to 'service/src/test/java')
| -rw-r--r-- | service/src/test/java/market/guess/service/Ex1FullTest.java | 343 |
1 files changed, 343 insertions, 0 deletions
diff --git a/service/src/test/java/market/guess/service/Ex1FullTest.java b/service/src/test/java/market/guess/service/Ex1FullTest.java new file mode 100644 index 0000000..6718142 --- /dev/null +++ b/service/src/test/java/market/guess/service/Ex1FullTest.java | |||
| @@ -0,0 +1,343 @@ | |||
| 1 | package market.guess.service; | ||
| 2 | |||
| 3 | import static org.junit.jupiter.api.Assertions.*; | ||
| 4 | |||
| 5 | import java.math.BigDecimal; | ||
| 6 | import java.nio.file.Files; | ||
| 7 | import java.nio.file.Path; | ||
| 8 | import java.time.Instant; | ||
| 9 | import java.util.List; | ||
| 10 | import market.guess.api.CommissionTiming; | ||
| 11 | import market.guess.model.event.EventStatus; | ||
| 12 | import market.guess.service.catalog.LocalCatalogContext; | ||
| 13 | import market.guess.service.catalog.infrastructure.MarketContext; | ||
| 14 | import market.guess.service.catalog.infrastructure.mapper.v1.EventMapperV1; | ||
| 15 | import market.guess.service.catalog.infrastructure.provider.v1.XMLLoaderV1; | ||
| 16 | import market.guess.service.catalog.infrastructure.provider.v1.XMLValidatorV1; | ||
| 17 | import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; | ||
| 18 | import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; | ||
| 19 | import market.guess.service.domain.Event; | ||
| 20 | import market.guess.service.domain.Market; | ||
| 21 | import market.guess.service.domain.User; | ||
| 22 | import market.guess.service.fulfillment.LocalFulfillmentContext; | ||
| 23 | import market.guess.service.helpers.BigDecimalOptions; | ||
| 24 | import market.guess.service.ledger.LedgerContext; | ||
| 25 | import market.guess.service.matching.LocalMatchingEngine; | ||
| 26 | import market.guess.service.mechanism.LmsrTradingMechanism; | ||
| 27 | import market.guess.service.risk.LocalRiskEngine; | ||
| 28 | import market.guess.service.settlement.LocalSettlementContext; | ||
| 29 | import org.junit.jupiter.api.BeforeEach; | ||
| 30 | import org.junit.jupiter.api.Test; | ||
| 31 | |||
| 32 | public class Ex1FullTest { | ||
| 33 | |||
| 34 | private InMemoryEventRepository eventRepo; | ||
| 35 | private InMemoryUserRepository userRepo; | ||
| 36 | private MarketContext marketContext; | ||
| 37 | private XMLLoaderV1 loader; | ||
| 38 | private XMLValidatorV1 validator; | ||
| 39 | private LocalCatalogContext catalog; | ||
| 40 | private LocalGuessMarketContext guessMarket; | ||
| 41 | private LedgerContext ledgerContext; | ||
| 42 | private LocalSettlementContext settlementContext; | ||
| 43 | |||
| 44 | @BeforeEach | ||
| 45 | public void setUp() { | ||
| 46 | eventRepo = new InMemoryEventRepository(); | ||
| 47 | userRepo = new InMemoryUserRepository(); | ||
| 48 | marketContext = new MarketContext(eventRepo, userRepo); | ||
| 49 | validator = new XMLValidatorV1(); | ||
| 50 | loader = new XMLLoaderV1(eventRepo, userRepo, new EventMapperV1(), validator); | ||
| 51 | catalog = new LocalCatalogContext(loader, marketContext, eventRepo); | ||
| 52 | ledgerContext = new LedgerContext(userRepo, java.time.Clock.systemUTC()); | ||
| 53 | settlementContext = new LocalSettlementContext(ledgerContext); | ||
| 54 | guessMarket = | ||
| 55 | new LocalGuessMarketContext( | ||
| 56 | marketContext, | ||
| 57 | new LocalRiskEngine(), | ||
| 58 | new LocalMatchingEngine(java.time.Clock.systemUTC()), | ||
| 59 | new LocalFulfillmentContext(ledgerContext), | ||
| 60 | settlementContext); | ||
| 61 | } | ||
| 62 | |||
| 63 | @Test | ||
| 64 | public void testLmsrMathCalculations() { | ||
| 65 | var lmsr = new LmsrTradingMechanism(100, 2); | ||
| 66 | |||
| 67 | // Initial prices should be 0.50 and 0.50 | ||
| 68 | var initialPrices = lmsr.prices(); | ||
| 69 | assertEquals(new BigDecimal("0.50"), initialPrices[0]); | ||
| 70 | assertEquals(new BigDecimal("0.50"), initialPrices[1]); | ||
| 71 | |||
| 72 | // Initial max loss / subsidy = 100 * ln(2) = 69.31 | ||
| 73 | assertEquals(new BigDecimal("69.31"), lmsr.maxLoss()); | ||
| 74 | |||
| 75 | // Buy 100 shares of option 0 (YES) | ||
| 76 | var dummyEvent = | ||
| 77 | new Event( | ||
| 78 | "1", | ||
| 79 | 1, | ||
| 80 | "Test Event", | ||
| 81 | "Desc", | ||
| 82 | 0, | ||
| 83 | CommissionTiming.ON_PURCHASE, | ||
| 84 | lmsr, | ||
| 85 | EventStatus.ACTIVE, | ||
| 86 | List.of(new Market("1:0", "YES"), new Market("1:1", "NO")), | ||
| 87 | "Tester"); | ||
| 88 | |||
| 89 | var trades = lmsr.buy(dummyEvent, dummyEvent.getMarkets().get(0), Instant.now(), 100); | ||
| 90 | assertEquals(1, trades.size()); | ||
| 91 | assertEquals(100, trades.get(0).quantity()); | ||
| 92 | assertEquals(new BigDecimal("62.01"), trades.get(0).sharesCost()); | ||
| 93 | |||
| 94 | // Price after 100 shares of YES should be ~0.73 and ~0.27 | ||
| 95 | var pricesAfter = lmsr.prices(); | ||
| 96 | assertEquals(new BigDecimal("0.73"), pricesAfter[0]); | ||
| 97 | assertEquals(new BigDecimal("0.27"), pricesAfter[1]); | ||
| 98 | } | ||
| 99 | |||
| 100 | @Test | ||
| 101 | public void testXmlLoadingAndAtomicity() { | ||
| 102 | var xmlPath = Path.of("test-data/ex-1/multiple.xml"); | ||
| 103 | assertTrue(Files.exists(xmlPath), "test-data/ex-1/multiple.xml must exist"); | ||
| 104 | |||
| 105 | var result = catalog.loadEvents(xmlPath); | ||
| 106 | assertTrue(result.isSuccess(), "Loading multiple.xml should succeed"); | ||
| 107 | assertEquals(3, result.getData().eventsLoaded()); | ||
| 108 | assertEquals(3, eventRepo.getAll().size()); | ||
| 109 | |||
| 110 | // Re-loading multiple.xml should replace, NOT accumulate (size must remain 3) | ||
| 111 | var reloadResult = catalog.loadEvents(xmlPath); | ||
| 112 | assertTrue(reloadResult.isSuccess()); | ||
| 113 | assertEquals(3, eventRepo.getAll().size()); | ||
| 114 | |||
| 115 | // Loading non-existent file should fail and preserve 3 events | ||
| 116 | var failResult = catalog.loadEvents(Path.of("non_existent_file.xml")); | ||
| 117 | assertFalse(failResult.isSuccess()); | ||
| 118 | assertEquals(3, eventRepo.getAll().size()); | ||
| 119 | |||
| 120 | // Loading non-xml file should fail and preserve 3 events | ||
| 121 | var notXmlResult = catalog.loadEvents(Path.of("README.md")); | ||
| 122 | assertFalse(notXmlResult.isSuccess()); | ||
| 123 | assertEquals(3, eventRepo.getAll().size()); | ||
| 124 | } | ||
| 125 | |||
| 126 | @Test | ||
| 127 | public void testPlaceOrderWithOnPurchaseCommission() { | ||
| 128 | var xmlPath = Path.of("test-data/ex-1/multiple.xml"); | ||
| 129 | catalog.loadEvents(xmlPath); | ||
| 130 | |||
| 131 | // Event 1 has 5% commission on-purchase | ||
| 132 | var event1 = eventRepo.get("1").orElseThrow(); | ||
| 133 | assertEquals(5, event1.getCommissionPercent()); | ||
| 134 | assertEquals(CommissionTiming.ON_PURCHASE, event1.getCommissionTiming()); | ||
| 135 | |||
| 136 | var option0Key = event1.getMarkets().get(0).getKey(); | ||
| 137 | var buyResult = guessMarket.placeOrder("Tester", "1", option0Key, new BigDecimal("0.50"), 100); | ||
| 138 | |||
| 139 | assertTrue(buyResult.isSuccess()); | ||
| 140 | var receipt = buyResult.getData(); | ||
| 141 | assertNotNull(receipt); | ||
| 142 | assertEquals("62.01", receipt.sharesCost()); | ||
| 143 | assertEquals("3.10", receipt.commissionPaid()); // 5% of 62.01 = 3.1005 -> 3.10 | ||
| 144 | assertEquals("65.11", receipt.totalPaid()); // 62.01 + 3.10 = 65.11 | ||
| 145 | |||
| 146 | // History should contain 1 trade | ||
| 147 | assertEquals(1, event1.getHistory().size()); | ||
| 148 | assertEquals("62.01", event1.getHistory().get(0).pricePaid()); | ||
| 149 | } | ||
| 150 | |||
| 151 | @Test | ||
| 152 | public void testSettleEventWithOnCloseCommission() { | ||
| 153 | var xmlPath = Path.of("test-data/ex-1/multiple.xml"); | ||
| 154 | catalog.loadEvents(xmlPath); | ||
| 155 | |||
| 156 | // Event 2 has 15% commission on-close, b=50 | ||
| 157 | var event2 = eventRepo.get("2").orElseThrow(); | ||
| 158 | assertEquals(15, event2.getCommissionPercent()); | ||
| 159 | assertEquals(CommissionTiming.ON_CLOSE, event2.getCommissionTiming()); | ||
| 160 | |||
| 161 | var option0Key = event2.getMarkets().get(0).getKey(); | ||
| 162 | // Buy 50 shares of Argentina | ||
| 163 | var buyResult = guessMarket.placeOrder("Tester", "2", option0Key, new BigDecimal("0.50"), 50); | ||
| 164 | assertTrue(buyResult.isSuccess()); | ||
| 165 | var receipt = buyResult.getData(); | ||
| 166 | // On purchase commission is 0.00 | ||
| 167 | assertEquals("0.00", receipt.commissionPaid()); | ||
| 168 | |||
| 169 | // Now settle event choosing option 0 as winner | ||
| 170 | var settleResult = guessMarket.settleEvent("Tester", "2", option0Key); | ||
| 171 | assertTrue(settleResult.isSuccess()); | ||
| 172 | |||
| 173 | var detail = settleResult.getData(); | ||
| 174 | assertEquals(EventStatus.SETTLED, detail.summary().status()); | ||
| 175 | assertEquals(event2.getMarkets().get(0).getName(), detail.settledMarket()); | ||
| 176 | |||
| 177 | // 50 winning shares @ $1 gross = $50.00. 15% commission = $7.50. Net payout = $42.50. | ||
| 178 | assertEquals(new BigDecimal("7.50"), event2.getCommission()); | ||
| 179 | } | ||
| 180 | |||
| 181 | @Test | ||
| 182 | public void testStateSaveAndRestoreBonusRoundTrip() throws Exception { | ||
| 183 | var xmlPath = Path.of("test-data/ex-1/multiple.xml"); | ||
| 184 | catalog.loadEvents(xmlPath); | ||
| 185 | |||
| 186 | // Trade on event 1 | ||
| 187 | var optKey = eventRepo.get("1").orElseThrow().getMarkets().get(0).getKey(); | ||
| 188 | guessMarket.placeOrder("Tester", "1", optKey, new BigDecimal("0.50"), 100); | ||
| 189 | |||
| 190 | // Save to temp file | ||
| 191 | var tempDir = Files.createTempDirectory("gm_test_save"); | ||
| 192 | var savePath = tempDir.resolve("snapshot"); | ||
| 193 | |||
| 194 | var saveRes = catalog.saveState(savePath); | ||
| 195 | assertTrue(saveRes.isSuccess(), "Saving state should succeed"); | ||
| 196 | assertTrue(Files.exists(tempDir.resolve("snapshot.json")), "snapshot.json must exist"); | ||
| 197 | |||
| 198 | // Clear repositories | ||
| 199 | eventRepo.clear(); | ||
| 200 | userRepo.clear(); | ||
| 201 | assertEquals(0, eventRepo.getAll().size()); | ||
| 202 | |||
| 203 | // Restore from path (without extension) | ||
| 204 | var restoreRes = catalog.restoreState(savePath); | ||
| 205 | assertTrue(restoreRes.isSuccess(), "Restoring state should succeed"); | ||
| 206 | assertEquals(3, restoreRes.getData().eventsLoaded()); | ||
| 207 | assertEquals(3, eventRepo.getAll().size()); | ||
| 208 | |||
| 209 | var restoredEvent1 = eventRepo.get("1").orElseThrow(); | ||
| 210 | assertEquals(1, restoredEvent1.getTrades().size()); | ||
| 211 | assertEquals(100, restoredEvent1.getTrades().get(0).quantity()); | ||
| 212 | assertEquals(new BigDecimal("62.01"), restoredEvent1.getTrades().get(0).sharesCost()); | ||
| 213 | } | ||
| 214 | |||
| 215 | @Test | ||
| 216 | public void testXmlValidationRules() throws Exception { | ||
| 217 | var tempDir = Files.createTempDirectory("gm_xml_val_test"); | ||
| 218 | |||
| 219 | // 1. Commission > 90 | ||
| 220 | var xmlInvalidComm = | ||
| 221 | """ | ||
| 222 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 223 | <Guess-Market> | ||
| 224 | <GM-events> | ||
| 225 | <GM-event name="High Comm"> | ||
| 226 | <id>1</id> | ||
| 227 | <description>Desc</description> | ||
| 228 | <comision type="on-purchase">95</comision> | ||
| 229 | <GM-options> | ||
| 230 | <GM-option>Yes</GM-option> | ||
| 231 | <GM-option>No</GM-option> | ||
| 232 | </GM-options> | ||
| 233 | <GM-method><GM-LMSR><b>100</b></GM-LMSR></GM-method> | ||
| 234 | </GM-event> | ||
| 235 | </GM-events> | ||
| 236 | </Guess-Market> | ||
| 237 | """; | ||
| 238 | var p1 = tempDir.resolve("high_comm.xml"); | ||
| 239 | Files.writeString(p1, xmlInvalidComm); | ||
| 240 | var res1 = catalog.loadEvents(p1); | ||
| 241 | assertFalse(res1.isSuccess()); | ||
| 242 | assertTrue(res1.getDetails().contains("between 0% and 90%")); | ||
| 243 | |||
| 244 | // 2. Duplicate Event IDs | ||
| 245 | var xmlDupId = | ||
| 246 | """ | ||
| 247 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 248 | <Guess-Market> | ||
| 249 | <GM-events> | ||
| 250 | <GM-event name="Event 1"> | ||
| 251 | <id>1</id> | ||
| 252 | <description>Desc 1</description> | ||
| 253 | <comision type="on-purchase">10</comision> | ||
| 254 | <GM-options><GM-option>Yes</GM-option><GM-option>No</GM-option></GM-options> | ||
| 255 | <GM-method><GM-LMSR><b>100</b></GM-LMSR></GM-method> | ||
| 256 | </GM-event> | ||
| 257 | <GM-event name="Event 2"> | ||
| 258 | <id>1</id> | ||
| 259 | <description>Desc 2</description> | ||
| 260 | <comision type="on-close">10</comision> | ||
| 261 | <GM-options><GM-option>Yes</GM-option><GM-option>No</GM-option></GM-options> | ||
| 262 | <GM-method><GM-LMSR><b>100</b></GM-LMSR></GM-method> | ||
| 263 | </GM-event> | ||
| 264 | </GM-events> | ||
| 265 | </Guess-Market> | ||
| 266 | """; | ||
| 267 | var p2 = tempDir.resolve("dup_id.xml"); | ||
| 268 | Files.writeString(p2, xmlDupId); | ||
| 269 | var res2 = catalog.loadEvents(p2); | ||
| 270 | assertFalse(res2.isSuccess()); | ||
| 271 | assertTrue(res2.getDetails().contains("Every event ID must be unique")); | ||
| 272 | |||
| 273 | // 3. Three options (must be exactly 2 for Ex1) | ||
| 274 | var xml3Opts = | ||
| 275 | """ | ||
| 276 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 277 | <Guess-Market> | ||
| 278 | <GM-events> | ||
| 279 | <GM-event name="Three Options"> | ||
| 280 | <id>1</id> | ||
| 281 | <description>Desc</description> | ||
| 282 | <comision type="on-purchase">10</comision> | ||
| 283 | <GM-options> | ||
| 284 | <GM-option>Opt 1</GM-option> | ||
| 285 | <GM-option>Opt 2</GM-option> | ||
| 286 | <GM-option>Opt 3</GM-option> | ||
| 287 | </GM-options> | ||
| 288 | <GM-method><GM-LMSR><b>100</b></GM-LMSR></GM-method> | ||
| 289 | </GM-event> | ||
| 290 | </GM-events> | ||
| 291 | </Guess-Market> | ||
| 292 | """; | ||
| 293 | var p3 = tempDir.resolve("three_opts.xml"); | ||
| 294 | Files.writeString(p3, xml3Opts); | ||
| 295 | var res3 = catalog.loadEvents(p3); | ||
| 296 | assertFalse(res3.isSuccess()); | ||
| 297 | assertTrue(res3.getDetails().contains("must have exactly 2 options")); | ||
| 298 | |||
| 299 | // 4. Invalid liquidity b <= 0 | ||
| 300 | var xmlBadB = | ||
| 301 | """ | ||
| 302 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 303 | <Guess-Market> | ||
| 304 | <GM-events> | ||
| 305 | <GM-event name="Bad B"> | ||
| 306 | <id>1</id> | ||
| 307 | <description>Desc</description> | ||
| 308 | <comision type="on-purchase">10</comision> | ||
| 309 | <GM-options><GM-option>Yes</GM-option><GM-option>No</GM-option></GM-options> | ||
| 310 | <GM-method><GM-LMSR><b>0</b></GM-LMSR></GM-method> | ||
| 311 | </GM-event> | ||
| 312 | </GM-events> | ||
| 313 | </Guess-Market> | ||
| 314 | """; | ||
| 315 | var p4 = tempDir.resolve("bad_b.xml"); | ||
| 316 | Files.writeString(p4, xmlBadB); | ||
| 317 | var res4 = catalog.loadEvents(p4); | ||
| 318 | assertFalse(res4.isSuccess()); | ||
| 319 | assertTrue(res4.getDetails().contains("positive integer")); | ||
| 320 | |||
| 321 | // 5. Invalid commission type | ||
| 322 | var xmlBadCommType = | ||
| 323 | """ | ||
| 324 | <?xml version="1.0" encoding="UTF-8"?> | ||
| 325 | <Guess-Market> | ||
| 326 | <GM-events> | ||
| 327 | <GM-event name="Bad Comm Type"> | ||
| 328 | <id>1</id> | ||
| 329 | <description>Desc</description> | ||
| 330 | <comision type="invalid-type">10</comision> | ||
| 331 | <GM-options><GM-option>Yes</GM-option><GM-option>No</GM-option></GM-options> | ||
| 332 | <GM-method><GM-LMSR><b>100</b></GM-LMSR></GM-method> | ||
| 333 | </GM-event> | ||
| 334 | </GM-events> | ||
| 335 | </Guess-Market> | ||
| 336 | """; | ||
| 337 | var p5 = tempDir.resolve("bad_comm_type.xml"); | ||
| 338 | Files.writeString(p5, xmlBadCommType); | ||
| 339 | var res5 = catalog.loadEvents(p5); | ||
| 340 | assertFalse(res5.isSuccess()); | ||
| 341 | assertTrue(res5.getDetails().contains("Must be 'on-close' or 'on-purchase'")); | ||
| 342 | } | ||
| 343 | } | ||