From 755d2265054e5140e82d322681566c3034583c95 Mon Sep 17 00:00:00 2001 From: Kostya Date: Tue, 18 Aug 2026 18:19:50 +0300 Subject: Break down purchase receipts by cost/commission, harden XML validation and state I/O Adds a full pre-load validation pass for GM XML seeds (ids, names, options, commission, LMSR liquidity), replaces IO.print with System.out, and surfaces detailed error messages across load/save commands instead of generic failures. --- .../java/market/guess/service/Ex1FullTest.java | 343 +++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 service/src/test/java/market/guess/service/Ex1FullTest.java (limited to 'service/src/test/java') 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 @@ +package market.guess.service; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import market.guess.api.CommissionTiming; +import market.guess.model.event.EventStatus; +import market.guess.service.catalog.LocalCatalogContext; +import market.guess.service.catalog.infrastructure.MarketContext; +import market.guess.service.catalog.infrastructure.mapper.v1.EventMapperV1; +import market.guess.service.catalog.infrastructure.provider.v1.XMLLoaderV1; +import market.guess.service.catalog.infrastructure.provider.v1.XMLValidatorV1; +import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository; +import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository; +import market.guess.service.domain.Event; +import market.guess.service.domain.Market; +import market.guess.service.domain.User; +import market.guess.service.fulfillment.LocalFulfillmentContext; +import market.guess.service.helpers.BigDecimalOptions; +import market.guess.service.ledger.LedgerContext; +import market.guess.service.matching.LocalMatchingEngine; +import market.guess.service.mechanism.LmsrTradingMechanism; +import market.guess.service.risk.LocalRiskEngine; +import market.guess.service.settlement.LocalSettlementContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class Ex1FullTest { + + private InMemoryEventRepository eventRepo; + private InMemoryUserRepository userRepo; + private MarketContext marketContext; + private XMLLoaderV1 loader; + private XMLValidatorV1 validator; + private LocalCatalogContext catalog; + private LocalGuessMarketContext guessMarket; + private LedgerContext ledgerContext; + private LocalSettlementContext settlementContext; + + @BeforeEach + public void setUp() { + eventRepo = new InMemoryEventRepository(); + userRepo = new InMemoryUserRepository(); + marketContext = new MarketContext(eventRepo, userRepo); + validator = new XMLValidatorV1(); + loader = new XMLLoaderV1(eventRepo, userRepo, new EventMapperV1(), validator); + catalog = new LocalCatalogContext(loader, marketContext, eventRepo); + ledgerContext = new LedgerContext(userRepo, java.time.Clock.systemUTC()); + settlementContext = new LocalSettlementContext(ledgerContext); + guessMarket = + new LocalGuessMarketContext( + marketContext, + new LocalRiskEngine(), + new LocalMatchingEngine(java.time.Clock.systemUTC()), + new LocalFulfillmentContext(ledgerContext), + settlementContext); + } + + @Test + public void testLmsrMathCalculations() { + var lmsr = new LmsrTradingMechanism(100, 2); + + // Initial prices should be 0.50 and 0.50 + var initialPrices = lmsr.prices(); + assertEquals(new BigDecimal("0.50"), initialPrices[0]); + assertEquals(new BigDecimal("0.50"), initialPrices[1]); + + // Initial max loss / subsidy = 100 * ln(2) = 69.31 + assertEquals(new BigDecimal("69.31"), lmsr.maxLoss()); + + // Buy 100 shares of option 0 (YES) + var dummyEvent = + new Event( + "1", + 1, + "Test Event", + "Desc", + 0, + CommissionTiming.ON_PURCHASE, + lmsr, + EventStatus.ACTIVE, + List.of(new Market("1:0", "YES"), new Market("1:1", "NO")), + "Tester"); + + var trades = lmsr.buy(dummyEvent, dummyEvent.getMarkets().get(0), Instant.now(), 100); + assertEquals(1, trades.size()); + assertEquals(100, trades.get(0).quantity()); + assertEquals(new BigDecimal("62.01"), trades.get(0).sharesCost()); + + // Price after 100 shares of YES should be ~0.73 and ~0.27 + var pricesAfter = lmsr.prices(); + assertEquals(new BigDecimal("0.73"), pricesAfter[0]); + assertEquals(new BigDecimal("0.27"), pricesAfter[1]); + } + + @Test + public void testXmlLoadingAndAtomicity() { + var xmlPath = Path.of("test-data/ex-1/multiple.xml"); + assertTrue(Files.exists(xmlPath), "test-data/ex-1/multiple.xml must exist"); + + var result = catalog.loadEvents(xmlPath); + assertTrue(result.isSuccess(), "Loading multiple.xml should succeed"); + assertEquals(3, result.getData().eventsLoaded()); + assertEquals(3, eventRepo.getAll().size()); + + // Re-loading multiple.xml should replace, NOT accumulate (size must remain 3) + var reloadResult = catalog.loadEvents(xmlPath); + assertTrue(reloadResult.isSuccess()); + assertEquals(3, eventRepo.getAll().size()); + + // Loading non-existent file should fail and preserve 3 events + var failResult = catalog.loadEvents(Path.of("non_existent_file.xml")); + assertFalse(failResult.isSuccess()); + assertEquals(3, eventRepo.getAll().size()); + + // Loading non-xml file should fail and preserve 3 events + var notXmlResult = catalog.loadEvents(Path.of("README.md")); + assertFalse(notXmlResult.isSuccess()); + assertEquals(3, eventRepo.getAll().size()); + } + + @Test + public void testPlaceOrderWithOnPurchaseCommission() { + var xmlPath = Path.of("test-data/ex-1/multiple.xml"); + catalog.loadEvents(xmlPath); + + // Event 1 has 5% commission on-purchase + var event1 = eventRepo.get("1").orElseThrow(); + assertEquals(5, event1.getCommissionPercent()); + assertEquals(CommissionTiming.ON_PURCHASE, event1.getCommissionTiming()); + + var option0Key = event1.getMarkets().get(0).getKey(); + var buyResult = guessMarket.placeOrder("Tester", "1", option0Key, new BigDecimal("0.50"), 100); + + assertTrue(buyResult.isSuccess()); + var receipt = buyResult.getData(); + assertNotNull(receipt); + assertEquals("62.01", receipt.sharesCost()); + assertEquals("3.10", receipt.commissionPaid()); // 5% of 62.01 = 3.1005 -> 3.10 + assertEquals("65.11", receipt.totalPaid()); // 62.01 + 3.10 = 65.11 + + // History should contain 1 trade + assertEquals(1, event1.getHistory().size()); + assertEquals("62.01", event1.getHistory().get(0).pricePaid()); + } + + @Test + public void testSettleEventWithOnCloseCommission() { + var xmlPath = Path.of("test-data/ex-1/multiple.xml"); + catalog.loadEvents(xmlPath); + + // Event 2 has 15% commission on-close, b=50 + var event2 = eventRepo.get("2").orElseThrow(); + assertEquals(15, event2.getCommissionPercent()); + assertEquals(CommissionTiming.ON_CLOSE, event2.getCommissionTiming()); + + var option0Key = event2.getMarkets().get(0).getKey(); + // Buy 50 shares of Argentina + var buyResult = guessMarket.placeOrder("Tester", "2", option0Key, new BigDecimal("0.50"), 50); + assertTrue(buyResult.isSuccess()); + var receipt = buyResult.getData(); + // On purchase commission is 0.00 + assertEquals("0.00", receipt.commissionPaid()); + + // Now settle event choosing option 0 as winner + var settleResult = guessMarket.settleEvent("Tester", "2", option0Key); + assertTrue(settleResult.isSuccess()); + + var detail = settleResult.getData(); + assertEquals(EventStatus.SETTLED, detail.summary().status()); + assertEquals(event2.getMarkets().get(0).getName(), detail.settledMarket()); + + // 50 winning shares @ $1 gross = $50.00. 15% commission = $7.50. Net payout = $42.50. + assertEquals(new BigDecimal("7.50"), event2.getCommission()); + } + + @Test + public void testStateSaveAndRestoreBonusRoundTrip() throws Exception { + var xmlPath = Path.of("test-data/ex-1/multiple.xml"); + catalog.loadEvents(xmlPath); + + // Trade on event 1 + var optKey = eventRepo.get("1").orElseThrow().getMarkets().get(0).getKey(); + guessMarket.placeOrder("Tester", "1", optKey, new BigDecimal("0.50"), 100); + + // Save to temp file + var tempDir = Files.createTempDirectory("gm_test_save"); + var savePath = tempDir.resolve("snapshot"); + + var saveRes = catalog.saveState(savePath); + assertTrue(saveRes.isSuccess(), "Saving state should succeed"); + assertTrue(Files.exists(tempDir.resolve("snapshot.json")), "snapshot.json must exist"); + + // Clear repositories + eventRepo.clear(); + userRepo.clear(); + assertEquals(0, eventRepo.getAll().size()); + + // Restore from path (without extension) + var restoreRes = catalog.restoreState(savePath); + assertTrue(restoreRes.isSuccess(), "Restoring state should succeed"); + assertEquals(3, restoreRes.getData().eventsLoaded()); + assertEquals(3, eventRepo.getAll().size()); + + var restoredEvent1 = eventRepo.get("1").orElseThrow(); + assertEquals(1, restoredEvent1.getTrades().size()); + assertEquals(100, restoredEvent1.getTrades().get(0).quantity()); + assertEquals(new BigDecimal("62.01"), restoredEvent1.getTrades().get(0).sharesCost()); + } + + @Test + public void testXmlValidationRules() throws Exception { + var tempDir = Files.createTempDirectory("gm_xml_val_test"); + + // 1. Commission > 90 + var xmlInvalidComm = + """ + + + + + 1 + Desc + 95 + + Yes + No + + 100 + + + + """; + var p1 = tempDir.resolve("high_comm.xml"); + Files.writeString(p1, xmlInvalidComm); + var res1 = catalog.loadEvents(p1); + assertFalse(res1.isSuccess()); + assertTrue(res1.getDetails().contains("between 0% and 90%")); + + // 2. Duplicate Event IDs + var xmlDupId = + """ + + + + + 1 + Desc 1 + 10 + YesNo + 100 + + + 1 + Desc 2 + 10 + YesNo + 100 + + + + """; + var p2 = tempDir.resolve("dup_id.xml"); + Files.writeString(p2, xmlDupId); + var res2 = catalog.loadEvents(p2); + assertFalse(res2.isSuccess()); + assertTrue(res2.getDetails().contains("Every event ID must be unique")); + + // 3. Three options (must be exactly 2 for Ex1) + var xml3Opts = + """ + + + + + 1 + Desc + 10 + + Opt 1 + Opt 2 + Opt 3 + + 100 + + + + """; + var p3 = tempDir.resolve("three_opts.xml"); + Files.writeString(p3, xml3Opts); + var res3 = catalog.loadEvents(p3); + assertFalse(res3.isSuccess()); + assertTrue(res3.getDetails().contains("must have exactly 2 options")); + + // 4. Invalid liquidity b <= 0 + var xmlBadB = + """ + + + + + 1 + Desc + 10 + YesNo + 0 + + + + """; + var p4 = tempDir.resolve("bad_b.xml"); + Files.writeString(p4, xmlBadB); + var res4 = catalog.loadEvents(p4); + assertFalse(res4.isSuccess()); + assertTrue(res4.getDetails().contains("positive integer")); + + // 5. Invalid commission type + var xmlBadCommType = + """ + + + + + 1 + Desc + 10 + YesNo + 100 + + + + """; + var p5 = tempDir.resolve("bad_comm_type.xml"); + Files.writeString(p5, xmlBadCommType); + var res5 = catalog.loadEvents(p5); + assertFalse(res5.isSuccess()); + assertTrue(res5.getDetails().contains("Must be 'on-close' or 'on-purchase'")); + } +} -- cgit v1.2.3