diff options
Diffstat (limited to 'service/src')
6 files changed, 485 insertions, 27 deletions
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; | |||
| 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.fulfillment.FulfillmentContext; | 12 | import market.guess.service.fulfillment.FulfillmentContext; |
| 13 | import market.guess.service.helpers.BigDecimalOptions; | ||
| 13 | import market.guess.service.helpers.InstantOptions; | 14 | import market.guess.service.helpers.InstantOptions; |
| 14 | import market.guess.service.matching.MatchingEngine; | 15 | import market.guess.service.matching.MatchingEngine; |
| 15 | import market.guess.service.risk.RiskEngine; | 16 | import market.guess.service.risk.RiskEngine; |
| @@ -55,6 +56,20 @@ public final class LocalGuessMarketContext implements GuessMarketContext { | |||
| 55 | 56 | ||
| 56 | var rawTrades = matching.match(order); | 57 | var rawTrades = matching.match(order); |
| 57 | var trades = fulfillment.fulfill(user, order, rawTrades); | 58 | var trades = fulfillment.fulfill(user, order, rawTrades); |
| 59 | |||
| 60 | var totalSharesCost = | ||
| 61 | trades.stream() | ||
| 62 | .map(t -> t.sharesCost()) | ||
| 63 | .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); | ||
| 64 | var totalCommission = | ||
| 65 | trades.stream() | ||
| 66 | .map(t -> t.commission()) | ||
| 67 | .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); | ||
| 68 | var totalPaid = | ||
| 69 | trades.stream() | ||
| 70 | .map(t -> t.totalPaid()) | ||
| 71 | .reduce(BigDecimalOptions.ZERO_MONEY, BigDecimal::add); | ||
| 72 | |||
| 58 | return Result.ok( | 73 | return Result.ok( |
| 59 | new PurchaseReceiptDTO( | 74 | new PurchaseReceiptDTO( |
| 60 | trades.stream() | 75 | trades.stream() |
| @@ -65,8 +80,11 @@ public final class LocalGuessMarketContext implements GuessMarketContext { | |||
| 65 | t.buyerUserName(), | 80 | t.buyerUserName(), |
| 66 | t.marketName(), | 81 | t.marketName(), |
| 67 | String.valueOf(t.quantity()), | 82 | String.valueOf(t.quantity()), |
| 68 | t.totalPaid().toPlainString())) | 83 | t.sharesCost().toPlainString())) |
| 69 | .toList(), | 84 | .toList(), |
| 85 | totalSharesCost.toPlainString(), | ||
| 86 | totalCommission.toPlainString(), | ||
| 87 | totalPaid.toPlainString(), | ||
| 70 | event.toMarketState())); | 88 | event.toMarketState())); |
| 71 | } | 89 | } |
| 72 | 90 | ||
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 { | |||
| 67 | GSON.toJson(new Wrapper(events.getAll(), users.getAll())), | 67 | GSON.toJson(new Wrapper(events.getAll(), users.getAll())), |
| 68 | StandardCharsets.UTF_8); | 68 | StandardCharsets.UTF_8); |
| 69 | } catch (Exception e) { | 69 | } catch (Exception e) { |
| 70 | throw new GuessMarketException("Failed to sove the file."); | 70 | throw new GuessMarketException("Failed to save state to " + target + ": " + e.getMessage()); |
| 71 | } | 71 | } |
| 72 | } | 72 | } |
| 73 | 73 | ||
| 74 | public void load(Path path) throws GuessMarketException { | 74 | public void load(Path path) throws GuessMarketException { |
| 75 | var target = withJsonExtension(path); | ||
| 76 | if (!Files.isRegularFile(target)) { | ||
| 77 | throw new GuessMarketException("State file not found: " + target); | ||
| 78 | } | ||
| 75 | try { | 79 | try { |
| 76 | var wrapper = | 80 | var wrapper = |
| 77 | GSON.fromJson( | 81 | GSON.fromJson( |
| 78 | Files.readString(withJsonExtension(path), StandardCharsets.UTF_8), Wrapper.class); | 82 | Files.readString(target, StandardCharsets.UTF_8), Wrapper.class); |
| 83 | if (wrapper == null || wrapper.events() == null) { | ||
| 84 | throw new GuessMarketException("State file contains invalid or empty data."); | ||
| 85 | } | ||
| 79 | events.clear(); | 86 | events.clear(); |
| 80 | users.clear(); | 87 | users.clear(); |
| 81 | 88 | ||
| @@ -88,14 +95,15 @@ public final class MarketContext { | |||
| 88 | } | 95 | } |
| 89 | 96 | ||
| 90 | } catch (JsonSyntaxException e) { | 97 | } catch (JsonSyntaxException e) { |
| 91 | throw new GuessMarketException("The JSON file is malformed."); | 98 | throw new GuessMarketException("The state file is malformed JSON: " + e.getMessage()); |
| 92 | } catch (IOException e) { | 99 | } catch (IOException e) { |
| 93 | throw new GuessMarketException("Unable the file."); | 100 | throw new GuessMarketException("Unable to read state file: " + e.getMessage()); |
| 94 | } | 101 | } |
| 95 | } | 102 | } |
| 96 | 103 | ||
| 97 | private static Path withJsonExtension(Path path) { | 104 | private static Path withJsonExtension(Path path) { |
| 105 | if (path == null) return null; | ||
| 98 | var name = path.getFileName().toString(); | 106 | var name = path.getFileName().toString(); |
| 99 | return name.endsWith(".json") ? path : path.resolveSibling(name + ".json"); | 107 | return name.toLowerCase().endsWith(".json") ? path : path.resolveSibling(name + ".json"); |
| 100 | } | 108 | } |
| 101 | } | 109 | } |
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<GMEvent, Event> { | |||
| 19 | return new Event( | 19 | return new Event( |
| 20 | getEventKey(source), | 20 | getEventKey(source), |
| 21 | source.getId(), | 21 | source.getId(), |
| 22 | joinName(source.getName()), | 22 | joinName(source.getName()).trim(), |
| 23 | source.getDescription(), | 23 | source.getDescription() != null ? source.getDescription().trim() : "", |
| 24 | source.getComision().getValue(), | 24 | source.getComision().getValue(), |
| 25 | getTiming(source.getComision().getType()), | 25 | getTiming(source.getComision().getType()), |
| 26 | new LmsrTradingMechanism(source.getGMMethod().getGMLMSR().getB(), options.size()), | 26 | new LmsrTradingMechanism(source.getGMMethod().getGMLMSR().getB(), options.size()), |
| @@ -38,17 +38,20 @@ public final class EventMapperV1 implements Mapper<GMEvent, Event> { | |||
| 38 | } | 38 | } |
| 39 | 39 | ||
| 40 | private static CommissionTiming getTiming(String type) throws IllegalArgumentException { | 40 | private static CommissionTiming getTiming(String type) throws IllegalArgumentException { |
| 41 | return switch (type) { | 41 | if (type == null) throw new IllegalArgumentException("Commission type cannot be null"); |
| 42 | return switch (type.toLowerCase().trim()) { | ||
| 42 | case "on-close" -> CommissionTiming.ON_CLOSE; | 43 | case "on-close" -> CommissionTiming.ON_CLOSE; |
| 43 | case "on-purchase" -> CommissionTiming.ON_PURCHASE; | 44 | case "on-purchase" -> CommissionTiming.ON_PURCHASE; |
| 44 | default -> throw new IllegalArgumentException("Unknown comision type: " + type); | 45 | default -> throw new IllegalArgumentException("Unknown commission type: " + type); |
| 45 | }; | 46 | }; |
| 46 | } | 47 | } |
| 47 | 48 | ||
| 48 | private static List<Market> getOptions(GMEvent x) { | 49 | private static List<Market> getOptions(GMEvent x) { |
| 49 | var ret = new ArrayList<Market>(x.getGMOptions().getGMOption().size()); | 50 | var raw = x.getGMOptions().getGMOption(); |
| 50 | for (var i = 0; i < x.getGMOptions().getGMOption().size(); i++) { | 51 | var ret = new ArrayList<Market>(raw.size()); |
| 51 | ret.add(new Market(getEventKey(x) + ":" + i, x.getGMOptions().getGMOption().get(i))); | 52 | for (var i = 0; i < raw.size(); i++) { |
| 53 | var opt = raw.get(i); | ||
| 54 | ret.add(new Market(getEventKey(x) + ":" + i, opt != null ? opt.trim() : "")); | ||
| 52 | } | 55 | } |
| 53 | return ret; | 56 | return ret; |
| 54 | } | 57 | } |
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; | |||
| 2 | 2 | ||
| 3 | import jakarta.xml.bind.JAXBContext; | 3 | import jakarta.xml.bind.JAXBContext; |
| 4 | import jakarta.xml.bind.JAXBException; | 4 | import jakarta.xml.bind.JAXBException; |
| 5 | import java.math.BigDecimal; | ||
| 5 | import java.nio.file.Files; | 6 | import java.nio.file.Files; |
| 6 | import java.nio.file.Path; | 7 | import java.nio.file.Path; |
| 8 | import java.util.ArrayList; | ||
| 7 | import market.guess.api.CatalogContext; | 9 | import market.guess.api.CatalogContext; |
| 8 | import market.guess.api.LoadResult; | 10 | import market.guess.api.LoadResult; |
| 9 | import market.guess.api.Result; | 11 | import market.guess.api.Result; |
| @@ -12,6 +14,7 @@ import market.guess.service.catalog.infrastructure.provider.Loader; | |||
| 12 | import market.guess.service.catalog.infrastructure.repository.EventRepository; | 14 | import market.guess.service.catalog.infrastructure.repository.EventRepository; |
| 13 | import market.guess.service.catalog.infrastructure.repository.UserRepository; | 15 | import market.guess.service.catalog.infrastructure.repository.UserRepository; |
| 14 | import market.guess.service.catalog.model.v1.GuessMarket; | 16 | import market.guess.service.catalog.model.v1.GuessMarket; |
| 17 | import market.guess.service.domain.Event; | ||
| 15 | import market.guess.service.domain.User; | 18 | import market.guess.service.domain.User; |
| 16 | 19 | ||
| 17 | public final class XMLLoaderV1 implements Loader { | 20 | public final class XMLLoaderV1 implements Loader { |
| @@ -35,19 +38,23 @@ public final class XMLLoaderV1 implements Loader { | |||
| 35 | try { | 38 | try { |
| 36 | context = JAXBContext.newInstance(GuessMarket.class); | 39 | context = JAXBContext.newInstance(GuessMarket.class); |
| 37 | } catch (JAXBException e) { | 40 | } catch (JAXBException e) { |
| 38 | throw new IllegalStateException(); | 41 | throw new IllegalStateException("Failed to initialize JAXB context: " + e.getMessage(), e); |
| 39 | } | 42 | } |
| 40 | } | 43 | } |
| 41 | 44 | ||
| 42 | @Override | 45 | @Override |
| 43 | public Result<LoadResult> load(Path path) { | 46 | public Result<LoadResult> load(Path path) { |
| 47 | if (path == null) { | ||
| 48 | return Result.error("INVALID_PATH", "File path cannot be null."); | ||
| 49 | } | ||
| 50 | |||
| 44 | if (!Files.isRegularFile(path)) { | 51 | if (!Files.isRegularFile(path)) { |
| 45 | return Result.error("FILE_NOT_FOUND", "No such file."); | 52 | return Result.error("FILE_NOT_FOUND", "No such file: " + path); |
| 46 | } | 53 | } |
| 47 | 54 | ||
| 48 | if (path.getFileName() == null | 55 | if (path.getFileName() == null |
| 49 | || !path.getFileName().toString().toLowerCase().endsWith(".xml")) { | 56 | || !path.getFileName().toString().toLowerCase().endsWith(".xml")) { |
| 50 | return Result.error("NOT_XML", "The file must be an XML."); | 57 | return Result.error("NOT_XML", "The file must have a .xml extension."); |
| 51 | } | 58 | } |
| 52 | 59 | ||
| 53 | try (var stream = Files.newInputStream(path)) { | 60 | try (var stream = Files.newInputStream(path)) { |
| @@ -59,21 +66,34 @@ public final class XMLLoaderV1 implements Loader { | |||
| 59 | return Result.error(validation.getMessage(), validation.getDetails()); | 66 | return Result.error(validation.getMessage(), validation.getDetails()); |
| 60 | } | 67 | } |
| 61 | 68 | ||
| 69 | var domainEvents = new ArrayList<Event>(); | ||
| 62 | for (var event : seed.getGMEvents().getGMEvent()) { | 70 | for (var event : seed.getGMEvents().getGMEvent()) { |
| 63 | eventRepository.add(mapper.toDomain(event)); | 71 | domainEvents.add(mapper.toDomain(event)); |
| 72 | } | ||
| 73 | |||
| 74 | eventRepository.clear(); | ||
| 75 | userRepository.clear(); | ||
| 76 | |||
| 77 | for (var domainEvent : domainEvents) { | ||
| 78 | eventRepository.add(domainEvent); | ||
| 64 | } | 79 | } |
| 65 | 80 | ||
| 66 | userRepository.add(new User("Tester", 500)); | 81 | userRepository.add(new User("Tester", new BigDecimal("500.00"))); |
| 67 | 82 | ||
| 68 | return Result.ok(new LoadResult(path.toString(), eventRepository.getAll().size())); | 83 | return Result.ok(new LoadResult(path.toString(), eventRepository.getAll().size())); |
| 84 | } catch (JAXBException e) { | ||
| 85 | var cause = | ||
| 86 | e.getLinkedException() != null ? e.getLinkedException().getMessage() : e.getMessage(); | ||
| 87 | return Result.error( | ||
| 88 | "XML_PARSE_ERROR", | ||
| 89 | "The XML file is malformed or invalid: " + (cause != null ? cause : e.getMessage())); | ||
| 69 | } catch (Exception e) { | 90 | } catch (Exception e) { |
| 70 | return Result.error("UNREADABLE", "The file could not be read."); | 91 | return Result.error("UNREADABLE", "The file could not be read: " + e.getMessage()); |
| 71 | } | 92 | } |
| 72 | } | 93 | } |
| 73 | 94 | ||
| 74 | @Override | 95 | @Override |
| 75 | public Result<LoadResult> seed(CatalogContext context) { | 96 | public Result<LoadResult> seed(CatalogContext context) { |
| 76 | // TODO Auto-generated method stub | ||
| 77 | throw new UnsupportedOperationException("Unimplemented method 'seed'"); | 97 | throw new UnsupportedOperationException("Unimplemented method 'seed'"); |
| 78 | } | 98 | } |
| 79 | } | 99 | } |
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 { | |||
| 9 | private static final int MAX_COMMISSION = 90; | 9 | private static final int MAX_COMMISSION = 90; |
| 10 | 10 | ||
| 11 | public Result<Void> validate(GuessMarket seed) { | 11 | public Result<Void> validate(GuessMarket seed) { |
| 12 | if (seed == null || seed.getGMEvents() == null || seed.getGMEvents().getGMEvent() == null) { | ||
| 13 | return Result.error("INVALID_XML", "The XML file contains no GM-events element."); | ||
| 14 | } | ||
| 12 | 15 | ||
| 13 | var seenIds = new HashSet<Integer>(); | 16 | var events = seed.getGMEvents().getGMEvent(); |
| 14 | var duplicated = new HashSet<Integer>(); | 17 | if (events.isEmpty()) { |
| 18 | return Result.error("EMPTY_EVENTS", "The XML file does not contain any events."); | ||
| 19 | } | ||
| 15 | 20 | ||
| 16 | for (var e : seed.getGMEvents().getGMEvent()) { | 21 | var seenIds = new HashSet<Integer>(); |
| 17 | 22 | ||
| 18 | if (!seenIds.add(e.getId()) && duplicated.add(e.getId())) { | 23 | for (var e : events) { |
| 24 | // 1. Event ID must be positive integer (> 0) | ||
| 25 | if (e.getId() <= 0) { | ||
| 26 | return Result.error( | ||
| 27 | "INVALID_ID", | ||
| 28 | "Event ID must be a positive integer (> 0), found: " + e.getId()); | ||
| 29 | } | ||
| 30 | if (!seenIds.add(e.getId())) { | ||
| 19 | return Result.error( | 31 | return Result.error( |
| 20 | "DUPLICATE_ID", | 32 | "DUPLICATE_ID", |
| 21 | "Event id " + e.getId() + " appears more than once, every event id must be unique."); | 33 | "Event ID " + e.getId() + " appears more than once. Every event ID must be unique."); |
| 22 | } | 34 | } |
| 23 | 35 | ||
| 36 | // 2. Event name must not be empty | ||
| 37 | if (e.getName() == null || e.getName().isEmpty() || String.join(" ", e.getName()).isBlank()) { | ||
| 38 | return Result.error("MISSING_NAME", "Event ID " + e.getId() + " has an empty name."); | ||
| 39 | } | ||
| 40 | |||
| 41 | // 3. Event description must not be empty | ||
| 42 | if (e.getDescription() == null || e.getDescription().isBlank()) { | ||
| 43 | return Result.error("MISSING_DESCRIPTION", "Event ID " + e.getId() + " has an empty description."); | ||
| 44 | } | ||
| 45 | |||
| 46 | // 4. Commission validation | ||
| 47 | if (e.getComision() == null) { | ||
| 48 | return Result.error("MISSING_COMMISSION", "Event ID " + e.getId() + " is missing commission."); | ||
| 49 | } | ||
| 24 | var pct = e.getComision().getValue(); | 50 | var pct = e.getComision().getValue(); |
| 25 | if (pct < MIN_COMMISSION || pct > MAX_COMMISSION) { | 51 | if (pct < MIN_COMMISSION || pct > MAX_COMMISSION) { |
| 26 | return Result.error( | 52 | return Result.error( |
| 27 | "COMMISSION_OUT_OF_RANGE", | 53 | "COMMISSION_OUT_OF_RANGE", |
| 28 | "Commission is %d%%, it must be between %d%% and %d%%." | 54 | "Event ID " + e.getId() + " commission is " + pct + "%. It must be between " + MIN_COMMISSION + "% and " + MAX_COMMISSION + "%."); |
| 29 | .formatted(pct, MIN_COMMISSION, MAX_COMMISSION)); | 55 | } |
| 56 | |||
| 57 | var type = e.getComision().getType(); | ||
| 58 | if (type == null || (!type.equalsIgnoreCase("on-close") && !type.equalsIgnoreCase("on-purchase"))) { | ||
| 59 | return Result.error( | ||
| 60 | "INVALID_COMMISSION_TYPE", | ||
| 61 | "Event ID " + e.getId() + " has invalid commission type '" + type + "'. Must be 'on-close' or 'on-purchase'."); | ||
| 62 | } | ||
| 63 | |||
| 64 | // 5. GM-options validation (exactly 2 options for Exercise 1) | ||
| 65 | if (e.getGMOptions() == null || e.getGMOptions().getGMOption() == null) { | ||
| 66 | return Result.error("MISSING_OPTIONS", "Event ID " + e.getId() + " is missing GM-options."); | ||
| 67 | } | ||
| 68 | var options = e.getGMOptions().getGMOption(); | ||
| 69 | if (options.size() != 2) { | ||
| 70 | return Result.error( | ||
| 71 | "INVALID_OPTIONS_COUNT", | ||
| 72 | "Event ID " + e.getId() + " must have exactly 2 options for Exercise 1, but found " + options.size() + "."); | ||
| 73 | } | ||
| 74 | var opt0 = options.get(0) == null ? "" : options.get(0).trim(); | ||
| 75 | var opt1 = options.get(1) == null ? "" : options.get(1).trim(); | ||
| 76 | if (opt0.isEmpty() || opt1.isEmpty()) { | ||
| 77 | return Result.error("EMPTY_OPTION", "Event ID " + e.getId() + " has an empty option name."); | ||
| 78 | } | ||
| 79 | if (opt0.equalsIgnoreCase(opt1)) { | ||
| 80 | return Result.error("DUPLICATE_OPTION", "Event ID " + e.getId() + " has duplicate option names: '" + opt0 + "'."); | ||
| 81 | } | ||
| 82 | |||
| 83 | // 6. GM-method and LMSR validation | ||
| 84 | if (e.getGMMethod() == null) { | ||
| 85 | return Result.error("MISSING_METHOD", "Event ID " + e.getId() + " is missing GM-method."); | ||
| 86 | } | ||
| 87 | if (e.getGMMethod().getGMLMSR() == null) { | ||
| 88 | return Result.error("INVALID_METHOD", "Event ID " + e.getId() + " must use LMSR trading method in Exercise 1."); | ||
| 89 | } | ||
| 90 | var b = e.getGMMethod().getGMLMSR().getB(); | ||
| 91 | if (b <= 0) { | ||
| 92 | return Result.error( | ||
| 93 | "INVALID_LIQUIDITY", | ||
| 94 | "Event ID " + e.getId() + " has invalid liquidity b=" + b + ". b must be a positive integer (> 0)."); | ||
| 30 | } | 95 | } |
| 31 | } | 96 | } |
| 32 | 97 | ||
| 33 | return Result.ok(); | 98 | return Result.ok(); |
| 34 | } | 99 | } |
| 35 | } | 100 | } |
| 101 | |||
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 | } | ||