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. --- .gitignore | 2 + .../java/market/guess/api/PurchaseReceiptDTO.java | 7 +- .../guess/service/LocalGuessMarketContext.java | 20 +- .../catalog/infrastructure/MarketContext.java | 18 +- .../infrastructure/mapper/v1/EventMapperV1.java | 17 +- .../infrastructure/provider/v1/XMLLoaderV1.java | 34 +- .../infrastructure/provider/v1/XMLValidatorV1.java | 80 ++++- .../java/market/guess/service/Ex1FullTest.java | 343 +++++++++++++++++++++ tools/_util.sh | 7 +- tools/package.sh | 2 + .../console/command/EventDetailsMenuCommand.java | 31 +- .../ui/console/command/ListEventsMenuCommand.java | 19 +- .../ui/console/command/LoadFileMenuCommand.java | 10 +- .../command/LoadMarketStateMenuCommand.java | 10 +- .../ui/console/command/PlaceOrderMenuCommand.java | 17 +- .../command/SaveMarketStateMenuCommand.java | 16 +- .../ui/console/command/SettleEventMenuCommand.java | 20 +- .../market/guess/ui/console/io/InputProcessor.java | 31 +- 18 files changed, 608 insertions(+), 76 deletions(-) create mode 100644 service/src/test/java/market/guess/service/Ex1FullTest.java diff --git a/.gitignore b/.gitignore index 862ee08..9b4e8b8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ stage/ build/ *.class + +README.* diff --git a/api/src/main/java/market/guess/api/PurchaseReceiptDTO.java b/api/src/main/java/market/guess/api/PurchaseReceiptDTO.java index e98a080..02e8007 100644 --- a/api/src/main/java/market/guess/api/PurchaseReceiptDTO.java +++ b/api/src/main/java/market/guess/api/PurchaseReceiptDTO.java @@ -2,4 +2,9 @@ package market.guess.api; import java.util.List; -public record PurchaseReceiptDTO(List trades, MarketStateDTO stateAfter) {} +public record PurchaseReceiptDTO( + List trades, + String sharesCost, + String commissionPaid, + String totalPaid, + MarketStateDTO stateAfter) {} diff --git a/service/src/main/java/market/guess/service/LocalGuessMarketContext.java b/service/src/main/java/market/guess/service/LocalGuessMarketContext.java index 909a808..f04324b 100644 --- a/service/src/main/java/market/guess/service/LocalGuessMarketContext.java +++ b/service/src/main/java/market/guess/service/LocalGuessMarketContext.java @@ -10,6 +10,7 @@ import market.guess.model.event.EventDetailDTO; import market.guess.service.catalog.infrastructure.MarketContext; import market.guess.service.domain.Order; import market.guess.service.fulfillment.FulfillmentContext; +import market.guess.service.helpers.BigDecimalOptions; import market.guess.service.helpers.InstantOptions; import market.guess.service.matching.MatchingEngine; import market.guess.service.risk.RiskEngine; @@ -55,6 +56,20 @@ public final class LocalGuessMarketContext implements GuessMarketContext { var rawTrades = matching.match(order); var trades = fulfillment.fulfill(user, order, rawTrades); + + var totalSharesCost = + trades.stream() + .map(t -> t.sharesCost()) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + var totalCommission = + trades.stream() + .map(t -> t.commission()) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + var totalPaid = + trades.stream() + .map(t -> t.totalPaid()) + .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); + return Result.ok( new PurchaseReceiptDTO( trades.stream() @@ -65,8 +80,11 @@ public final class LocalGuessMarketContext implements GuessMarketContext { t.buyerUserName(), t.marketName(), String.valueOf(t.quantity()), - t.totalPaid().toPlainString())) + t.sharesCost().toPlainString())) .toList(), + totalSharesCost.toPlainString(), + totalCommission.toPlainString(), + totalPaid.toPlainString(), event.toMarketState())); } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java b/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java index cb2b467..5fc2e24 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java @@ -67,15 +67,22 @@ public final class MarketContext { GSON.toJson(new Wrapper(events.getAll(), users.getAll())), StandardCharsets.UTF_8); } catch (Exception e) { - throw new GuessMarketException("Failed to sove the file."); + throw new GuessMarketException("Failed to save state to " + target + ": " + e.getMessage()); } } public void load(Path path) throws GuessMarketException { + var target = withJsonExtension(path); + if (!Files.isRegularFile(target)) { + throw new GuessMarketException("State file not found: " + target); + } try { var wrapper = GSON.fromJson( - Files.readString(withJsonExtension(path), StandardCharsets.UTF_8), Wrapper.class); + Files.readString(target, StandardCharsets.UTF_8), Wrapper.class); + if (wrapper == null || wrapper.events() == null) { + throw new GuessMarketException("State file contains invalid or empty data."); + } events.clear(); users.clear(); @@ -88,14 +95,15 @@ public final class MarketContext { } } catch (JsonSyntaxException e) { - throw new GuessMarketException("The JSON file is malformed."); + throw new GuessMarketException("The state file is malformed JSON: " + e.getMessage()); } catch (IOException e) { - throw new GuessMarketException("Unable the file."); + throw new GuessMarketException("Unable to read state file: " + e.getMessage()); } } private static Path withJsonExtension(Path path) { + if (path == null) return null; var name = path.getFileName().toString(); - return name.endsWith(".json") ? path : path.resolveSibling(name + ".json"); + return name.toLowerCase().endsWith(".json") ? path : path.resolveSibling(name + ".json"); } } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java b/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java index f45eb46..b9c43bb 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java @@ -19,8 +19,8 @@ public final class EventMapperV1 implements Mapper { return new Event( getEventKey(source), source.getId(), - joinName(source.getName()), - source.getDescription(), + joinName(source.getName()).trim(), + source.getDescription() != null ? source.getDescription().trim() : "", source.getComision().getValue(), getTiming(source.getComision().getType()), new LmsrTradingMechanism(source.getGMMethod().getGMLMSR().getB(), options.size()), @@ -38,17 +38,20 @@ public final class EventMapperV1 implements Mapper { } private static CommissionTiming getTiming(String type) throws IllegalArgumentException { - return switch (type) { + if (type == null) throw new IllegalArgumentException("Commission type cannot be null"); + return switch (type.toLowerCase().trim()) { case "on-close" -> CommissionTiming.ON_CLOSE; case "on-purchase" -> CommissionTiming.ON_PURCHASE; - default -> throw new IllegalArgumentException("Unknown comision type: " + type); + default -> throw new IllegalArgumentException("Unknown commission type: " + type); }; } private static List getOptions(GMEvent x) { - var ret = new ArrayList(x.getGMOptions().getGMOption().size()); - for (var i = 0; i < x.getGMOptions().getGMOption().size(); i++) { - ret.add(new Market(getEventKey(x) + ":" + i, x.getGMOptions().getGMOption().get(i))); + var raw = x.getGMOptions().getGMOption(); + var ret = new ArrayList(raw.size()); + for (var i = 0; i < raw.size(); i++) { + var opt = raw.get(i); + ret.add(new Market(getEventKey(x) + ":" + i, opt != null ? opt.trim() : "")); } return ret; } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java index bcae787..44e1c7f 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java @@ -2,8 +2,10 @@ package market.guess.service.catalog.infrastructure.provider.v1; import jakarta.xml.bind.JAXBContext; import jakarta.xml.bind.JAXBException; +import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import market.guess.api.CatalogContext; import market.guess.api.LoadResult; import market.guess.api.Result; @@ -12,6 +14,7 @@ import market.guess.service.catalog.infrastructure.provider.Loader; import market.guess.service.catalog.infrastructure.repository.EventRepository; import market.guess.service.catalog.infrastructure.repository.UserRepository; import market.guess.service.catalog.model.v1.GuessMarket; +import market.guess.service.domain.Event; import market.guess.service.domain.User; public final class XMLLoaderV1 implements Loader { @@ -35,19 +38,23 @@ public final class XMLLoaderV1 implements Loader { try { context = JAXBContext.newInstance(GuessMarket.class); } catch (JAXBException e) { - throw new IllegalStateException(); + throw new IllegalStateException("Failed to initialize JAXB context: " + e.getMessage(), e); } } @Override public Result load(Path path) { + if (path == null) { + return Result.error("INVALID_PATH", "File path cannot be null."); + } + if (!Files.isRegularFile(path)) { - return Result.error("FILE_NOT_FOUND", "No such file."); + return Result.error("FILE_NOT_FOUND", "No such file: " + path); } if (path.getFileName() == null || !path.getFileName().toString().toLowerCase().endsWith(".xml")) { - return Result.error("NOT_XML", "The file must be an XML."); + return Result.error("NOT_XML", "The file must have a .xml extension."); } try (var stream = Files.newInputStream(path)) { @@ -59,21 +66,34 @@ public final class XMLLoaderV1 implements Loader { return Result.error(validation.getMessage(), validation.getDetails()); } + var domainEvents = new ArrayList(); for (var event : seed.getGMEvents().getGMEvent()) { - eventRepository.add(mapper.toDomain(event)); + domainEvents.add(mapper.toDomain(event)); + } + + eventRepository.clear(); + userRepository.clear(); + + for (var domainEvent : domainEvents) { + eventRepository.add(domainEvent); } - userRepository.add(new User("Tester", 500)); + userRepository.add(new User("Tester", new BigDecimal("500.00"))); return Result.ok(new LoadResult(path.toString(), eventRepository.getAll().size())); + } catch (JAXBException e) { + var cause = + e.getLinkedException() != null ? e.getLinkedException().getMessage() : e.getMessage(); + return Result.error( + "XML_PARSE_ERROR", + "The XML file is malformed or invalid: " + (cause != null ? cause : e.getMessage())); } catch (Exception e) { - return Result.error("UNREADABLE", "The file could not be read."); + return Result.error("UNREADABLE", "The file could not be read: " + e.getMessage()); } } @Override public Result seed(CatalogContext context) { - // TODO Auto-generated method stub throw new UnsupportedOperationException("Unimplemented method 'seed'"); } } diff --git a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java index 567558a..5a4e5ea 100644 --- a/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java +++ b/service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java @@ -9,27 +9,93 @@ public final class XMLValidatorV1 { private static final int MAX_COMMISSION = 90; public Result validate(GuessMarket seed) { + if (seed == null || seed.getGMEvents() == null || seed.getGMEvents().getGMEvent() == null) { + return Result.error("INVALID_XML", "The XML file contains no GM-events element."); + } - var seenIds = new HashSet(); - var duplicated = new HashSet(); + var events = seed.getGMEvents().getGMEvent(); + if (events.isEmpty()) { + return Result.error("EMPTY_EVENTS", "The XML file does not contain any events."); + } - for (var e : seed.getGMEvents().getGMEvent()) { + var seenIds = new HashSet(); - if (!seenIds.add(e.getId()) && duplicated.add(e.getId())) { + for (var e : events) { + // 1. Event ID must be positive integer (> 0) + if (e.getId() <= 0) { + return Result.error( + "INVALID_ID", + "Event ID must be a positive integer (> 0), found: " + e.getId()); + } + if (!seenIds.add(e.getId())) { return Result.error( "DUPLICATE_ID", - "Event id " + e.getId() + " appears more than once, every event id must be unique."); + "Event ID " + e.getId() + " appears more than once. Every event ID must be unique."); } + // 2. Event name must not be empty + if (e.getName() == null || e.getName().isEmpty() || String.join(" ", e.getName()).isBlank()) { + return Result.error("MISSING_NAME", "Event ID " + e.getId() + " has an empty name."); + } + + // 3. Event description must not be empty + if (e.getDescription() == null || e.getDescription().isBlank()) { + return Result.error("MISSING_DESCRIPTION", "Event ID " + e.getId() + " has an empty description."); + } + + // 4. Commission validation + if (e.getComision() == null) { + return Result.error("MISSING_COMMISSION", "Event ID " + e.getId() + " is missing commission."); + } var pct = e.getComision().getValue(); if (pct < MIN_COMMISSION || pct > MAX_COMMISSION) { return Result.error( "COMMISSION_OUT_OF_RANGE", - "Commission is %d%%, it must be between %d%% and %d%%." - .formatted(pct, MIN_COMMISSION, MAX_COMMISSION)); + "Event ID " + e.getId() + " commission is " + pct + "%. It must be between " + MIN_COMMISSION + "% and " + MAX_COMMISSION + "%."); + } + + var type = e.getComision().getType(); + if (type == null || (!type.equalsIgnoreCase("on-close") && !type.equalsIgnoreCase("on-purchase"))) { + return Result.error( + "INVALID_COMMISSION_TYPE", + "Event ID " + e.getId() + " has invalid commission type '" + type + "'. Must be 'on-close' or 'on-purchase'."); + } + + // 5. GM-options validation (exactly 2 options for Exercise 1) + if (e.getGMOptions() == null || e.getGMOptions().getGMOption() == null) { + return Result.error("MISSING_OPTIONS", "Event ID " + e.getId() + " is missing GM-options."); + } + var options = e.getGMOptions().getGMOption(); + if (options.size() != 2) { + return Result.error( + "INVALID_OPTIONS_COUNT", + "Event ID " + e.getId() + " must have exactly 2 options for Exercise 1, but found " + options.size() + "."); + } + var opt0 = options.get(0) == null ? "" : options.get(0).trim(); + var opt1 = options.get(1) == null ? "" : options.get(1).trim(); + if (opt0.isEmpty() || opt1.isEmpty()) { + return Result.error("EMPTY_OPTION", "Event ID " + e.getId() + " has an empty option name."); + } + if (opt0.equalsIgnoreCase(opt1)) { + return Result.error("DUPLICATE_OPTION", "Event ID " + e.getId() + " has duplicate option names: '" + opt0 + "'."); + } + + // 6. GM-method and LMSR validation + if (e.getGMMethod() == null) { + return Result.error("MISSING_METHOD", "Event ID " + e.getId() + " is missing GM-method."); + } + if (e.getGMMethod().getGMLMSR() == null) { + return Result.error("INVALID_METHOD", "Event ID " + e.getId() + " must use LMSR trading method in Exercise 1."); + } + var b = e.getGMMethod().getGMLMSR().getB(); + if (b <= 0) { + return Result.error( + "INVALID_LIQUIDITY", + "Event ID " + e.getId() + " has invalid liquidity b=" + b + ". b must be a positive integer (> 0)."); } } return Result.ok(); } } + 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'")); + } +} diff --git a/tools/_util.sh b/tools/_util.sh index 4650616..fa7e3b5 100644 --- a/tools/_util.sh +++ b/tools/_util.sh @@ -15,7 +15,12 @@ RUNTIME_LIB_DIRS="pico jaxb gson" MAIN_CLASS="market.guess.ui.console.App" -JAVA_HOME_DIR=$(java -XshowSettings:properties -version 2>&1 | sed -n 's/^ *java\.home = //p') +JAVA_HOME_DIR=$(java -XshowSettings:properties -version 2>&1 | tr -d '\r' | sed -n 's/^ *java\.home = //p') +case "$(uname -s)" in + CYGWIN*|MINGW*|MSYS*) + JAVA_HOME_DIR=$(cygpath -u "$JAVA_HOME_DIR" 2>/dev/null || echo "$JAVA_HOME_DIR") + ;; +esac JAR="$JAVA_HOME_DIR/bin/jar" [ -x "$JAR" ] || JAR="$JAVA_HOME_DIR/bin/jar.exe" if [ ! -x "$JAR" ]; then diff --git a/tools/package.sh b/tools/package.sh index 5877444..a85ee6c 100644 --- a/tools/package.sh +++ b/tools/package.sh @@ -60,6 +60,8 @@ java -jar "%~dp0ui-console.jar" if errorlevel 1 pause EOF +[ -f README.pdf ] && cp README.pdf "$DIST/" + echo "=== Manifest as it actually landed in the jar ===" # Cheapest check that prevents a Level-0: if Main-Class or Class-Path didn't # survive, it surfaces here instead of on the grader's machine. diff --git a/ui-console/src/main/java/market/guess/ui/console/command/EventDetailsMenuCommand.java b/ui-console/src/main/java/market/guess/ui/console/command/EventDetailsMenuCommand.java index 2ad7a53..bdd3eb1 100644 --- a/ui-console/src/main/java/market/guess/ui/console/command/EventDetailsMenuCommand.java +++ b/ui-console/src/main/java/market/guess/ui/console/command/EventDetailsMenuCommand.java @@ -29,7 +29,7 @@ public final class EventDetailsMenuCommand implements MenuCommand { var result = catalog.getAllEvents(); if (!result.isSuccess()) { - io.println("Failed to list events"); + io.println("Failed to list events: %s.".formatted(result.getMessage())); return; } @@ -37,11 +37,17 @@ public final class EventDetailsMenuCommand implements MenuCommand { var viewable = events.stream().filter(event -> event.status() != EventStatus.DRAFT).toList(); if (viewable.isEmpty()) { - io.println("There are currently no events to show."); + io.println("No events are currently loaded. Please load a valid XML file first."); return; } - var pick = io.readSelect("Pick an event: ", viewable, event -> "%s".formatted(event.name())); + io.newLine(); + io.println("Available events:"); + var pick = + io.readSelect( + "Pick an event: ", + viewable, + event -> "[ID: %d] %s (%s)".formatted(event.displayId(), event.name(), event.status())); var result2 = catalog.getEvent(pick.key()); var event = result2.getData(); @@ -50,11 +56,26 @@ public final class EventDetailsMenuCommand implements MenuCommand { return; } + io.newLine(); + io.println("=== Event Details ==="); + io.println("Event ID: %d".formatted(event.summary().displayId())); + io.println("Name: %s".formatted(event.summary().name())); + io.println("Description: %s".formatted(event.summary().description())); + io.println( + "Commission: %d%% (%s)" + .formatted( + event.summary().commissionPercent(), + event.summary().commissionTiming() == market.guess.api.CommissionTiming.ON_CLOSE + ? "on-close" + : "on-purchase")); + io.println("Status: %s".formatted(event.summary().status())); + io.printState(event.state()); io.printHistory(event.history()); - if (!event.settledMarket().isBlank()) { - io.println("Winning market: %s".formatted(event.settledMarket())); + if (event.settledMarket() != null && !event.settledMarket().isBlank()) { + io.newLine(); + io.println("Winning option: %s".formatted(event.settledMarket())); } } } diff --git a/ui-console/src/main/java/market/guess/ui/console/command/ListEventsMenuCommand.java b/ui-console/src/main/java/market/guess/ui/console/command/ListEventsMenuCommand.java index 755bf37..01e93bd 100644 --- a/ui-console/src/main/java/market/guess/ui/console/command/ListEventsMenuCommand.java +++ b/ui-console/src/main/java/market/guess/ui/console/command/ListEventsMenuCommand.java @@ -29,24 +29,29 @@ public final class ListEventsMenuCommand implements MenuCommand { var result = catalog.getAllEvents(); if (!result.isSuccess()) { - io.println("Failed to list events due to %s.".formatted(result.getMessage())); + io.println("Failed to list events: %s.".formatted(result.getMessage())); return; } var events = result.getData(); + if (events.isEmpty()) { + io.println("No events are currently loaded. Please load a valid XML file first."); + return; + } + for (var event : events) { io.newLine(); - io.println(" %s".formatted(event.name())); - io.println(" %s".formatted(event.description())); + io.println(" [%d] %s (ID: %d)".formatted(event.displayId(), event.name(), event.displayId())); + io.println(" Description: %s".formatted(event.description())); io.println( - " Commission: %d%% (%s)" + " Commission: %d%% (%s)" .formatted(event.commissionPercent(), timing(event.commissionTiming()))); - io.println(" Options: " + String.join(" | ", event.optionNames())); - io.println(" Status: " + event.status()); + io.println(" Options: " + String.join(" | ", event.optionNames())); + io.println(" Status: " + event.status()); } } private static String timing(CommissionTiming timing) { - return timing == CommissionTiming.ON_CLOSE ? "charged on close" : "charged on purchase"; + return timing == CommissionTiming.ON_CLOSE ? "on-close" : "on-purchase"; } } diff --git a/ui-console/src/main/java/market/guess/ui/console/command/LoadFileMenuCommand.java b/ui-console/src/main/java/market/guess/ui/console/command/LoadFileMenuCommand.java index c5b3c77..c36df90 100644 --- a/ui-console/src/main/java/market/guess/ui/console/command/LoadFileMenuCommand.java +++ b/ui-console/src/main/java/market/guess/ui/console/command/LoadFileMenuCommand.java @@ -24,16 +24,20 @@ public final class LoadFileMenuCommand implements MenuCommand { @Override public void execute() { - var path = io.readPath("Pleae enter the full file path to the XML file: "); + var path = io.readPath("Please enter the full file path to the XML file: "); var result = catalog.loadEvents(path); if (result.isSuccess()) { io.println( - "Loaded %d event(s) from %s." + "Successfully loaded %d event(s) from %s." .formatted(result.getData().eventsLoaded(), result.getData().source())); return; } - io.println("The file was not loaded, %s ".formatted(result.getDetails())); + var errorMsg = + result.getDetails() != null && !result.getDetails().isBlank() + ? result.getDetails() + : result.getMessage(); + io.println("The file was not loaded: %s".formatted(errorMsg)); } } diff --git a/ui-console/src/main/java/market/guess/ui/console/command/LoadMarketStateMenuCommand.java b/ui-console/src/main/java/market/guess/ui/console/command/LoadMarketStateMenuCommand.java index 30acef3..8fa77c2 100644 --- a/ui-console/src/main/java/market/guess/ui/console/command/LoadMarketStateMenuCommand.java +++ b/ui-console/src/main/java/market/guess/ui/console/command/LoadMarketStateMenuCommand.java @@ -25,11 +25,17 @@ public final class LoadMarketStateMenuCommand implements MenuCommand { @Override public void execute() { - var path = io.readPath("Which state file to load? "); + var path = io.readPath("Please enter full path to state file (without or with .json extension): "); var result = catalog.restoreState(path); if (result.isSuccess()) { - io.println("Restored %d event(s) from %s.".formatted(result.getData().eventsLoaded(), path)); + io.println("Successfully restored %d event(s) from %s.".formatted(result.getData().eventsLoaded(), path)); + } else { + var msg = + result.getDetails() != null && !result.getDetails().isBlank() + ? result.getDetails() + : result.getMessage(); + io.println("Failed to restore state: %s".formatted(msg)); } } } diff --git a/ui-console/src/main/java/market/guess/ui/console/command/PlaceOrderMenuCommand.java b/ui-console/src/main/java/market/guess/ui/console/command/PlaceOrderMenuCommand.java index a03f757..0d58b1d 100644 --- a/ui-console/src/main/java/market/guess/ui/console/command/PlaceOrderMenuCommand.java +++ b/ui-console/src/main/java/market/guess/ui/console/command/PlaceOrderMenuCommand.java @@ -34,11 +34,16 @@ public final class PlaceOrderMenuCommand implements MenuCommand { var result = catalog.getAllEvents(); if (!result.isSuccess()) { - io.println("Failed to list events"); + io.println("Failed to list events: %s.".formatted(result.getMessage())); return; } var events = result.getData(); + if (events.isEmpty()) { + io.println("No events are currently loaded. Please load a valid XML file first."); + return; + } + var active = events.stream().filter(event -> event.status() == EventStatus.ACTIVE).toList(); if (active.isEmpty()) { @@ -46,7 +51,9 @@ public final class PlaceOrderMenuCommand implements MenuCommand { return; } - var pick = io.readSelect("Pick an event: ", active, event -> "%s".formatted(event.name())); + io.newLine(); + io.println("Active events:"); + var pick = io.readSelect("Pick an active event: ", active, event -> "[ID: %d] %s".formatted(event.displayId(), event.name())); var result2 = catalog.getEvent(pick.key()); var event = result2.getData(); @@ -59,11 +66,11 @@ public final class PlaceOrderMenuCommand implements MenuCommand { var market = io.readSelect( - "Choose a market: ", + "Choose an option to buy: ", event.state().markets(), - o -> "%s [value %s, %s shares helds]".formatted(o.name(), o.price(), o.volume())); + o -> "%s [current value: %s, %s shares purchased]".formatted(o.name(), o.price(), o.volume())); - var amount = io.readInt("How many shares? ", 1, Integer.MAX_VALUE); + var amount = io.readInt("How many shares would you like to buy? ", 1, Integer.MAX_VALUE); var buyResult = context.placeOrder( diff --git a/ui-console/src/main/java/market/guess/ui/console/command/SaveMarketStateMenuCommand.java b/ui-console/src/main/java/market/guess/ui/console/command/SaveMarketStateMenuCommand.java index 3c5d6b9..78a5ca0 100644 --- a/ui-console/src/main/java/market/guess/ui/console/command/SaveMarketStateMenuCommand.java +++ b/ui-console/src/main/java/market/guess/ui/console/command/SaveMarketStateMenuCommand.java @@ -25,13 +25,17 @@ public final class SaveMarketStateMenuCommand implements MenuCommand { @Override public void execute() { - var path = io.readPath("Save to: "); + var path = io.readPath("Please enter full path to save file (without or with .json extension): "); - try { - catalog.saveState(path); - io.println("Saved current state to %s.".formatted(path)); - } catch (Exception e) { - io.println("Failed to save: %s".formatted(e.getMessage())); + var result = catalog.saveState(path); + if (result.isSuccess()) { + io.println("Successfully saved current market state to %s.".formatted(path)); + } else { + var msg = + result.getDetails() != null && !result.getDetails().isBlank() + ? result.getDetails() + : result.getMessage(); + io.println("Failed to save state: %s".formatted(msg)); } } } diff --git a/ui-console/src/main/java/market/guess/ui/console/command/SettleEventMenuCommand.java b/ui-console/src/main/java/market/guess/ui/console/command/SettleEventMenuCommand.java index 3457e08..bec25a7 100644 --- a/ui-console/src/main/java/market/guess/ui/console/command/SettleEventMenuCommand.java +++ b/ui-console/src/main/java/market/guess/ui/console/command/SettleEventMenuCommand.java @@ -32,11 +32,16 @@ public final class SettleEventMenuCommand implements MenuCommand { var result = catalog.getAllEvents(); if (!result.isSuccess()) { - io.println("Failed to list events"); + io.println("Failed to list events: %s.".formatted(result.getMessage())); return; } var events = result.getData(); + if (events.isEmpty()) { + io.println("No events are currently loaded. Please load a valid XML file first."); + return; + } + var active = events.stream().filter(event -> event.status() == EventStatus.ACTIVE).toList(); if (active.isEmpty()) { @@ -44,7 +49,9 @@ public final class SettleEventMenuCommand implements MenuCommand { return; } - var pick = io.readSelect("Pick an event: ", active, event -> "%s".formatted(event.name())); + io.newLine(); + io.println("Active events to settle:"); + var pick = io.readSelect("Pick an event to settle: ", active, event -> "[ID: %d] %s".formatted(event.displayId(), event.name())); var result2 = catalog.getEvent(pick.key()); var event = result2.getData(); @@ -56,12 +63,17 @@ public final class SettleEventMenuCommand implements MenuCommand { io.printState(event.state()); var winner = - io.readSelect("Which market won? ", event.state().markets(), o -> "%s".formatted(o.name())); + io.readSelect("Which option won? ", event.state().markets(), o -> "%s".formatted(o.name())); var settleResult = context.settleEvent("Tester", event.summary().key(), winner.key()); if (settleResult.isSuccess()) { + var settledEvent = settleResult.getData(); + io.newLine(); + io.println("Event closed successfully! Winning option: %s".formatted(settledEvent.settledMarket())); + io.printState(settledEvent.state()); + io.printHistory(settledEvent.history()); io.newLine(); - io.println("Settled. Winning market: %s".formatted(settleResult.getData().settledMarket())); + io.println("Winning option: %s".formatted(settledEvent.settledMarket())); return; } io.newLine(); diff --git a/ui-console/src/main/java/market/guess/ui/console/io/InputProcessor.java b/ui-console/src/main/java/market/guess/ui/console/io/InputProcessor.java index 721cf8b..3ff752c 100644 --- a/ui-console/src/main/java/market/guess/ui/console/io/InputProcessor.java +++ b/ui-console/src/main/java/market/guess/ui/console/io/InputProcessor.java @@ -20,20 +20,20 @@ public final class InputProcessor { private final Scanner scanner = new Scanner(System.in); public void print(Object object) { - IO.print(object); + System.out.print(object); } public void println(Object object) { - IO.println(object); + System.out.println(object); } public void newLine() { - IO.println(""); + System.out.println(""); } public void splashScreen() { newLine(); - IO.println(padLeft("<<- GUESS MARKET ->>", 36)); + System.out.println(padLeft("<<- GUESS MARKET ->>", 36)); } public void printState(MarketStateDTO state) { @@ -68,7 +68,8 @@ public final class InputProcessor { public String readLine(String prompt) { print(prompt); - return nextLine().trim(); + var line = nextLine(); + return line != null ? line.trim() : ""; } public int readInt(String prompt, int min, int max) { @@ -80,7 +81,6 @@ public final class InputProcessor { } try { var value = Integer.parseInt(raw); - if (value > max || value < min) { println( "%d is out of range, please enter a number between %d and %d." @@ -90,9 +90,8 @@ public final class InputProcessor { return value; } catch (NumberFormatException e) { println( - "%s in not a number, please enter a number between %d and %d." + "'%s' is not a valid number, please enter a number between %d and %d." .formatted(raw, min, max)); - continue; } } } @@ -100,12 +99,10 @@ public final class InputProcessor { public Path readPath(String prompt) { while (true) { var raw = readLine(prompt); - if (raw.isBlank()) { println("Please enter a file path."); continue; } - try { return Path.of(unquote(raw)); } catch (InvalidPathException e) { @@ -126,7 +123,6 @@ public final class InputProcessor { public boolean readYesNo(String prompt) { while (true) { var raw = readLine("%s [Y/N]: ".formatted(prompt)).toLowerCase(); - switch (raw) { case "y", "yes" -> { return true; @@ -134,14 +130,14 @@ public final class InputProcessor { case "n", "no" -> { return false; } - default -> IO.println("Please answer y/yes or n/no."); + default -> println("Please answer y/yes or n/no."); } } } public T readSelect(String prompt, List items, Function function) { for (var i = 0; i < items.size(); i++) { - IO.println(" [%d]->> %s".formatted(i + 1, function.apply(items.get(i)))); + println(" [%d]->> %s".formatted(i + 1, function.apply(items.get(i)))); } var pick = readInt(prompt, 1, items.size()); @@ -156,12 +152,16 @@ public final class InputProcessor { public void printReceipt(PurchaseReceiptDTO receipt) { newLine(); println("Purchase confirmed:"); - println(tradeRow("Option", "Qty", "Paid", "At")); + println(tradeRow("Option", "Qty", "Cost", "At")); println("-".repeat(OPTION_WIDTH + QTY_WIDTH + PAID_WIDTH + AT_WIDTH + 3)); for (var trade : receipt.trades()) { println(tradeRow(trade.optionName(), trade.quantity(), trade.pricePaid(), trade.at())); } + newLine(); + println("Shares cost: %s".formatted(receipt.sharesCost())); + println("Commission paid: %s".formatted(receipt.commissionPaid())); + println("Total amount paid: %s".formatted(receipt.totalPaid())); printState(receipt.stateAfter()); } @@ -175,7 +175,7 @@ public final class InputProcessor { return; } - println(tradeRow("Option", "Qty", "Paid", "At")); + println(tradeRow("Option", "Qty", "Cost", "At")); println("-".repeat(OPTION_WIDTH + QTY_WIDTH + PAID_WIDTH + AT_WIDTH + 3)); for (var trade : history) { @@ -192,3 +192,4 @@ public final class InputProcessor { padRight(at, AT_WIDTH)); } } + -- cgit v1.2.3