aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--api/src/main/java/market/guess/api/PurchaseReceiptDTO.java7
-rw-r--r--service/src/main/java/market/guess/service/LocalGuessMarketContext.java20
-rw-r--r--service/src/main/java/market/guess/service/catalog/infrastructure/MarketContext.java18
-rw-r--r--service/src/main/java/market/guess/service/catalog/infrastructure/mapper/v1/EventMapperV1.java17
-rw-r--r--service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLLoaderV1.java34
-rw-r--r--service/src/main/java/market/guess/service/catalog/infrastructure/provider/v1/XMLValidatorV1.java80
-rw-r--r--service/src/test/java/market/guess/service/Ex1FullTest.java343
-rw-r--r--tools/_util.sh7
-rw-r--r--tools/package.sh2
-rw-r--r--ui-console/src/main/java/market/guess/ui/console/command/EventDetailsMenuCommand.java31
-rw-r--r--ui-console/src/main/java/market/guess/ui/console/command/ListEventsMenuCommand.java19
-rw-r--r--ui-console/src/main/java/market/guess/ui/console/command/LoadFileMenuCommand.java10
-rw-r--r--ui-console/src/main/java/market/guess/ui/console/command/LoadMarketStateMenuCommand.java10
-rw-r--r--ui-console/src/main/java/market/guess/ui/console/command/PlaceOrderMenuCommand.java17
-rw-r--r--ui-console/src/main/java/market/guess/ui/console/command/SaveMarketStateMenuCommand.java16
-rw-r--r--ui-console/src/main/java/market/guess/ui/console/command/SettleEventMenuCommand.java20
-rw-r--r--ui-console/src/main/java/market/guess/ui/console/io/InputProcessor.java31
18 files changed, 608 insertions, 76 deletions
diff --git a/.gitignore b/.gitignore
index 862ee08..9b4e8b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@ stage/
4build/ 4build/
5*.class 5*.class
6 6
7
8README.*
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;
2 2
3import java.util.List; 3import java.util.List;
4 4
5public record PurchaseReceiptDTO(List<TradeRowDTO> trades, MarketStateDTO stateAfter) {} 5public record PurchaseReceiptDTO(
6 List<TradeRowDTO> trades,
7 String sharesCost,
8 String commissionPaid,
9 String totalPaid,
10 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;
10import market.guess.service.catalog.infrastructure.MarketContext; 10import market.guess.service.catalog.infrastructure.MarketContext;
11import market.guess.service.domain.Order; 11import market.guess.service.domain.Order;
12import market.guess.service.fulfillment.FulfillmentContext; 12import market.guess.service.fulfillment.FulfillmentContext;
13import market.guess.service.helpers.BigDecimalOptions;
13import market.guess.service.helpers.InstantOptions; 14import market.guess.service.helpers.InstantOptions;
14import market.guess.service.matching.MatchingEngine; 15import market.guess.service.matching.MatchingEngine;
15import market.guess.service.risk.RiskEngine; 16import 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
3import jakarta.xml.bind.JAXBContext; 3import jakarta.xml.bind.JAXBContext;
4import jakarta.xml.bind.JAXBException; 4import jakarta.xml.bind.JAXBException;
5import java.math.BigDecimal;
5import java.nio.file.Files; 6import java.nio.file.Files;
6import java.nio.file.Path; 7import java.nio.file.Path;
8import java.util.ArrayList;
7import market.guess.api.CatalogContext; 9import market.guess.api.CatalogContext;
8import market.guess.api.LoadResult; 10import market.guess.api.LoadResult;
9import market.guess.api.Result; 11import market.guess.api.Result;
@@ -12,6 +14,7 @@ import market.guess.service.catalog.infrastructure.provider.Loader;
12import market.guess.service.catalog.infrastructure.repository.EventRepository; 14import market.guess.service.catalog.infrastructure.repository.EventRepository;
13import market.guess.service.catalog.infrastructure.repository.UserRepository; 15import market.guess.service.catalog.infrastructure.repository.UserRepository;
14import market.guess.service.catalog.model.v1.GuessMarket; 16import market.guess.service.catalog.model.v1.GuessMarket;
17import market.guess.service.domain.Event;
15import market.guess.service.domain.User; 18import market.guess.service.domain.User;
16 19
17public final class XMLLoaderV1 implements Loader { 20public 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 @@
1package market.guess.service;
2
3import static org.junit.jupiter.api.Assertions.*;
4
5import java.math.BigDecimal;
6import java.nio.file.Files;
7import java.nio.file.Path;
8import java.time.Instant;
9import java.util.List;
10import market.guess.api.CommissionTiming;
11import market.guess.model.event.EventStatus;
12import market.guess.service.catalog.LocalCatalogContext;
13import market.guess.service.catalog.infrastructure.MarketContext;
14import market.guess.service.catalog.infrastructure.mapper.v1.EventMapperV1;
15import market.guess.service.catalog.infrastructure.provider.v1.XMLLoaderV1;
16import market.guess.service.catalog.infrastructure.provider.v1.XMLValidatorV1;
17import market.guess.service.catalog.infrastructure.repository.InMemoryEventRepository;
18import market.guess.service.catalog.infrastructure.repository.InMemoryUserRepository;
19import market.guess.service.domain.Event;
20import market.guess.service.domain.Market;
21import market.guess.service.domain.User;
22import market.guess.service.fulfillment.LocalFulfillmentContext;
23import market.guess.service.helpers.BigDecimalOptions;
24import market.guess.service.ledger.LedgerContext;
25import market.guess.service.matching.LocalMatchingEngine;
26import market.guess.service.mechanism.LmsrTradingMechanism;
27import market.guess.service.risk.LocalRiskEngine;
28import market.guess.service.settlement.LocalSettlementContext;
29import org.junit.jupiter.api.BeforeEach;
30import org.junit.jupiter.api.Test;
31
32public 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}
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"
15 15
16MAIN_CLASS="market.guess.ui.console.App" 16MAIN_CLASS="market.guess.ui.console.App"
17 17
18JAVA_HOME_DIR=$(java -XshowSettings:properties -version 2>&1 | sed -n 's/^ *java\.home = //p') 18JAVA_HOME_DIR=$(java -XshowSettings:properties -version 2>&1 | tr -d '\r' | sed -n 's/^ *java\.home = //p')
19case "$(uname -s)" in
20 CYGWIN*|MINGW*|MSYS*)
21 JAVA_HOME_DIR=$(cygpath -u "$JAVA_HOME_DIR" 2>/dev/null || echo "$JAVA_HOME_DIR")
22 ;;
23esac
19JAR="$JAVA_HOME_DIR/bin/jar" 24JAR="$JAVA_HOME_DIR/bin/jar"
20[ -x "$JAR" ] || JAR="$JAVA_HOME_DIR/bin/jar.exe" 25[ -x "$JAR" ] || JAR="$JAVA_HOME_DIR/bin/jar.exe"
21if [ ! -x "$JAR" ]; then 26if [ ! -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"
60if errorlevel 1 pause 60if errorlevel 1 pause
61EOF 61EOF
62 62
63[ -f README.pdf ] && cp README.pdf "$DIST/"
64
63echo "=== Manifest as it actually landed in the jar ===" 65echo "=== Manifest as it actually landed in the jar ==="
64# Cheapest check that prevents a Level-0: if Main-Class or Class-Path didn't 66# Cheapest check that prevents a Level-0: if Main-Class or Class-Path didn't
65# survive, it surfaces here instead of on the grader's machine. 67# 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 {
29 var result = catalog.getAllEvents(); 29 var result = catalog.getAllEvents();
30 30
31 if (!result.isSuccess()) { 31 if (!result.isSuccess()) {
32 io.println("Failed to list events"); 32 io.println("Failed to list events: %s.".formatted(result.getMessage()));
33 return; 33 return;
34 } 34 }
35 35
@@ -37,11 +37,17 @@ public final class EventDetailsMenuCommand implements MenuCommand {
37 var viewable = events.stream().filter(event -> event.status() != EventStatus.DRAFT).toList(); 37 var viewable = events.stream().filter(event -> event.status() != EventStatus.DRAFT).toList();
38 38
39 if (viewable.isEmpty()) { 39 if (viewable.isEmpty()) {
40 io.println("There are currently no events to show."); 40 io.println("No events are currently loaded. Please load a valid XML file first.");
41 return; 41 return;
42 } 42 }
43 43
44 var pick = io.readSelect("Pick an event: ", viewable, event -> "%s".formatted(event.name())); 44 io.newLine();
45 io.println("Available events:");
46 var pick =
47 io.readSelect(
48 "Pick an event: ",
49 viewable,
50 event -> "[ID: %d] %s (%s)".formatted(event.displayId(), event.name(), event.status()));
45 51
46 var result2 = catalog.getEvent(pick.key()); 52 var result2 = catalog.getEvent(pick.key());
47 var event = result2.getData(); 53 var event = result2.getData();
@@ -50,11 +56,26 @@ public final class EventDetailsMenuCommand implements MenuCommand {
50 return; 56 return;
51 } 57 }
52 58
59 io.newLine();
60 io.println("=== Event Details ===");
61 io.println("Event ID: %d".formatted(event.summary().displayId()));
62 io.println("Name: %s".formatted(event.summary().name()));
63 io.println("Description: %s".formatted(event.summary().description()));
64 io.println(
65 "Commission: %d%% (%s)"
66 .formatted(
67 event.summary().commissionPercent(),
68 event.summary().commissionTiming() == market.guess.api.CommissionTiming.ON_CLOSE
69 ? "on-close"
70 : "on-purchase"));
71 io.println("Status: %s".formatted(event.summary().status()));
72
53 io.printState(event.state()); 73 io.printState(event.state());
54 io.printHistory(event.history()); 74 io.printHistory(event.history());
55 75
56 if (!event.settledMarket().isBlank()) { 76 if (event.settledMarket() != null && !event.settledMarket().isBlank()) {
57 io.println("Winning market: %s".formatted(event.settledMarket())); 77 io.newLine();
78 io.println("Winning option: %s".formatted(event.settledMarket()));
58 } 79 }
59 } 80 }
60} 81}
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 {
29 var result = catalog.getAllEvents(); 29 var result = catalog.getAllEvents();
30 30
31 if (!result.isSuccess()) { 31 if (!result.isSuccess()) {
32 io.println("Failed to list events due to %s.".formatted(result.getMessage())); 32 io.println("Failed to list events: %s.".formatted(result.getMessage()));
33 return; 33 return;
34 } 34 }
35 var events = result.getData(); 35 var events = result.getData();
36 36
37 if (events.isEmpty()) {
38 io.println("No events are currently loaded. Please load a valid XML file first.");
39 return;
40 }
41
37 for (var event : events) { 42 for (var event : events) {
38 io.newLine(); 43 io.newLine();
39 io.println(" %s".formatted(event.name())); 44 io.println(" [%d] %s (ID: %d)".formatted(event.displayId(), event.name(), event.displayId()));
40 io.println(" %s".formatted(event.description())); 45 io.println(" Description: %s".formatted(event.description()));
41 io.println( 46 io.println(
42 " Commission: %d%% (%s)" 47 " Commission: %d%% (%s)"
43 .formatted(event.commissionPercent(), timing(event.commissionTiming()))); 48 .formatted(event.commissionPercent(), timing(event.commissionTiming())));
44 io.println(" Options: " + String.join(" | ", event.optionNames())); 49 io.println(" Options: " + String.join(" | ", event.optionNames()));
45 io.println(" Status: " + event.status()); 50 io.println(" Status: " + event.status());
46 } 51 }
47 } 52 }
48 53
49 private static String timing(CommissionTiming timing) { 54 private static String timing(CommissionTiming timing) {
50 return timing == CommissionTiming.ON_CLOSE ? "charged on close" : "charged on purchase"; 55 return timing == CommissionTiming.ON_CLOSE ? "on-close" : "on-purchase";
51 } 56 }
52} 57}
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 {
24 24
25 @Override 25 @Override
26 public void execute() { 26 public void execute() {
27 var path = io.readPath("Pleae enter the full file path to the XML file: "); 27 var path = io.readPath("Please enter the full file path to the XML file: ");
28 var result = catalog.loadEvents(path); 28 var result = catalog.loadEvents(path);
29 29
30 if (result.isSuccess()) { 30 if (result.isSuccess()) {
31 io.println( 31 io.println(
32 "Loaded %d event(s) from %s." 32 "Successfully loaded %d event(s) from %s."
33 .formatted(result.getData().eventsLoaded(), result.getData().source())); 33 .formatted(result.getData().eventsLoaded(), result.getData().source()));
34 return; 34 return;
35 } 35 }
36 36
37 io.println("The file was not loaded, %s ".formatted(result.getDetails())); 37 var errorMsg =
38 result.getDetails() != null && !result.getDetails().isBlank()
39 ? result.getDetails()
40 : result.getMessage();
41 io.println("The file was not loaded: %s".formatted(errorMsg));
38 } 42 }
39} 43}
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 {
25 25
26 @Override 26 @Override
27 public void execute() { 27 public void execute() {
28 var path = io.readPath("Which state file to load? "); 28 var path = io.readPath("Please enter full path to state file (without or with .json extension): ");
29 var result = catalog.restoreState(path); 29 var result = catalog.restoreState(path);
30 30
31 if (result.isSuccess()) { 31 if (result.isSuccess()) {
32 io.println("Restored %d event(s) from %s.".formatted(result.getData().eventsLoaded(), path)); 32 io.println("Successfully restored %d event(s) from %s.".formatted(result.getData().eventsLoaded(), path));
33 } else {
34 var msg =
35 result.getDetails() != null && !result.getDetails().isBlank()
36 ? result.getDetails()
37 : result.getMessage();
38 io.println("Failed to restore state: %s".formatted(msg));
33 } 39 }
34 } 40 }
35} 41}
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 {
34 var result = catalog.getAllEvents(); 34 var result = catalog.getAllEvents();
35 35
36 if (!result.isSuccess()) { 36 if (!result.isSuccess()) {
37 io.println("Failed to list events"); 37 io.println("Failed to list events: %s.".formatted(result.getMessage()));
38 return; 38 return;
39 } 39 }
40 40
41 var events = result.getData(); 41 var events = result.getData();
42 if (events.isEmpty()) {
43 io.println("No events are currently loaded. Please load a valid XML file first.");
44 return;
45 }
46
42 var active = events.stream().filter(event -> event.status() == EventStatus.ACTIVE).toList(); 47 var active = events.stream().filter(event -> event.status() == EventStatus.ACTIVE).toList();
43 48
44 if (active.isEmpty()) { 49 if (active.isEmpty()) {
@@ -46,7 +51,9 @@ public final class PlaceOrderMenuCommand implements MenuCommand {
46 return; 51 return;
47 } 52 }
48 53
49 var pick = io.readSelect("Pick an event: ", active, event -> "%s".formatted(event.name())); 54 io.newLine();
55 io.println("Active events:");
56 var pick = io.readSelect("Pick an active event: ", active, event -> "[ID: %d] %s".formatted(event.displayId(), event.name()));
50 57
51 var result2 = catalog.getEvent(pick.key()); 58 var result2 = catalog.getEvent(pick.key());
52 var event = result2.getData(); 59 var event = result2.getData();
@@ -59,11 +66,11 @@ public final class PlaceOrderMenuCommand implements MenuCommand {
59 66
60 var market = 67 var market =
61 io.readSelect( 68 io.readSelect(
62 "Choose a market: ", 69 "Choose an option to buy: ",
63 event.state().markets(), 70 event.state().markets(),
64 o -> "%s [value %s, %s shares helds]".formatted(o.name(), o.price(), o.volume())); 71 o -> "%s [current value: %s, %s shares purchased]".formatted(o.name(), o.price(), o.volume()));
65 72
66 var amount = io.readInt("How many shares? ", 1, Integer.MAX_VALUE); 73 var amount = io.readInt("How many shares would you like to buy? ", 1, Integer.MAX_VALUE);
67 74
68 var buyResult = 75 var buyResult =
69 context.placeOrder( 76 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 {
25 25
26 @Override 26 @Override
27 public void execute() { 27 public void execute() {
28 var path = io.readPath("Save to: "); 28 var path = io.readPath("Please enter full path to save file (without or with .json extension): ");
29 29
30 try { 30 var result = catalog.saveState(path);
31 catalog.saveState(path); 31 if (result.isSuccess()) {
32 io.println("Saved current state to %s.".formatted(path)); 32 io.println("Successfully saved current market state to %s.".formatted(path));
33 } catch (Exception e) { 33 } else {
34 io.println("Failed to save: %s".formatted(e.getMessage())); 34 var msg =
35 result.getDetails() != null && !result.getDetails().isBlank()
36 ? result.getDetails()
37 : result.getMessage();
38 io.println("Failed to save state: %s".formatted(msg));
35 } 39 }
36 } 40 }
37} 41}
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 {
32 var result = catalog.getAllEvents(); 32 var result = catalog.getAllEvents();
33 33
34 if (!result.isSuccess()) { 34 if (!result.isSuccess()) {
35 io.println("Failed to list events"); 35 io.println("Failed to list events: %s.".formatted(result.getMessage()));
36 return; 36 return;
37 } 37 }
38 38
39 var events = result.getData(); 39 var events = result.getData();
40 if (events.isEmpty()) {
41 io.println("No events are currently loaded. Please load a valid XML file first.");
42 return;
43 }
44
40 var active = events.stream().filter(event -> event.status() == EventStatus.ACTIVE).toList(); 45 var active = events.stream().filter(event -> event.status() == EventStatus.ACTIVE).toList();
41 46
42 if (active.isEmpty()) { 47 if (active.isEmpty()) {
@@ -44,7 +49,9 @@ public final class SettleEventMenuCommand implements MenuCommand {
44 return; 49 return;
45 } 50 }
46 51
47 var pick = io.readSelect("Pick an event: ", active, event -> "%s".formatted(event.name())); 52 io.newLine();
53 io.println("Active events to settle:");
54 var pick = io.readSelect("Pick an event to settle: ", active, event -> "[ID: %d] %s".formatted(event.displayId(), event.name()));
48 55
49 var result2 = catalog.getEvent(pick.key()); 56 var result2 = catalog.getEvent(pick.key());
50 var event = result2.getData(); 57 var event = result2.getData();
@@ -56,12 +63,17 @@ public final class SettleEventMenuCommand implements MenuCommand {
56 io.printState(event.state()); 63 io.printState(event.state());
57 64
58 var winner = 65 var winner =
59 io.readSelect("Which market won? ", event.state().markets(), o -> "%s".formatted(o.name())); 66 io.readSelect("Which option won? ", event.state().markets(), o -> "%s".formatted(o.name()));
60 67
61 var settleResult = context.settleEvent("Tester", event.summary().key(), winner.key()); 68 var settleResult = context.settleEvent("Tester", event.summary().key(), winner.key());
62 if (settleResult.isSuccess()) { 69 if (settleResult.isSuccess()) {
70 var settledEvent = settleResult.getData();
71 io.newLine();
72 io.println("Event closed successfully! Winning option: %s".formatted(settledEvent.settledMarket()));
73 io.printState(settledEvent.state());
74 io.printHistory(settledEvent.history());
63 io.newLine(); 75 io.newLine();
64 io.println("Settled. Winning market: %s".formatted(settleResult.getData().settledMarket())); 76 io.println("Winning option: %s".formatted(settledEvent.settledMarket()));
65 return; 77 return;
66 } 78 }
67 io.newLine(); 79 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 {
20 private final Scanner scanner = new Scanner(System.in); 20 private final Scanner scanner = new Scanner(System.in);
21 21
22 public void print(Object object) { 22 public void print(Object object) {
23 IO.print(object); 23 System.out.print(object);
24 } 24 }
25 25
26 public void println(Object object) { 26 public void println(Object object) {
27 IO.println(object); 27 System.out.println(object);
28 } 28 }
29 29
30 public void newLine() { 30 public void newLine() {
31 IO.println(""); 31 System.out.println("");
32 } 32 }
33 33
34 public void splashScreen() { 34 public void splashScreen() {
35 newLine(); 35 newLine();
36 IO.println(padLeft("<<- GUESS MARKET ->>", 36)); 36 System.out.println(padLeft("<<- GUESS MARKET ->>", 36));
37 } 37 }
38 38
39 public void printState(MarketStateDTO state) { 39 public void printState(MarketStateDTO state) {
@@ -68,7 +68,8 @@ public final class InputProcessor {
68 68
69 public String readLine(String prompt) { 69 public String readLine(String prompt) {
70 print(prompt); 70 print(prompt);
71 return nextLine().trim(); 71 var line = nextLine();
72 return line != null ? line.trim() : "";
72 } 73 }
73 74
74 public int readInt(String prompt, int min, int max) { 75 public int readInt(String prompt, int min, int max) {
@@ -80,7 +81,6 @@ public final class InputProcessor {
80 } 81 }
81 try { 82 try {
82 var value = Integer.parseInt(raw); 83 var value = Integer.parseInt(raw);
83
84 if (value > max || value < min) { 84 if (value > max || value < min) {
85 println( 85 println(
86 "%d is out of range, please enter a number between %d and %d." 86 "%d is out of range, please enter a number between %d and %d."
@@ -90,9 +90,8 @@ public final class InputProcessor {
90 return value; 90 return value;
91 } catch (NumberFormatException e) { 91 } catch (NumberFormatException e) {
92 println( 92 println(
93 "%s in not a number, please enter a number between %d and %d." 93 "'%s' is not a valid number, please enter a number between %d and %d."
94 .formatted(raw, min, max)); 94 .formatted(raw, min, max));
95 continue;
96 } 95 }
97 } 96 }
98 } 97 }
@@ -100,12 +99,10 @@ public final class InputProcessor {
100 public Path readPath(String prompt) { 99 public Path readPath(String prompt) {
101 while (true) { 100 while (true) {
102 var raw = readLine(prompt); 101 var raw = readLine(prompt);
103
104 if (raw.isBlank()) { 102 if (raw.isBlank()) {
105 println("Please enter a file path."); 103 println("Please enter a file path.");
106 continue; 104 continue;
107 } 105 }
108
109 try { 106 try {
110 return Path.of(unquote(raw)); 107 return Path.of(unquote(raw));
111 } catch (InvalidPathException e) { 108 } catch (InvalidPathException e) {
@@ -126,7 +123,6 @@ public final class InputProcessor {
126 public boolean readYesNo(String prompt) { 123 public boolean readYesNo(String prompt) {
127 while (true) { 124 while (true) {
128 var raw = readLine("%s [Y/N]: ".formatted(prompt)).toLowerCase(); 125 var raw = readLine("%s [Y/N]: ".formatted(prompt)).toLowerCase();
129
130 switch (raw) { 126 switch (raw) {
131 case "y", "yes" -> { 127 case "y", "yes" -> {
132 return true; 128 return true;
@@ -134,14 +130,14 @@ public final class InputProcessor {
134 case "n", "no" -> { 130 case "n", "no" -> {
135 return false; 131 return false;
136 } 132 }
137 default -> IO.println("Please answer y/yes or n/no."); 133 default -> println("Please answer y/yes or n/no.");
138 } 134 }
139 } 135 }
140 } 136 }
141 137
142 public <T> T readSelect(String prompt, List<T> items, Function<T, String> function) { 138 public <T> T readSelect(String prompt, List<T> items, Function<T, String> function) {
143 for (var i = 0; i < items.size(); i++) { 139 for (var i = 0; i < items.size(); i++) {
144 IO.println(" [%d]->> %s".formatted(i + 1, function.apply(items.get(i)))); 140 println(" [%d]->> %s".formatted(i + 1, function.apply(items.get(i))));
145 } 141 }
146 142
147 var pick = readInt(prompt, 1, items.size()); 143 var pick = readInt(prompt, 1, items.size());
@@ -156,12 +152,16 @@ public final class InputProcessor {
156 public void printReceipt(PurchaseReceiptDTO receipt) { 152 public void printReceipt(PurchaseReceiptDTO receipt) {
157 newLine(); 153 newLine();
158 println("Purchase confirmed:"); 154 println("Purchase confirmed:");
159 println(tradeRow("Option", "Qty", "Paid", "At")); 155 println(tradeRow("Option", "Qty", "Cost", "At"));
160 println("-".repeat(OPTION_WIDTH + QTY_WIDTH + PAID_WIDTH + AT_WIDTH + 3)); 156 println("-".repeat(OPTION_WIDTH + QTY_WIDTH + PAID_WIDTH + AT_WIDTH + 3));
161 157
162 for (var trade : receipt.trades()) { 158 for (var trade : receipt.trades()) {
163 println(tradeRow(trade.optionName(), trade.quantity(), trade.pricePaid(), trade.at())); 159 println(tradeRow(trade.optionName(), trade.quantity(), trade.pricePaid(), trade.at()));
164 } 160 }
161 newLine();
162 println("Shares cost: %s".formatted(receipt.sharesCost()));
163 println("Commission paid: %s".formatted(receipt.commissionPaid()));
164 println("Total amount paid: %s".formatted(receipt.totalPaid()));
165 165
166 printState(receipt.stateAfter()); 166 printState(receipt.stateAfter());
167 } 167 }
@@ -175,7 +175,7 @@ public final class InputProcessor {
175 return; 175 return;
176 } 176 }
177 177
178 println(tradeRow("Option", "Qty", "Paid", "At")); 178 println(tradeRow("Option", "Qty", "Cost", "At"));
179 println("-".repeat(OPTION_WIDTH + QTY_WIDTH + PAID_WIDTH + AT_WIDTH + 3)); 179 println("-".repeat(OPTION_WIDTH + QTY_WIDTH + PAID_WIDTH + AT_WIDTH + 3));
180 180
181 for (var trade : history) { 181 for (var trade : history) {
@@ -192,3 +192,4 @@ public final class InputProcessor {
192 padRight(at, AT_WIDTH)); 192 padRight(at, AT_WIDTH));
193 } 193 }
194} 194}
195