lines) {
+ return lines.stream()
+ .filter(l -> !(l.startsWith("#") || l.isBlank()))
+ .noneMatch(l -> l.split(",").length != 4 || l.split(",")[3].split(";").length != 6);
+ }
+
+ /**
+ * Parses the supplied lines if they satisfy the correct format expectations
+ *
+ * @param lines lines to be parsed.
+ * Each line must contain four data fields: String,String,BigDecimal,String
+ * (Fields cannot be blank)
+ * blank lines or lines beginning with '#' are ignored
+ *
+ * @return List of stock objects created from the supplied lines
+ * @throws InvalidFormatException If one or more lines contain too many or too few data fields
+ * @throws InvalidFormatException If the BigDecimal field on one or more lines are not compatible
+ * @throws InvalidFormatException Upon recieving an IllegalArgumentException (Either Symbol or Company name is blank)
+ */
+ public List parse(List lines) {
+ List stocks = new ArrayList<>();
+ if (verifyCSV(lines)) {
+ lines.stream()
+ .filter(l -> !((l.startsWith("#") || l.isBlank())))
+ .forEach(
+ l -> {
+ try {
+ String[] split = l.split(",");
+ String symbol = split[0];
+ String company = split[1];
+ BigDecimal price = new BigDecimal(split[2]);
+ String[] functionValues = split[3].split(";");
+ List convertedFunctionValues = new ArrayList<>();
+ for (String functionValue : functionValues) {
+ convertedFunctionValues.add(new BigDecimal(functionValue));
+ }
+ stocks.add(new Stock(symbol, company, price, convertedFunctionValues));
+ } catch (NumberFormatException e) {
+ throw new InvalidFormatException("Error with number conversion on line: " + l + "\n" + "ensure all number fields are actually numbers");
+ } catch (IllegalArgumentException e) {
+ throw new InvalidFormatException("Illegal argument on line: " + l + "\n" + e.getMessage());
+ }
+ });
+ } else {
+ throw new InvalidFormatException("Incorrect format for CSV File: incorrect amount of data fields detected on one or more lines");
+ }
+ return stocks;
+ }
+}
diff --git a/src/main/java/millions/controller/fileIO/CSV/CSVStockFileWriter.java b/src/main/java/millions/controller/fileIO/CSV/CSVStockFileWriter.java
new file mode 100644
index 0000000..fdce35d
--- /dev/null
+++ b/src/main/java/millions/controller/fileIO/CSV/CSVStockFileWriter.java
@@ -0,0 +1,56 @@
+package millions.controller.fileIO.CSV;
+
+import java.io.*;
+import java.io.BufferedWriter;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.logging.Logger;
+
+import millions.controller.fileIO.StockFileWriter;
+import millions.model.Stock;
+
+/**
+ * Implements StockFileWriter.
+ * Converts a list of stock objects into a writeable string with a CSV format.
+ *
+ */
+public class CSVStockFileWriter implements StockFileWriter {
+ private static final Logger logger = Logger.getLogger(CSVStockFileWriter.class.getName());
+ private String finalString;
+
+ public CSVStockFileWriter() {}
+
+ /**
+ * Formats given string to CSV format to prepare for writing to file
+ */
+ @Override
+ public String formatString(List stocks) {
+ StringBuilder builder = new StringBuilder();
+ stocks.forEach(stock -> {
+ builder.append(stock.getSymbol());
+ builder.append(",");
+ builder.append(stock.getCompany());
+ builder.append(",");
+ builder.append(stock.getSalesPrice().toString());
+ builder.append("\n");
+ });
+ return builder.toString();
+ }
+
+ /**
+ * Writes the saved string to a file
+ * @param stocks List of stock objects to write
+ * @param path Path to desired file
+ * @return Boolean for success
+ */
+ @Override
+ public boolean write(List stocks, Path path){
+ try (FileWriter fw = new FileWriter(path.toString()); BufferedWriter writer = new BufferedWriter(fw);) {
+ this.formatString(stocks);
+ writer.write(finalString);
+ } catch (IOException e) {
+ logger.severe(e.getMessage());
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/millions/controller/fileIO/InvalidFormatException.java b/src/main/java/millions/controller/fileIO/InvalidFormatException.java
new file mode 100644
index 0000000..a1e2136
--- /dev/null
+++ b/src/main/java/millions/controller/fileIO/InvalidFormatException.java
@@ -0,0 +1,10 @@
+package millions.controller.fileIO;
+
+/**
+ * Exception to be thrown when verifying the format of files
+ */
+public class InvalidFormatException extends RuntimeException {
+ public InvalidFormatException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/millions/controller/fileIO/StockFileReader.java b/src/main/java/millions/controller/fileIO/StockFileReader.java
new file mode 100644
index 0000000..6c8069d
--- /dev/null
+++ b/src/main/java/millions/controller/fileIO/StockFileReader.java
@@ -0,0 +1,45 @@
+package millions.controller.fileIO;
+
+
+
+import java.io.*;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+
+/**
+ * Reads a file and returns its lines as a list of strings.
+ */
+public class StockFileReader {
+ private static final Logger logger = Logger.getLogger(StockFileReader.class.getName());
+ public StockFileReader() {}
+
+ /**
+ * Reads the file found at the specified path
+ * @param path Path to the desired file
+ * @return List of each line in the file as a string
+ * @throws UncheckedFileNotFoundException Upon encountering a FileNotFoundException
+ */
+ public List readFile(Path path) throws FileNotFoundException {
+ File file = new File(path.toString());
+ List lines = new ArrayList<>();
+ try (Reader reader = new FileReader(file);
+ BufferedReader bufferedReader = new BufferedReader(reader)) {
+ String line;
+ while ((line = bufferedReader.readLine()) != null) {
+ lines.add(line);
+ }
+ } catch (IOException e) {
+ if (e instanceof FileNotFoundException) {
+ throw new FileNotFoundException("Couldn't find file at specified path");
+ }
+ else {
+ logger.log(Level.SEVERE, "Encountered unexpected IOException: ", e.getMessage());
+ }
+ }
+ return lines;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/millions/controller/fileIO/StockFileWriter.java b/src/main/java/millions/controller/fileIO/StockFileWriter.java
new file mode 100644
index 0000000..a85f363
--- /dev/null
+++ b/src/main/java/millions/controller/fileIO/StockFileWriter.java
@@ -0,0 +1,14 @@
+package millions.controller.fileIO;
+
+import millions.model.Stock;
+
+import java.nio.file.Path;
+import java.util.List;
+
+/**
+ * Interface for writing stock data to a file.
+ */
+public interface StockFileWriter {
+ String formatString(List stocks);
+ boolean write(List stocks, Path path);
+}
diff --git a/src/main/java/millions/controller/fileIO/UncheckedFileNotFoundException.java b/src/main/java/millions/controller/fileIO/UncheckedFileNotFoundException.java
new file mode 100644
index 0000000..6f87d4c
--- /dev/null
+++ b/src/main/java/millions/controller/fileIO/UncheckedFileNotFoundException.java
@@ -0,0 +1,10 @@
+package millions.controller.fileIO;
+
+/**
+ * RuntimeException wrapper for FileNotFoundException.
+ */
+public class UncheckedFileNotFoundException extends RuntimeException {
+ public UncheckedFileNotFoundException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/millions/model/Exchange.java b/src/main/java/millions/model/Exchange.java
new file mode 100644
index 0000000..fbbec8d
--- /dev/null
+++ b/src/main/java/millions/model/Exchange.java
@@ -0,0 +1,183 @@
+package millions.model;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.stream.Collectors;
+import millions.model.calculators.PriceChangeCalculator;
+import millions.model.factories.PurchaseFactory;
+import millions.model.factories.SaleFactory;
+import millions.model.factories.TransactionFactory;
+
+/**
+ * The stock exchange where players buy and sell shares. Manages stocks and simulates weekly price
+ * changes.
+ */
+public class Exchange {
+ private String name;
+ private Map stocks;
+ private int weekNumber;
+ private Random random = new Random();
+ private final TransactionFactory purchaseFactory = new PurchaseFactory();
+ private final TransactionFactory saleFactory = new SaleFactory();
+ private final List listeners = new ArrayList<>();
+
+ public Exchange(String name, List stockList) {
+ this.name = name;
+ this.stocks = new HashMap<>();
+ this.weekNumber = 1;
+
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("Exchange name cannot be null or blank");
+ }
+
+ // Populate the stocks map to get ticker -> stock
+ for (Stock stock : stockList) {
+ this.stocks.put(stock.getSymbol(), stock);
+ }
+ }
+
+ /**
+ * Purchases a quantity of a given stock.
+ *
+ * @param symbol Symbol identifying the stock.
+ * @param player The player doing the purchase.
+ * @param quantity Quantity to purchase.
+ * @throws IllegalArgumentException If a stock isn't found
+ * @return Transaction object for purchase.
+ */
+ public Transaction buy(String symbol, Player player, BigDecimal quantity) {
+
+ Stock stock = this.stocks.get(symbol);
+ if (stock == null) {
+ throw new IllegalArgumentException("Stock not found");
+ }
+
+ Share shareToBuy = new Share(stock, quantity, stock.getSalesPrice());
+
+ Transaction purchase = purchaseFactory.createTransaction(shareToBuy, weekNumber);
+ purchase.commit(player);
+ notifyTransactionCompleted(purchase);
+
+ return purchase;
+ }
+
+ public Transaction buy(String symbol, Player player, int quantity) {
+ return this.buy(symbol, player, BigDecimal.valueOf(quantity));
+ }
+
+ /**
+ * Creates a transaction for a sale.
+ *
+ * @param share share to sell.
+ * @param player player performing the sale.
+ * @return Transaction object for sale.
+ */
+ public Transaction sell(Share share, Player player) {
+ Transaction sale = saleFactory.createTransaction(share, weekNumber);
+
+ sale.commit(player);
+ notifyTransactionCompleted(sale);
+ return sale;
+ }
+
+ public String getName() {
+ return this.name;
+ }
+
+ public int getWeekNumber() {
+ return this.weekNumber;
+ }
+
+ public Map getStocks() {
+ return this.stocks;
+ }
+
+ public Stock getStock(String symbol) {
+ return this.stocks.get(symbol);
+ }
+
+ public boolean hasStock(String symbol) {
+ return this.stocks.containsKey(symbol);
+ }
+
+ public List findStocks(String searchTerm) {
+ return this.stocks.values().stream()
+ .filter(s -> s.getSymbol().contains(searchTerm) || s.getCompany().contains(searchTerm))
+ .toList();
+ }
+
+ /**
+ * Returns the best performing stocks.
+ *
+ * @param limit Mmount of stocks to collect.
+ * @return Sorted list of stock objects sorted
+ */
+ public List getGainers(int limit) {
+ Collection stocksCollection = stocks.values();
+ return stocksCollection.stream()
+ .sorted(Comparator.comparing(Stock::getLatestPriceChange).reversed())
+ .limit(limit)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Returns the worst performing stocks.
+ *
+ * @param limit Amount of stocks to collect
+ * @return Sorted list of stock objects
+ */
+ public List getLosers(int limit) {
+ Collection stocksCollection = stocks.values();
+ return stocksCollection.stream()
+ .sorted(Comparator.comparing(Stock::getLatestPriceChange))
+ .limit(limit)
+ .collect(Collectors.toList());
+ }
+
+ /** Advances the current game week by performing new price calculations for all stocks. */
+ public void advance() {
+ PriceChangeCalculator priceChangeCalculator = new PriceChangeCalculator();
+ this.weekNumber++;
+ for (Stock stock : this.stocks.values()) {
+ BigDecimal change = priceChangeCalculator.calculateChange(stock);
+ stock.addNewSalesPrice(stock.getSalesPrice().add(change).setScale(2, RoundingMode.HALF_UP));
+ // Round to stop crazy values
+
+ // double change = 0.9 + random.nextDouble() * 0.2;
+ // stock.addNewSalesPrice(
+ // stock
+ // .getSalesPrice()
+ // .multiply(BigDecimal.valueOf(change))
+ // .setScale(2, RoundingMode.HALF_UP));
+ // // RoundingMode from AI suggestion
+ }
+ notifyWeekAdvanced();
+ }
+
+ public void addListener(ExchangeListener listener) {
+ listeners.add(listener);
+ }
+
+ public void removeListener(ExchangeListener listener) {
+ listeners.remove(listener);
+ }
+
+ private void notifyWeekAdvanced() {
+ for (ExchangeListener listener : listeners) {
+ listener.onWeekAdvanced(weekNumber);
+ }
+ }
+
+ private void notifyTransactionCompleted(Transaction transaction) {
+ for (ExchangeListener listener : listeners) {
+ listener.onTransactionCompleted(transaction);
+ }
+ }
+}
diff --git a/src/main/java/millions/model/ExchangeListener.java b/src/main/java/millions/model/ExchangeListener.java
new file mode 100644
index 0000000..6731d45
--- /dev/null
+++ b/src/main/java/millions/model/ExchangeListener.java
@@ -0,0 +1,11 @@
+package millions.model;
+
+/**
+ * Listener for exchange events such as week advances and completed transactions.
+ */
+public interface ExchangeListener {
+
+ void onWeekAdvanced(int newWeek);
+
+ void onTransactionCompleted(Transaction transaction);
+}
diff --git a/src/main/java/millions/model/Player.java b/src/main/java/millions/model/Player.java
new file mode 100644
index 0000000..515926e
--- /dev/null
+++ b/src/main/java/millions/model/Player.java
@@ -0,0 +1,175 @@
+package millions.model;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Player class. */
+public class Player {
+ private String name;
+ private BigDecimal startingMoney;
+ private BigDecimal money;
+ private Portfolio portfolio;
+ private TransactionArchive transactionArchive;
+ // temporary attribute until a better solution is found
+ public int weeksTraded;
+ private final List listeners = new ArrayList<>();
+
+ /**
+ * @param name Name of player
+ * @param startingMoney Amount of money the player starts with
+ * @throws IllegalArgumentException
+ */
+ public Player(String name, BigDecimal startingMoney) {
+ this.name = name;
+ this.startingMoney = startingMoney;
+ this.money = startingMoney;
+ this.portfolio = new Portfolio();
+ this.transactionArchive = new TransactionArchive();
+
+ if (name == null || name.isBlank()) {
+ throw new IllegalArgumentException("Player name cannot be null or blank");
+ }
+
+ if (startingMoney == null || startingMoney.compareTo(BigDecimal.ZERO) < 0) {
+ throw new IllegalArgumentException("Starting money cannot be null or negative");
+ }
+ }
+
+ /**
+ * @param amount How much money to add
+ * @throws IllegalArgumentException
+ */
+ public void addMoney(BigDecimal amount) {
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
+ throw new IllegalArgumentException("Amount cannot be null or negative");
+ }
+ this.money = this.money.add(amount);
+ notifyMoneyChanged();
+ }
+
+ /**
+ * @param amount How much money to withdeaw
+ * @throws IllegalArgumentException
+ */
+ public void withdrawMoney(BigDecimal amount) {
+ if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) {
+ throw new IllegalArgumentException("Amount cannot be null or negative");
+ }
+ this.money = this.money.subtract(amount);
+ notifyMoneyChanged();
+ }
+
+ /**
+ * Calculates the skill level of the player
+ *
+ * @return String player status level
+ */
+ public String getStatus() {
+ int weeksTraded = transactionArchive.countDistinctWeeks();
+
+ String status = "Novice";
+ BigDecimal netWorth = getNetWorth();
+ BigDecimal netWorthChange = netWorth.divide(startingMoney, RoundingMode.DOWN);
+ if (netWorthChange.compareTo(BigDecimal.valueOf(0.20)) >= 0 && weeksTraded >= 10) {
+ status = "Investor";
+ }
+ if (netWorthChange.compareTo(BigDecimal.valueOf(0.40)) >= 0 && weeksTraded >= 20) {
+ status = "Speculator";
+ }
+ return status;
+ }
+
+ /**
+ * @return player name
+ */
+ public String getName() {
+ return this.name;
+ }
+
+ /**
+ * @return player money
+ */
+ public BigDecimal getMoney() {
+ return this.money;
+ }
+
+ /**
+ * @return player portfolio
+ */
+ public Portfolio getPortfolio() {
+ return this.portfolio;
+ }
+
+ /**
+ * @return player startingMoney
+ */
+ public BigDecimal getStartingMoneh() {
+ return this.startingMoney;
+ }
+
+ /**
+ * @param share Share to be added
+ */
+ public void addShareToPortfolio(Share share) {
+ this.portfolio.addShare(share);
+ notifyPortfolioChanged();
+ notifyStatusChanged();
+ }
+
+ /**
+ * @param share Share to be removed
+ */
+ public void removeShareFromPortfolio(Share share) {
+ this.portfolio.removeShare(share);
+ notifyPortfolioChanged();
+ notifyStatusChanged();
+ }
+
+ /**
+ * @return player net worth
+ */
+ public BigDecimal getNetWorth() {
+ return this.money.add(this.portfolio.getNetWorth());
+ }
+
+ /**
+ * @return TransactionArchive object
+ */
+ public TransactionArchive getTransactionArchive() {
+ return this.transactionArchive;
+ }
+
+ /**
+ * @param listener PlayerListener
+ */
+ public void addListener(PlayerListener listener) {
+ listeners.add(listener);
+ }
+
+ /**
+ * @param listener PlayerListener
+ */
+ public void removeListener(PlayerListener listener) {
+ listeners.remove(listener);
+ }
+
+ private void notifyMoneyChanged() {
+ for (PlayerListener listener : listeners) {
+ listener.onMoneyChanged(money);
+ }
+ }
+
+ private void notifyPortfolioChanged() {
+ for (PlayerListener listener : listeners) {
+ listener.onPortfolioChanged();
+ }
+ }
+
+ private void notifyStatusChanged() {
+ for (PlayerListener listener : listeners) {
+ listener.onStatusChanged(getStatus());
+ }
+ }
+}
diff --git a/src/main/java/millions/model/PlayerListener.java b/src/main/java/millions/model/PlayerListener.java
new file mode 100644
index 0000000..e397ead
--- /dev/null
+++ b/src/main/java/millions/model/PlayerListener.java
@@ -0,0 +1,13 @@
+package millions.model;
+
+import java.math.BigDecimal;
+
+/** Listener for player state changes. */
+public interface PlayerListener {
+
+ void onMoneyChanged(BigDecimal newBalance);
+
+ void onPortfolioChanged();
+
+ void onStatusChanged(String newStatus);
+}
diff --git a/src/main/java/millions/model/Portfolio.java b/src/main/java/millions/model/Portfolio.java
new file mode 100644
index 0000000..4374595
--- /dev/null
+++ b/src/main/java/millions/model/Portfolio.java
@@ -0,0 +1,68 @@
+package millions.model;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+import millions.model.calculators.SaleCalculator;
+
+/** A collection of shares owned by a player. */
+public class Portfolio {
+ List shares;
+
+ public Portfolio() {
+ shares = new ArrayList<>();
+ }
+
+ /**
+ * @param share Share to be added
+ * @return Boolean for success
+ */
+ public boolean addShare(Share share) {
+ return this.shares.add(share);
+ }
+
+ /**
+ * @param share Share to be removed
+ * @return Boolean for success
+ */
+ public boolean removeShare(Share share) {
+ return this.shares.remove(share);
+ }
+
+ /**
+ * @return List of shares
+ */
+ public List getShares() {
+ return this.shares;
+ }
+
+ /**
+ * @param symbol Symbol for share
+ * @return List of shares
+ */
+ public List getShares(String symbol) {
+ return this.shares.stream()
+ .filter(share -> share.getStock().getSymbol().equals(symbol))
+ .toList();
+ }
+
+ /**
+ * @return BigDecimal net worth
+ */
+ public BigDecimal getNetWorth() {
+ BigDecimal total = BigDecimal.ZERO;
+ for (Share share : shares) {
+ BigDecimal value = new SaleCalculator(share).calculateTotal();
+ total = total.add(value);
+ }
+ return total;
+ }
+
+ /**
+ * @param share Share
+ * @return Boolean
+ */
+ public boolean contains(Share share) {
+ return this.shares.contains(share);
+ }
+}
diff --git a/src/main/java/millions/Purchase.java b/src/main/java/millions/model/Purchase.java
similarity index 70%
rename from src/main/java/millions/Purchase.java
rename to src/main/java/millions/model/Purchase.java
index 72f02ad..8bbacb8 100644
--- a/src/main/java/millions/Purchase.java
+++ b/src/main/java/millions/model/Purchase.java
@@ -1,7 +1,10 @@
-package millions;
+package millions.model;
-import millions.calculators.PurchaseCalculator;
+import millions.model.calculators.PurchaseCalculator;
+/**
+ * A transaction representing the purchase of shares.
+ */
public class Purchase extends Transaction {
public Purchase(Share share, int week) {
@@ -18,7 +21,8 @@ public void commit(Player player) {
throw new IllegalStateException("Not enought money");
}
player.withdrawMoney(getCalculator().calculateTotal());
- player.getPortfolio().addShare(getShare());
+ // Don't reach directly to the portefolio object
+ player.addShareToPortfolio(getShare());
player.getTransactionArchive().add(this);
setCommitted(true);
}
diff --git a/src/main/java/millions/Sale.java b/src/main/java/millions/model/Sale.java
similarity index 74%
rename from src/main/java/millions/Sale.java
rename to src/main/java/millions/model/Sale.java
index 25f2919..1c17745 100644
--- a/src/main/java/millions/Sale.java
+++ b/src/main/java/millions/model/Sale.java
@@ -1,7 +1,10 @@
-package millions;
+package millions.model;
-import millions.calculators.SaleCalculator;
+import millions.model.calculators.SaleCalculator;
+/**
+ * A transaction representing the sale of shares.
+ */
public class Sale extends Transaction {
public Sale(Share share, int week) {
@@ -18,7 +21,7 @@ public void commit(Player player) {
throw new IllegalStateException("Does not own the share");
}
player.addMoney(getCalculator().calculateTotal());
- player.getPortfolio().removeShare(getShare());
+ player.removeShareFromPortfolio(getShare());
player.getTransactionArchive().add(this);
setCommitted(true);
}
diff --git a/src/main/java/millions/model/Share.java b/src/main/java/millions/model/Share.java
new file mode 100644
index 0000000..5a7a726
--- /dev/null
+++ b/src/main/java/millions/model/Share.java
@@ -0,0 +1,60 @@
+package millions.model;
+
+import java.math.BigDecimal;
+
+/** Represents a holding of a specific stock with a quantity and purchase price. */
+public class Share {
+ Stock stock;
+ BigDecimal quantity;
+ BigDecimal purchasePrice;
+
+ /**
+ * @param stock Which stock the share is for.
+ * @param quantity How many stocks.
+ * @param purchasePrice Purchase price of the share.
+ * @throws IllegalArgumentException if stock is null.
+ * @throws IllegalArgumentException if quantity is null.
+ * @throws IllegalArgumentException if purchasePrice is null.
+ */
+ public Share(Stock stock, BigDecimal quantity, BigDecimal purchasePrice) {
+ this.stock = stock;
+ this.quantity = quantity;
+ this.purchasePrice = purchasePrice;
+
+ if (stock == null) {
+ throw new IllegalArgumentException("Stock cannot be null");
+ }
+ if (quantity == null) {
+ throw new IllegalArgumentException("Quantity cannot be null");
+ }
+ if (purchasePrice == null) {
+ throw new IllegalArgumentException("Purchase price cannot be null");
+ }
+ }
+
+ /** Share() with int quantity. */
+ public Share(Stock stock, int quantity, BigDecimal purchasePrice) {
+ this(stock, BigDecimal.valueOf(quantity), purchasePrice);
+ }
+
+ /**
+ * @return Stock object.
+ */
+ public Stock getStock() {
+ return this.stock;
+ }
+
+ /**
+ * @return BigDecimal: quantity.
+ */
+ public BigDecimal getQuantity() {
+ return this.quantity;
+ }
+
+ /**
+ * @return BigDecimal PurchasePrice.
+ */
+ public BigDecimal getPurchasePrice() {
+ return this.purchasePrice;
+ }
+}
diff --git a/src/main/java/millions/model/Stock.java b/src/main/java/millions/model/Stock.java
new file mode 100644
index 0000000..a5445fe
--- /dev/null
+++ b/src/main/java/millions/model/Stock.java
@@ -0,0 +1,130 @@
+package millions.model;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Stock */
+public class Stock {
+ String symbol;
+ String company;
+ List volatility;
+ List prices;
+
+ /**
+ * @param symbol Stock ticker symbol
+ * @param company company name
+ * @param prices List of prices
+ * @param volatilityParameters numbers used for price change calculation functions
+ * @throws IllegalArgumentException
+ */
+ public Stock(String symbol, String company, List prices, List volatilityParameters) {
+ this.symbol = symbol;
+ this.company = company;
+ this.prices = new ArrayList<>(prices);
+
+ this.volatility = volatilityParameters;
+
+ if (volatilityParameters.size() != 6) {
+ throw new IllegalArgumentException("Invalid volatility function count");
+ }
+
+ if (symbol == null || symbol.isBlank()) {
+ throw new IllegalArgumentException("Symbol cannot be null or blank");
+ }
+
+ if (company == null || company.isBlank()) {
+ throw new IllegalArgumentException("Company cannot be null or blank");
+ }
+ }
+
+
+ /** Stock() with single price instead of list. */
+ public Stock(String symbol, String company, BigDecimal initialPrice, List volatilityFunctions) {
+ this(symbol, company, new ArrayList<>(List.of(initialPrice)), volatilityFunctions);
+ }
+
+ /**
+ * @return String: symbol.
+ */
+ public String getSymbol() {
+ return this.symbol;
+ }
+
+ /**
+ * @return String: company.
+ */
+ public String getCompany() {
+ return this.company;
+ }
+
+ /**
+ * @return BigDecimal: price.
+ */
+ public BigDecimal getSalesPrice() {
+ return this.prices.getLast();
+ }
+
+ /**
+ * @param price Sales price.
+ */
+ public void addNewSalesPrice(BigDecimal price) {
+ this.prices.add(price);
+ }
+
+ /**
+ * @return BigDecimal list of prices.
+ */
+ public List getHistoricalPrices() {
+ return this.prices;
+ }
+
+ /**
+ * @return BigDecimal highest recorded price.
+ */
+ public BigDecimal getHighestPrice() {
+ BigDecimal highestPrice = this.prices.get(0);
+ for (BigDecimal price : this.prices) {
+ if (price.compareTo(highestPrice) > 0) {
+ highestPrice = price;
+ }
+ }
+ return highestPrice;
+ }
+
+ /**
+ * @return BigDecimal lowest recorded price.
+ */
+ public BigDecimal getLowestPrice() {
+ BigDecimal lowestPrice = this.prices.get(0);
+ for (BigDecimal price : this.prices) {
+ if (price.compareTo(lowestPrice) < 0) {
+ lowestPrice = price;
+ }
+ }
+ return lowestPrice;
+ }
+
+ /**
+ * @return BigDecimal price difference from last week
+ */
+ public BigDecimal getLatestPriceChange() {
+ if (this.prices.size() < 2) {
+ return BigDecimal.ZERO;
+ }
+
+ BigDecimal currentPrice = this.prices.getLast();
+ BigDecimal lastPrice = this.prices.get(this.prices.size() - 2);
+
+ return currentPrice.subtract(lastPrice);
+ }
+
+ public List getVolatilityParameters() {
+ return this.volatility;
+ }
+
+ @Override
+ public String toString() {
+ return "Stock [symbol: " + symbol + ", company: " + company + ", prices: " + prices + "]";
+ }
+}
diff --git a/src/main/java/millions/Transaction.java b/src/main/java/millions/model/Transaction.java
similarity index 69%
rename from src/main/java/millions/Transaction.java
rename to src/main/java/millions/model/Transaction.java
index b4b4022..69c1167 100644
--- a/src/main/java/millions/Transaction.java
+++ b/src/main/java/millions/model/Transaction.java
@@ -1,13 +1,14 @@
-package millions;
+package millions.model;
-import millions.calculators.TransactionCalculator;
+import millions.model.calculators.TransactionCalculator;
+/** Abstract base class for stock transactions */
public abstract class Transaction {
private Share share;
private int week;
private TransactionCalculator transactionCalculator;
- private boolean committed;
+ protected boolean committed;
protected Transaction(Share share, int week, TransactionCalculator transactionCalculator) {
this.share = share;
@@ -16,18 +17,30 @@ protected Transaction(Share share, int week, TransactionCalculator transactionCa
this.committed = false;
}
+ /**
+ * @return Share object
+ */
public Share getShare() {
return this.share;
}
+ /**
+ * @return int: week
+ */
public int getWeek() {
return this.week;
}
+ /**
+ * @return TransactionCalculator object
+ */
public TransactionCalculator getCalculator() {
return this.transactionCalculator;
}
+ /**
+ * @return Boolean: status
+ */
public boolean isCommitted() {
return this.committed;
}
diff --git a/src/main/java/millions/TransactionArchive.java b/src/main/java/millions/model/TransactionArchive.java
similarity index 61%
rename from src/main/java/millions/TransactionArchive.java
rename to src/main/java/millions/model/TransactionArchive.java
index 0a83e92..20b7e7a 100644
--- a/src/main/java/millions/TransactionArchive.java
+++ b/src/main/java/millions/model/TransactionArchive.java
@@ -1,9 +1,10 @@
-package millions;
+package millions.model;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
+/** Stores and queries commited transactions. */
public class TransactionArchive {
List transactions;
@@ -12,6 +13,11 @@ public TransactionArchive() {
this.transactions = new ArrayList<>();
}
+ /**
+ * Adds a transaction to the archive
+ * @param transaction transaction object
+ * @return Boolean for success
+ */
public boolean add(Transaction transaction) {
if (transactions.contains(transaction)) {
return false;
@@ -20,21 +26,45 @@ public boolean add(Transaction transaction) {
return true;
}
+ /**
+ * @return Boolean
+ */
public boolean isEmpty() {
return transactions.isEmpty();
}
+ /**
+ * @return List of transaction objects
+ */
+ public List getTransactions() {
+ return new ArrayList<>(transactions);
+ }
+
+ /**
+ * returns transaction processed in a given week
+ * @param week int
+ * @return List of transaction objects
+ */
public List getTransactions(int week) {
return transactions.stream().filter(x -> x.getWeek() == week).collect(Collectors.toList());
}
+ /**
+ * Returns all purchase transactions in a given week
+ * @param week int
+ * @return List of transaction objects
+ */
public List getPurchases(int week) {
return transactions.stream()
.filter(t -> t.getWeek() == week && t instanceof Purchase)
.map(t -> (Purchase) t)
.collect(Collectors.toList());
}
-
+ /**
+ * Returns all sale transactions in a given week
+ * @param week int
+ * @return List of transaction objects
+ */
public List getSales(int week) {
return transactions.stream()
.filter(t -> t.getWeek() == week && t instanceof Sale)
diff --git a/src/main/java/millions/model/calculators/PriceChangeCalculator.java b/src/main/java/millions/model/calculators/PriceChangeCalculator.java
new file mode 100644
index 0000000..7ce275a
--- /dev/null
+++ b/src/main/java/millions/model/calculators/PriceChangeCalculator.java
@@ -0,0 +1,64 @@
+package millions.model.calculators;
+
+import java.math.BigDecimal;
+import java.util.List;
+import millions.model.Stock;
+
+public class PriceChangeCalculator {
+
+ public PriceChangeCalculator() {}
+
+ public BigDecimal calculateChange(Stock stock) {
+ List values = stock.getVolatilityParameters();
+ int week = stock.getHistoricalPrices().size();
+ BigDecimal change = BigDecimal.ZERO;
+ change = change.add(drift(values.get(0)));
+ change = change.add(volatility(values.get(1)));
+ change = change.add(cycle(values.get(2), values.get(3), week));
+ change = change.add(explosion(values.get(4), values.get(5)));
+ return stock.getSalesPrice().multiply(change);
+ }
+
+ /*
+ * Flat slope change, eg upwards/downwards slope
+ */
+ private BigDecimal drift(BigDecimal input) {
+ if (input.equals(BigDecimal.ZERO)) {
+ return BigDecimal.ZERO;
+ }
+ return input;
+ }
+
+ /*
+ * Random noise of size x
+ */
+ private BigDecimal volatility(BigDecimal input) {
+ if (input.equals(BigDecimal.ZERO)) {
+ return BigDecimal.ZERO;
+ }
+ return input.multiply(BigDecimal.valueOf(Math.random() * 2 - 1));
+ }
+
+ /*
+ * Sinus curve based on week, times size of the curve
+ */
+ private BigDecimal cycle(BigDecimal speed, BigDecimal size, int week) {
+ if (speed.equals(BigDecimal.ZERO) || size.equals(BigDecimal.ZERO)) {
+ return BigDecimal.ZERO;
+ }
+ return size.multiply(BigDecimal.valueOf(Math.sin(week * speed.doubleValue())));
+ }
+
+ /*
+ * probability% change of an explosion of size% positive or negative
+ */
+ private BigDecimal explosion(BigDecimal probability, BigDecimal size) {
+ if (probability.equals(BigDecimal.ZERO) || size.equals(BigDecimal.ZERO)) {
+ return BigDecimal.ZERO;
+ }
+ if (Math.random() >= probability.doubleValue()) {
+ return BigDecimal.ZERO;
+ }
+ return Math.random() < 0.5 ? size : size.negate();
+ }
+}
diff --git a/src/main/java/millions/calculators/PurchaseCalculator.java b/src/main/java/millions/model/calculators/PurchaseCalculator.java
similarity index 85%
rename from src/main/java/millions/calculators/PurchaseCalculator.java
rename to src/main/java/millions/model/calculators/PurchaseCalculator.java
index e23293c..ab2608d 100644
--- a/src/main/java/millions/calculators/PurchaseCalculator.java
+++ b/src/main/java/millions/model/calculators/PurchaseCalculator.java
@@ -1,8 +1,9 @@
-package millions.calculators;
+package millions.model.calculators;
import java.math.BigDecimal;
-import millions.Share;
+import millions.model.Share;
+/** Calculates costs for a purchase transaction. Commission */
public class PurchaseCalculator implements TransactionCalculator {
BigDecimal purchasePrice;
BigDecimal quantity;
diff --git a/src/main/java/millions/calculators/SaleCalculator.java b/src/main/java/millions/model/calculators/SaleCalculator.java
similarity index 57%
rename from src/main/java/millions/calculators/SaleCalculator.java
rename to src/main/java/millions/model/calculators/SaleCalculator.java
index 2bddf30..a907b46 100644
--- a/src/main/java/millions/calculators/SaleCalculator.java
+++ b/src/main/java/millions/model/calculators/SaleCalculator.java
@@ -1,9 +1,10 @@
-package millions.calculators;
+package millions.model.calculators;
import java.math.BigDecimal;
import java.math.RoundingMode;
-import millions.Share;
+import millions.model.Share;
+/** Calculates costs for a sale transaction. Commission and profit tax */
public class SaleCalculator implements TransactionCalculator {
BigDecimal purchasePrice;
BigDecimal salesPrice;
@@ -12,6 +13,8 @@ public class SaleCalculator implements TransactionCalculator {
public SaleCalculator(Share share) {
super();
this.purchasePrice = share.getPurchasePrice();
+ this.salesPrice = share.getStock().getSalesPrice();
+ this.quantity = share.getQuantity();
}
@Override
@@ -21,7 +24,7 @@ public BigDecimal calculateGross() {
@Override
public BigDecimal calculateCommission() {
- return this.calculateGross().divide(new BigDecimal("100"), RoundingMode.HALF_UP);
+ return this.calculateGross().divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
@Override
@@ -29,13 +32,19 @@ public BigDecimal calculateTax() {
BigDecimal purchaseCosts = this.purchasePrice.multiply(this.quantity);
BigDecimal earnings =
this.calculateGross().subtract(this.calculateCommission()).subtract(purchaseCosts);
+ if (earnings.compareTo(BigDecimal.ZERO) <= 0) {
+ return BigDecimal.ZERO;
+ }
return earnings
.multiply(new BigDecimal("30"))
- .divide(new BigDecimal("100"), RoundingMode.HALF_UP);
+ .divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
@Override
public BigDecimal calculateTotal() {
- return this.calculateGross().subtract(this.calculateCommission()).subtract(this.calculateTax());
+ return this.calculateGross()
+ .subtract(this.calculateCommission())
+ .subtract(this.calculateTax())
+ .stripTrailingZeros();
}
}
diff --git a/src/main/java/millions/calculators/TransactionCalculator.java b/src/main/java/millions/model/calculators/TransactionCalculator.java
similarity index 63%
rename from src/main/java/millions/calculators/TransactionCalculator.java
rename to src/main/java/millions/model/calculators/TransactionCalculator.java
index 8b85c6a..9e6ecfb 100644
--- a/src/main/java/millions/calculators/TransactionCalculator.java
+++ b/src/main/java/millions/model/calculators/TransactionCalculator.java
@@ -1,7 +1,10 @@
-package millions.calculators;
+package millions.model.calculators;
import java.math.BigDecimal;
+/**
+ * Interface for calculating transaction costs including gross, commission, tax, and total.
+ */
public interface TransactionCalculator {
public BigDecimal calculateGross();
diff --git a/src/main/java/millions/model/factories/PurchaseFactory.java b/src/main/java/millions/model/factories/PurchaseFactory.java
new file mode 100644
index 0000000..0af39aa
--- /dev/null
+++ b/src/main/java/millions/model/factories/PurchaseFactory.java
@@ -0,0 +1,13 @@
+package millions.model.factories;
+
+import millions.model.Purchase;
+import millions.model.Share;
+
+/**
+ * Factory for creating purchase transactions.
+ */
+public class PurchaseFactory extends TransactionFactory {
+ public Purchase createTransaction(Share share, int week) {
+ return new Purchase(share, week);
+ }
+}
diff --git a/src/main/java/millions/model/factories/SaleFactory.java b/src/main/java/millions/model/factories/SaleFactory.java
new file mode 100644
index 0000000..6753a5e
--- /dev/null
+++ b/src/main/java/millions/model/factories/SaleFactory.java
@@ -0,0 +1,13 @@
+package millions.model.factories;
+
+import millions.model.Sale;
+import millions.model.Share;
+
+/**
+ * Factory for creating sale transactions.
+ */
+public class SaleFactory extends TransactionFactory {
+ public Sale createTransaction(Share share, int week) {
+ return new Sale(share, week);
+ }
+}
diff --git a/src/main/java/millions/model/factories/TransactionFactory.java b/src/main/java/millions/model/factories/TransactionFactory.java
new file mode 100644
index 0000000..e77d31d
--- /dev/null
+++ b/src/main/java/millions/model/factories/TransactionFactory.java
@@ -0,0 +1,11 @@
+package millions.model.factories;
+
+import millions.model.Share;
+import millions.model.Transaction;
+
+/**
+ * Abstract factory for creating transactions.
+ */
+public abstract class TransactionFactory {
+ public abstract Transaction createTransaction(Share share, int week);
+}
diff --git a/src/main/java/millions/view/ExitView.java b/src/main/java/millions/view/ExitView.java
new file mode 100644
index 0000000..f90a9e8
--- /dev/null
+++ b/src/main/java/millions/view/ExitView.java
@@ -0,0 +1,79 @@
+package millions.view;
+
+import java.math.BigDecimal;
+import javafx.application.Platform;
+import javafx.geometry.HPos;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.Separator;
+import javafx.scene.layout.ColumnConstraints;
+import javafx.scene.layout.GridPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+import millions.model.Player;
+
+public class ExitView extends VBox {
+
+ public ExitView(Player player) {
+ setSpacing(16);
+ setAlignment(Pos.CENTER);
+ setPadding(new Insets(60));
+
+ Label title = new Label("GAME OVER");
+ title.getStyleClass().add("game-over-title");
+ HBox titleRow = new HBox(title);
+ titleRow.setAlignment(Pos.CENTER);
+ titleRow.setMaxWidth(Double.MAX_VALUE);
+
+ Separator sep = new Separator();
+ sep.setMaxWidth(320);
+
+ Label weeks = new Label(String.valueOf(player.getTransactionArchive().countDistinctWeeks()));
+ Label trades =
+ new Label(String.valueOf(player.getTransactionArchive().getTransactions().size()));
+ Label netWorth = new Label(ViewUtils.formatMoney(player.getNetWorth()));
+
+ BigDecimal profit = player.getNetWorth().subtract(player.getStartingMoneh());
+ String sign = profit.compareTo(BigDecimal.ZERO) >= 0 ? "+" : "";
+ Label netProfit = new Label(sign + ViewUtils.formatMoney(profit));
+ netProfit
+ .getStyleClass()
+ .add(profit.compareTo(BigDecimal.ZERO) >= 0 ? "status-success" : "status-error");
+
+ GridPane stats = new GridPane();
+ stats.setHgap(24);
+ stats.setVgap(12);
+ ColumnConstraints labelCol = new ColumnConstraints();
+ labelCol.setHalignment(HPos.RIGHT);
+ labelCol.setHgrow(Priority.NEVER);
+ ColumnConstraints valueCol = new ColumnConstraints();
+ valueCol.setHalignment(HPos.LEFT);
+ valueCol.setHgrow(Priority.ALWAYS);
+ stats.getColumnConstraints().addAll(labelCol, valueCol);
+
+ stats.addRow(0, statlabel("Weeks traded"), weeks);
+ stats.addRow(1, statlabel("Trades made"), trades);
+ stats.addRow(2, statlabel("Final net worth"), netWorth);
+ stats.addRow(3, statlabel("Total profit"), netProfit);
+
+ for (Label l : new Label[] {weeks, trades, netWorth, netProfit}) {
+ l.getStyleClass().add("stat-value");
+ }
+
+ Button quitButton = new Button("Quit");
+ quitButton.getStyleClass().add("btn-primary");
+ quitButton.setPrefWidth(160);
+ quitButton.setOnAction(event -> Platform.exit());
+
+ getChildren().addAll(titleRow, sep, stats, quitButton);
+ }
+
+ private static Label statlabel(String text) {
+ Label l = new Label(text);
+ l.getStyleClass().add("section-title");
+ return l;
+ }
+}
diff --git a/src/main/java/millions/view/GameView.java b/src/main/java/millions/view/GameView.java
new file mode 100644
index 0000000..9fefcc3
--- /dev/null
+++ b/src/main/java/millions/view/GameView.java
@@ -0,0 +1,822 @@
+package millions.view;
+
+import java.math.BigDecimal;
+import java.util.List;
+import javafx.beans.property.SimpleStringProperty; // For data binding for table string
+import javafx.geometry.Orientation;
+import javafx.scene.chart.LineChart;
+import javafx.scene.chart.NumberAxis;
+import javafx.scene.chart.XYChart;
+import javafx.scene.control.Button;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListCell;
+import javafx.scene.control.ListView;
+import javafx.scene.control.Separator;
+import javafx.scene.control.Slider;
+import javafx.scene.control.Tab;
+import javafx.scene.control.TabPane;
+import javafx.scene.control.TableCell;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.control.TextField;
+import javafx.scene.control.TextFormatter;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.Priority;
+import javafx.scene.layout.VBox;
+import millions.controller.GameController;
+import millions.model.Exchange;
+import millions.model.ExchangeListener;
+import millions.model.Player;
+import millions.model.PlayerListener;
+import millions.model.Share;
+import millions.model.Stock;
+import millions.model.Transaction;
+import millions.model.calculators.PurchaseCalculator;
+import millions.model.calculators.SaleCalculator;
+
+/** Main game screen with tabs */
+public class GameView extends BorderPane implements PlayerListener, ExchangeListener {
+
+ private final GameController controller;
+ private final Label playerNameLabel = new Label();
+ private final Label weekLabel = new Label();
+ private final Label moneyLabel = new Label();
+ private final Label netWorthLabel = new Label();
+ private final Label statusLabel = new Label();
+
+ {
+ for (Label l :
+ new Label[] {playerNameLabel, weekLabel, moneyLabel, netWorthLabel, statusLabel}) {
+ l.getStyleClass().add("stat-value");
+ }
+ }
+
+ private final TextField searchField = new TextField();
+ private final ListView stocksList = new ListView<>();
+ private final TableView portfolioTable = new TableView<>();
+ private final TableView transactionsTable = new TableView<>();
+ private final Label selectedStockLabel = new Label("Select a stock to see chart");
+ private final Label ownedQuantityLabel = new Label("Owned: 0");
+ private final Label actionStatusLabel = new Label();
+ private final TextField quantityField = new TextField("1");
+ private final Slider quantitySlider = new Slider(1, 1, 1);
+ private final ComboBox ownedSharesBox = new ComboBox<>();
+ private final NumberAxis xAxis = new NumberAxis();
+ private final NumberAxis yAxis = new NumberAxis();
+ private final LineChart stockChart = new LineChart<>(xAxis, yAxis);
+ private final Button buyButton = new Button("Buy");
+ private final Button sellButton = new Button("Sell");
+ private final Button advanceButton = new Button("Advance week");
+ private final Button sellAllAndQuitButton = new Button("Sell all & quit");
+
+ private final Label stockHighLabel = new Label();
+ private final Label stockLowLabel = new Label();
+ private final Label stockChangeLabel = new Label();
+ private final Label buyCostPreviewLabel = new Label();
+ private final Label sellCostPreviewLabel = new Label();
+ private final TableView gainersTable = new TableView<>();
+ private final TableView losersTable = new TableView<>();
+ private final TextField transactionSearchField = new TextField();
+
+ {
+ buyButton.getStyleClass().add("btn-primary");
+ advanceButton.getStyleClass().add("btn-primary");
+ sellAllAndQuitButton.getStyleClass().add("btn-danger");
+ }
+
+ private final Runnable onSellAllAndQuit;
+ private final TabPane tabPane;
+ private boolean updatingQuantityControls;
+
+ public GameView(GameController controller, Runnable onSellAllAndQuit) {
+ this.controller = controller;
+ this.onSellAllAndQuit = onSellAllAndQuit;
+ this.tabPane = createTabs();
+ setTop(createHeader());
+ setCenter(tabPane);
+ configureStocksList();
+ configureButtons();
+ configureQuantityControls();
+ refreshAll();
+ }
+
+ private HBox createHeader() {
+ HBox title = ViewUtils.buildTitle();
+
+ javafx.scene.layout.Region spacer = new javafx.scene.layout.Region();
+ HBox.setHgrow(spacer, Priority.ALWAYS);
+
+ HBox header =
+ new HBox(
+ 20,
+ title,
+ statBox("PLAYER", playerNameLabel),
+ statBox("WEEK", weekLabel),
+ statBox("CASH", moneyLabel),
+ statBox("NET WORTH", netWorthLabel),
+ statBox("STATUS", statusLabel),
+ spacer,
+ sellAllAndQuitButton);
+ header.getStyleClass().add("header-panel");
+ header.setAlignment(javafx.geometry.Pos.CENTER_LEFT);
+ return header;
+ }
+
+ private static VBox statBox(String key, Label valueLabel) {
+ Label keyLabel = new Label(key);
+ keyLabel.getStyleClass().add("section-title");
+ VBox box = new VBox(1, keyLabel, valueLabel);
+ box.setAlignment(javafx.geometry.Pos.CENTER_LEFT);
+ return box;
+ }
+
+ private TabPane createTabs() {
+ TabPane tabPane = new TabPane();
+ tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);
+ tabPane.getTabs().add(createStocksTab());
+ tabPane.getTabs().add(createPortfolioTab());
+ tabPane.getTabs().add(createTransactionsTab());
+ tabPane.getTabs().add(createMarketTab());
+ return tabPane;
+ }
+
+ private Tab createStocksTab() {
+ VBox leftPane = new VBox(10, searchField, stocksList);
+ leftPane.setPrefWidth(500);
+ stocksList.setPrefWidth(500);
+ stocksList.setMinWidth(500);
+ stocksList.setMaxWidth(Double.MAX_VALUE);
+
+ searchField.setPromptText("Search");
+ searchField.textProperty().addListener((obs, oldVal, newVal) -> refreshStocks());
+
+ xAxis.setLabel("Week");
+ xAxis.setAutoRanging(false);
+ xAxis.setLowerBound(1); // Stop week 0
+ xAxis.setTickUnit(1);
+ yAxis.setLabel("Price");
+ stockChart.setTitle("Price history");
+ stockChart.setLegendVisible(false);
+ stockChart.setCreateSymbols(true);
+ stockChart.setAnimated(false);
+ stockChart.setPrefHeight(500);
+ stockChart.setMaxWidth(Double.MAX_VALUE);
+
+ selectedStockLabel.getStyleClass().add("selected-stock-label");
+
+ stockHighLabel.getStyleClass().add("section-title");
+ stockLowLabel.getStyleClass().add("section-title");
+ stockChangeLabel.getStyleClass().add("section-title");
+ HBox stockStats = new HBox(20, stockHighLabel, stockLowLabel, stockChangeLabel);
+
+ VBox rightPane = new VBox(6, selectedStockLabel, stockStats, stockChart);
+ rightPane.setMaxWidth(Double.MAX_VALUE);
+ HBox.setHgrow(rightPane, Priority.ALWAYS);
+
+ quantityField.setPrefWidth(100);
+ quantityField.setTextFormatter(
+ new TextFormatter<>(
+ change -> change.getControlNewText().matches("-?\\d*") ? change : null));
+ quantitySlider.setPrefWidth(200);
+
+ ViewUtils.configureOwnedSharesBox(ownedSharesBox);
+
+ Label buyHeader = new Label("BUY");
+ buyHeader.getStyleClass().add("section-title");
+ HBox buyControls = new HBox(8, quantityField, quantitySlider, buyButton);
+ buyControls.setAlignment(javafx.geometry.Pos.CENTER_LEFT);
+ buyCostPreviewLabel.getStyleClass().add("cost-preview");
+ VBox buySection = new VBox(4, buyHeader, buyControls, buyCostPreviewLabel);
+
+ javafx.scene.control.Separator sep1 =
+ new javafx.scene.control.Separator(javafx.geometry.Orientation.VERTICAL);
+
+ Label sellHeader = new Label("SELL");
+ sellHeader.getStyleClass().add("section-title");
+ HBox sellControls = new HBox(8, ownedQuantityLabel, ownedSharesBox, sellButton);
+ sellControls.setAlignment(javafx.geometry.Pos.CENTER_LEFT);
+ sellCostPreviewLabel.getStyleClass().add("cost-preview");
+ VBox sellSection = new VBox(4, sellHeader, sellControls, sellCostPreviewLabel);
+
+ javafx.scene.control.Separator sep2 =
+ new javafx.scene.control.Separator(javafx.geometry.Orientation.VERTICAL);
+
+ Label weekHeader = new Label("WEEK");
+ weekHeader.getStyleClass().add("section-title");
+ VBox weekSection = new VBox(4, weekHeader, advanceButton);
+
+ HBox actionBar = new HBox(16, buySection, sep1, sellSection, sep2, weekSection);
+ actionBar.getStyleClass().add("toolbar-panel");
+ actionBar.setAlignment(javafx.geometry.Pos.CENTER_LEFT);
+
+ HBox content = new HBox(12, leftPane, rightPane);
+ HBox.setHgrow(content, Priority.ALWAYS);
+ VBox outer = new VBox(12, content, actionBar, actionStatusLabel);
+ return new Tab("Stocks", outer);
+ }
+
+ private Tab createPortfolioTab() {
+ portfolioTable.setPlaceholder(new Label("No shares yet"));
+
+ TableColumn symbolColumn = new TableColumn<>("Symbol");
+ symbolColumn.setPrefWidth(200);
+ symbolColumn.setCellValueFactory(
+ data -> new SimpleStringProperty(data.getValue().getStock().getSymbol()));
+
+ TableColumn quantityColumn = new TableColumn<>("Quantity");
+ quantityColumn.setPrefWidth(100);
+ quantityColumn.setCellValueFactory(
+ data -> new SimpleStringProperty(data.getValue().getQuantity().toPlainString()));
+
+ TableColumn purchasePriceColumn = new TableColumn<>("Purchase price");
+ purchasePriceColumn.setPrefWidth(150);
+ purchasePriceColumn.setCellValueFactory(
+ data ->
+ new SimpleStringProperty(ViewUtils.formatMoney(data.getValue().getPurchasePrice())));
+
+ TableColumn currentPriceColumn = new TableColumn<>("Current price");
+ currentPriceColumn.setPrefWidth(150);
+ currentPriceColumn.setCellValueFactory(
+ data ->
+ new SimpleStringProperty(
+ ViewUtils.formatMoney(data.getValue().getStock().getSalesPrice())));
+
+ TableColumn profitColumn = new TableColumn<>("Profit");
+ profitColumn.setPrefWidth(100);
+ profitColumn.setCellValueFactory(
+ data ->
+ new SimpleStringProperty(
+ ViewUtils.formatMoney(ViewUtils.getShareProfit(data.getValue()))));
+ profitColumn.setCellFactory(
+ column ->
+ new TableCell<>() {
+ @Override
+ protected void updateItem(String profit, boolean empty) {
+ super.updateItem(profit, empty);
+ getStyleClass().removeAll("status-success", "status-error");
+ if (empty) {
+ setText(null);
+ return;
+ }
+ setText(profit);
+ getStyleClass().add(profit.startsWith("-") ? "status-error" : "status-success");
+ }
+ });
+ TableColumn marketColumn = new TableColumn<>("Market");
+ marketColumn.setMinWidth(100);
+ marketColumn.setStyle("-fx-alignment: CENTER;");
+ marketColumn.setCellFactory(column -> new TableCell() {
+ private final Button button = new Button("Market Page");
+
+ @Override
+ protected void updateItem(Share share, boolean empty) {
+ super.updateItem(share, empty);
+
+ if (empty) {
+ setGraphic(null);
+ } else {
+ button.setOnAction(event -> {
+ Share s = getTableView().getItems().get(getIndex());
+ getTabPane().getSelectionModel().select(0);
+ stocksList.getSelectionModel().select(s.getStock());
+ showStockChart(s.getStock());
+ });
+ setGraphic(button);
+ }
+ }
+ });
+ portfolioTable
+ .getColumns()
+ .addAll(
+ symbolColumn, quantityColumn, purchasePriceColumn, currentPriceColumn, profitColumn, marketColumn);
+ return new Tab("Portfolio", portfolioTable);
+ }
+
+ private Tab createTransactionsTab() {
+ transactionsTable.setPlaceholder(new Label("No transactions yet"));
+
+ TableColumn typeColumn = new TableColumn<>("Type");
+ typeColumn.setPrefWidth(100);
+ typeColumn.setCellValueFactory(
+ data -> new SimpleStringProperty(data.getValue().getClass().getSimpleName()));
+
+ TableColumn symbolColumn = new TableColumn<>("Symbol");
+ symbolColumn.setPrefWidth(100);
+ symbolColumn.setCellValueFactory(
+ data -> new SimpleStringProperty(data.getValue().getShare().getStock().getSymbol()));
+
+ TableColumn quantityColumn = new TableColumn<>("Quantity");
+ quantityColumn.setPrefWidth(100);
+ quantityColumn.setCellValueFactory(
+ data -> new SimpleStringProperty(data.getValue().getShare().getQuantity().toPlainString()));
+
+ TableColumn weekColumn = new TableColumn<>("Week");
+ weekColumn.setPrefWidth(100);
+ weekColumn.setCellValueFactory(
+ data -> new SimpleStringProperty(String.valueOf(data.getValue().getWeek())));
+
+ TableColumn grossColumn = new TableColumn<>("Gross");
+ grossColumn.setPrefWidth(150);
+ grossColumn.setCellValueFactory(
+ data ->
+ new SimpleStringProperty(
+ ViewUtils.formatMoney(data.getValue().getCalculator().calculateGross())));
+
+ TableColumn commissionColumn = new TableColumn<>("Commission");
+ commissionColumn.setPrefWidth(150);
+ commissionColumn.setCellValueFactory(
+ data ->
+ new SimpleStringProperty(
+ ViewUtils.formatMoney(data.getValue().getCalculator().calculateCommission())));
+
+ TableColumn taxColumn = new TableColumn<>("Tax");
+ taxColumn.setPrefWidth(150);
+ taxColumn.setCellValueFactory(
+ data ->
+ new SimpleStringProperty(
+ ViewUtils.formatMoney(data.getValue().getCalculator().calculateTax())));
+
+ TableColumn totalColumn = new TableColumn<>("Total");
+ totalColumn.setPrefWidth(150);
+ totalColumn.setCellValueFactory(
+ data ->
+ new SimpleStringProperty(
+ ViewUtils.formatMoney(data.getValue().getCalculator().calculateTotal())));
+
+ transactionsTable
+ .getColumns()
+ .addAll(
+ typeColumn,
+ symbolColumn,
+ quantityColumn,
+ weekColumn,
+ grossColumn,
+ commissionColumn,
+ taxColumn,
+ totalColumn);
+
+ transactionSearchField.setPromptText("Search by symbol or type...");
+ transactionSearchField
+ .textProperty()
+ .addListener((obs, oldVal, newVal) -> refreshTransactions());
+
+ VBox content = new VBox(8, transactionSearchField, transactionsTable);
+ VBox.setVgrow(transactionsTable, Priority.ALWAYS);
+ return new Tab("Transactions", content);
+ }
+
+ private void configureButtons() {
+ buyButton.setOnAction(event -> buySelectedStock());
+ sellButton.setOnAction(event -> sellSelectedShare());
+ advanceButton.setOnAction(event -> advanceWeek());
+ sellAllAndQuitButton.setOnAction(event -> sellAllAndQuit());
+ ownedSharesBox
+ .getSelectionModel()
+ .selectedItemProperty()
+ .addListener((obs, oldShare, newShare) -> updateSellCostPreview(newShare));
+ }
+
+ private void configureQuantityControls() {
+ quantityField
+ .textProperty()
+ .addListener((obs, oldValue, newValue) -> syncQuantityFromField(newValue));
+ quantitySlider
+ .valueProperty()
+ .addListener((obs, oldValue, newValue) -> syncQuantityFromSlider(newValue.intValue()));
+ }
+
+ private void configureStocksList() {
+ stocksList.setCellFactory(
+ listView ->
+ new ListCell<>() {
+ @Override
+ protected void updateItem(Stock stock, boolean empty) {
+ super.updateItem(stock, empty);
+ if (empty || stock == null) {
+ setText(null);
+ } else {
+ setText(ViewUtils.formatStock(stock));
+ }
+ }
+ });
+
+ stocksList
+ .getSelectionModel()
+ .selectedItemProperty()
+ .addListener((obs, oldStock, newStock) -> showStockChart(newStock));
+ }
+
+ private void refreshAll() {
+ refreshPlayerInfo();
+ refreshStocks();
+ refreshPortfolio();
+ refreshTransactions();
+ refreshMarket();
+ }
+
+ private void refreshPlayerInfo() {
+ Player player = controller.getPlayer();
+ Exchange exchange = controller.getExchange();
+
+ if (player == null || exchange == null) {
+ return;
+ }
+
+ playerNameLabel.setText(player.getName());
+ weekLabel.setText(String.valueOf(exchange.getWeekNumber()));
+ moneyLabel.setText(ViewUtils.formatMoney(player.getMoney()));
+ netWorthLabel.setText(ViewUtils.formatMoney(player.getNetWorth()));
+ statusLabel.setText(player.getStatus());
+ }
+
+ private void refreshStocks() {
+ Stock selected = stocksList.getSelectionModel().getSelectedItem();
+ List items = controller.searchStocks(searchField.getText());
+ stocksList.getItems().setAll(items);
+
+ if (selected != null && items.contains(selected)) {
+ stocksList.getSelectionModel().select(selected);
+ showStockChart(selected);
+ } else if (!items.isEmpty()) {
+ stocksList.getSelectionModel().selectFirst();
+ showStockChart(items.getFirst());
+ } else {
+ showStockChart(null);
+ }
+ }
+
+ private void showStockChart(Stock stock) {
+ stockChart.getData().clear();
+
+ if (stock == null) {
+ selectedStockLabel.setText("Select a stock to see chart");
+ stockHighLabel.setText("");
+ stockLowLabel.setText("");
+ stockChangeLabel.setText("");
+ refreshQuantityControls(null);
+ return;
+ }
+
+ selectedStockLabel.setText(stock.getSymbol() + " - " + stock.getCompany());
+
+ BigDecimal change = stock.getLatestPriceChange();
+ String changeSign = change.compareTo(BigDecimal.ZERO) >= 0 ? "+" : "";
+ stockHighLabel.setText("High: " + ViewUtils.formatMoney(stock.getHighestPrice()));
+ stockLowLabel.setText("Low: " + ViewUtils.formatMoney(stock.getLowestPrice()));
+ stockChangeLabel.setText("Change: " + changeSign + ViewUtils.formatMoney(change));
+ stockChangeLabel.getStyleClass().removeAll("status-success", "status-error");
+ stockChangeLabel.getStyleClass().add(change.compareTo(BigDecimal.ZERO) >= 0 ? "status-success" : "status-error");
+ refreshQuantityControls(stock);
+
+ XYChart.Series series = new XYChart.Series<>();
+ List prices = stock.getHistoricalPrices();
+ xAxis.setUpperBound(Math.max(2, prices.size()));
+ for (int i = 0; i < prices.size(); i++) {
+ series.getData().add(new XYChart.Data<>(i + 1, prices.get(i)));
+ }
+ stockChart.getData().add(series);
+ }
+
+ private void refreshQuantityControls(Stock stock) {
+ if (stock == null) {
+ ownedQuantityLabel.setText("Owned: 0");
+ quantityField.setDisable(true);
+ quantitySlider.setDisable(true);
+ buyButton.setDisable(true);
+ sellButton.setDisable(true);
+ ownedSharesBox.getItems().clear();
+ return;
+ }
+
+ int maxBuyable = controller.getMaxBuyableQuantity(stock.getSymbol());
+ int current = ViewUtils.clampQuantity(getQuantityValue(), Math.max(1, maxBuyable));
+
+ updatingQuantityControls = true;
+ quantityField.setText(String.valueOf(current));
+ quantitySlider.setMin(1);
+ quantitySlider.setMax(Math.max(1, maxBuyable));
+ quantitySlider.setValue(current);
+ quantityField.setDisable(false);
+ quantitySlider.setDisable(false);
+ buyButton.setDisable(maxBuyable <= 0);
+ sellButton.setDisable(false);
+ updatingQuantityControls = false;
+
+ ownedQuantityLabel.setText("Owned: " + controller.getOwnedQuantity(stock.getSymbol()));
+ ownedSharesBox.getItems().setAll(controller.getOwnedShares(stock.getSymbol()));
+ if (!ownedSharesBox.getItems().isEmpty()) {
+ ownedSharesBox.getSelectionModel().selectFirst();
+ }
+ updateBuyCostPreview(stock, current);
+ updateSellCostPreview(ownedSharesBox.getSelectionModel().getSelectedItem());
+ }
+
+ private void refreshPortfolio() {
+ Player player = controller.getPlayer();
+
+ portfolioTable.getItems().setAll(player.getPortfolio().getShares());
+ }
+
+ private void refreshTransactions() {
+ Player player = controller.getPlayer();
+ if (player == null) return;
+
+ String filter = transactionSearchField.getText().trim().toLowerCase();
+ List all = player.getTransactionArchive().getTransactions();
+ if (filter.isBlank()) {
+ transactionsTable.getItems().setAll(all);
+ } else {
+ transactionsTable
+ .getItems()
+ .setAll(
+ all.stream()
+ .filter(
+ t ->
+ t.getShare().getStock().getSymbol().toLowerCase().contains(filter)
+ || t.getClass().getSimpleName().toLowerCase().contains(filter))
+ .toList());
+ }
+ }
+
+ private void buySelectedStock() {
+ Stock selectedStock = stocksList.getSelectionModel().getSelectedItem();
+ if (selectedStock == null) {
+ setActionStatus("Select a stock first.", false);
+ return;
+ }
+
+ try {
+ Transaction transaction =
+ controller.buyStock(selectedStock.getSymbol(), BigDecimal.valueOf(getQuantityValue()));
+ var calc = transaction.getCalculator();
+ setActionStatus(
+ "Bought "
+ + selectedStock.getSymbol()
+ + " — Gross: "
+ + ViewUtils.formatMoney(calc.calculateGross())
+ + " Commission: "
+ + ViewUtils.formatMoney(calc.calculateCommission())
+ + " Total: "
+ + ViewUtils.formatMoney(calc.calculateTotal()),
+ true);
+ refreshAll();
+ showStockChart(selectedStock);
+ } catch (RuntimeException ex) {
+ setActionStatus(ex.getMessage(), false);
+ }
+ }
+
+ private void sellSelectedShare() {
+ Stock selectedStock = stocksList.getSelectionModel().getSelectedItem();
+ Share selectedShare = ownedSharesBox.getSelectionModel().getSelectedItem();
+ if (selectedStock == null) {
+ setActionStatus("Select a stock first.", false);
+ return;
+ }
+ if (selectedShare == null) {
+ setActionStatus("Select a share lot.", false);
+ return;
+ }
+
+ try {
+ Transaction transaction = controller.sellShare(selectedShare);
+ var calc = transaction.getCalculator();
+ setActionStatus(
+ "Sold "
+ + selectedStock.getSymbol()
+ + " — Gross: "
+ + ViewUtils.formatMoney(calc.calculateGross())
+ + " Commission: "
+ + ViewUtils.formatMoney(calc.calculateCommission())
+ + " Tax: "
+ + ViewUtils.formatMoney(calc.calculateTax())
+ + " Net: "
+ + ViewUtils.formatMoney(calc.calculateTotal()),
+ true);
+ refreshAll();
+ showStockChart(selectedStock);
+ } catch (RuntimeException ex) {
+ setActionStatus(ex.getMessage(), false);
+ }
+ }
+
+ private void advanceWeek() {
+ controller.advanceWeek();
+ refreshAll();
+ showStockChart(stocksList.getSelectionModel().getSelectedItem());
+ }
+
+ public void sellAllShares() {
+ List shares =
+ new java.util.ArrayList<>(controller.getPlayer().getPortfolio().getShares());
+ for (Share share : shares) {
+ controller.sellShare(share);
+ }
+ refreshAll();
+ }
+
+ private void sellAllAndQuit() {
+ sellAllShares();
+ onSellAllAndQuit.run();
+ }
+
+ private void setActionStatus(String message, boolean success) {
+ actionStatusLabel.setText(message);
+ actionStatusLabel.getStyleClass().removeAll("status-success", "status-error");
+ actionStatusLabel.getStyleClass().add(success ? "status-success" : "status-error");
+ }
+
+ private int getQuantityValue() {
+ String text = quantityField.getText();
+ if (text == null || text.isBlank()) {
+ return (int) Math.round(quantitySlider.getValue());
+ }
+ try {
+ return Integer.parseInt(text.trim());
+ } catch (NumberFormatException e) {
+ return 1;
+ }
+ }
+
+ private void syncQuantityFromField(String newValue) {
+ if (updatingQuantityControls) {
+ return;
+ }
+ if (newValue == null || newValue.isBlank() || newValue.equals("-")) {
+ return;
+ }
+
+ updatingQuantityControls = true;
+ int clamped = ViewUtils.clampQuantity(parseQuantity(newValue), getCurrentMaxBuyable());
+ quantityField.setText(String.valueOf(clamped));
+ quantitySlider.setValue(clamped);
+ updatingQuantityControls = false;
+ updateBuyCostPreview(stocksList.getSelectionModel().getSelectedItem(), clamped);
+ }
+
+ private void syncQuantityFromSlider(int newValue) {
+ if (updatingQuantityControls) {
+ return;
+ }
+
+ updatingQuantityControls = true;
+ int clamped = ViewUtils.clampQuantity(newValue, getCurrentMaxBuyable());
+ quantityField.setText(String.valueOf(clamped));
+ quantitySlider.setValue(clamped);
+ updatingQuantityControls = false;
+ updateBuyCostPreview(stocksList.getSelectionModel().getSelectedItem(), clamped);
+ }
+
+ private int parseQuantity(String text) {
+ try {
+ return Integer.parseInt(text.trim());
+ } catch (NumberFormatException e) {
+ // Just make anything it can't parse to 1
+ return 1;
+ }
+ }
+
+ private int getCurrentMaxBuyable() {
+ Stock selected = stocksList.getSelectionModel().getSelectedItem();
+ if (selected == null) {
+ return 1;
+ }
+ return Math.max(1, controller.getMaxBuyableQuantity(selected.getSymbol()));
+ }
+
+ public TabPane getTabPane() {
+ return this.tabPane;
+ }
+ private String formatStock(Stock stock) {
+ return ViewUtils.formatStock(stock);
+ }
+
+ private void updateBuyCostPreview(Stock stock, int quantity) {
+ if (stock == null || quantity <= 0) {
+ buyCostPreviewLabel.setText("");
+ return;
+ }
+ Share tempShare = new Share(stock, BigDecimal.valueOf(quantity), stock.getSalesPrice());
+ PurchaseCalculator calc = new PurchaseCalculator(tempShare);
+ buyCostPreviewLabel.setText(
+ "Gross: "
+ + ViewUtils.formatMoney(calc.calculateGross())
+ + " Commission: "
+ + ViewUtils.formatMoney(calc.calculateCommission())
+ + " Total: "
+ + ViewUtils.formatMoney(calc.calculateTotal()));
+ }
+
+ private void updateSellCostPreview(Share share) {
+ if (share == null) {
+ sellCostPreviewLabel.setText("");
+ return;
+ }
+ SaleCalculator calc = new SaleCalculator(share);
+ sellCostPreviewLabel.setText(
+ "Gross: "
+ + ViewUtils.formatMoney(calc.calculateGross())
+ + " Commission: "
+ + ViewUtils.formatMoney(calc.calculateCommission())
+ + " Tax: "
+ + ViewUtils.formatMoney(calc.calculateTax())
+ + " Net: "
+ + ViewUtils.formatMoney(calc.calculateTotal()));
+ }
+
+ private Tab createMarketTab() {
+ gainersTable.setPlaceholder(new Label("Advance a week to see data"));
+ losersTable.setPlaceholder(new Label("Advance a week to see data"));
+
+ for (TableView table : new TableView[] {gainersTable, losersTable}) {
+ TableColumn symCol = new TableColumn<>("Symbol");
+ symCol.setPrefWidth(100);
+ symCol.setCellValueFactory(d -> new SimpleStringProperty(d.getValue().getSymbol()));
+
+ TableColumn nameCol = new TableColumn<>("Company");
+ nameCol.setPrefWidth(200);
+ nameCol.setCellValueFactory(d -> new SimpleStringProperty(d.getValue().getCompany()));
+
+ TableColumn priceCol = new TableColumn<>("Price");
+ priceCol.setPrefWidth(100);
+ priceCol.setCellValueFactory(
+ d -> new SimpleStringProperty(ViewUtils.formatMoney(d.getValue().getSalesPrice())));
+
+ TableColumn changeCol = new TableColumn<>("Change");
+ changeCol.setPrefWidth(100);
+ changeCol.setCellValueFactory(
+ d -> {
+ BigDecimal change = d.getValue().getLatestPriceChange();
+ String sign = change.compareTo(BigDecimal.ZERO) >= 0 ? "+" : "";
+ return new SimpleStringProperty(sign + ViewUtils.formatMoney(change));
+ });
+ changeCol.setCellFactory(
+ col ->
+ new TableCell<>() {
+ @Override
+ protected void updateItem(String val, boolean empty) {
+ super.updateItem(val, empty);
+ getStyleClass().removeAll("status-success", "status-error");
+ if (empty || val == null) {
+ setText(null);
+ return;
+ }
+ setText(val);
+ getStyleClass().add(val.startsWith("+") ? "status-success" : "status-error");
+ }
+ });
+ table.getColumns().addAll(symCol, nameCol, priceCol, changeCol);
+ }
+
+ Label gainersTitle = new Label("TOP GAINERS");
+ gainersTitle.getStyleClass().add("section-title");
+ Label losersTitle = new Label("TOP LOSERS");
+ losersTitle.getStyleClass().add("section-title");
+
+ VBox gainersBox = new VBox(6, gainersTitle, gainersTable);
+ VBox.setVgrow(gainersTable, Priority.ALWAYS);
+ VBox losersBox = new VBox(6, losersTitle, losersTable);
+ VBox.setVgrow(losersTable, Priority.ALWAYS);
+
+ HBox content = new HBox(16, gainersBox, new Separator(Orientation.VERTICAL), losersBox);
+ HBox.setHgrow(gainersBox, Priority.ALWAYS);
+ HBox.setHgrow(losersBox, Priority.ALWAYS);
+ content.setPadding(new javafx.geometry.Insets(12));
+ return new Tab("Market", content);
+ }
+
+ private void refreshMarket() {
+ Exchange exchange = controller.getExchange();
+ if (exchange == null) return;
+ gainersTable.getItems().setAll(exchange.getGainers(10));
+ losersTable.getItems().setAll(exchange.getLosers(10));
+ }
+
+ // Listener callbacks update the shared header and the stocks tab.
+ @Override
+ public void onMoneyChanged(BigDecimal newBalance) {
+ refreshAll();
+ }
+
+ @Override
+ public void onPortfolioChanged() {
+ refreshAll();
+ }
+
+ @Override
+ public void onStatusChanged(String newStatus) {
+ refreshAll();
+ }
+
+ @Override
+ public void onWeekAdvanced(int newWeek) {
+ refreshAll();
+ }
+
+ @Override
+ public void onTransactionCompleted(Transaction transaction) {
+ refreshAll();
+ }
+}
diff --git a/src/main/java/millions/view/StartView.java b/src/main/java/millions/view/StartView.java
new file mode 100644
index 0000000..481cff3
--- /dev/null
+++ b/src/main/java/millions/view/StartView.java
@@ -0,0 +1,175 @@
+package millions.view;
+
+import java.io.File;
+import java.math.BigDecimal;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Paths;
+import java.util.Objects;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.TextField;
+import javafx.scene.control.TextFormatter;
+import javafx.scene.layout.VBox;
+import javafx.stage.FileChooser;
+import javafx.stage.Stage;
+
+/** The initial game setup screen where the player enters their info. */
+public class StartView extends VBox {
+ private static final Logger logger = Logger.getLogger(StartView.class.getName());
+ private TextField nameField;
+ private TextField startingAmountField;
+ private TextField preRunWeeksField;
+ private File selectedFile;
+ private Button filepickerButton;
+ private Button startButton;
+
+ public StartView(Stage stage) {
+ setAlignment(Pos.CENTER);
+ setSpacing(12);
+ setPadding(new Insets(40));
+
+ nameField = new TextField("user");
+ nameField.setPromptText("Player name:");
+ nameField.setMaxWidth(250);
+ nameField.textProperty().addListener((obs, oldVal, newVal) -> checkStartButtonValid());
+ // Default to 50000
+ startingAmountField = new TextField("50000");
+ startingAmountField.setPromptText("Starting amount:");
+ startingAmountField.setMaxWidth(250);
+ startingAmountField
+ .textProperty()
+ .addListener((obs, oldVal, newVal) -> checkStartButtonValid());
+ startingAmountField.setTextFormatter(
+ new TextFormatter<>(
+ change -> {
+ if (change.getControlNewText().matches("([0-9]*)?")) {
+ return change;
+ }
+ return null;
+ }));
+ // Pre run weeks to run simulated weeks before the player starts
+ preRunWeeksField = new TextField("12");
+ preRunWeeksField.setPromptText("Pre run weeks:");
+ preRunWeeksField.setMaxWidth(250);
+ preRunWeeksField.textProperty().addListener((obs, oldVal, newVal) -> checkStartButtonValid());
+ preRunWeeksField.setTextFormatter(
+ new TextFormatter<>(
+ change -> {
+ if (change.getControlNewText().matches("([0-9]*)?")) {
+ return change;
+ }
+ return null;
+ }));
+
+ selectedFile = loadDefaultStocksFile();
+
+ filepickerButton = new Button();
+ filepickerButton.setText("Default stocks");
+ filepickerButton.setMaxWidth(250);
+ filepickerButton.setOnAction(
+ e -> {
+ FileChooser chooser = new FileChooser();
+ chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("CSV files", "*.csv"));
+ chooser.setTitle("Select stock CSV file");
+ File file = chooser.showOpenDialog(stage);
+
+ if (file != null) {
+ selectedFile = file;
+ filepickerButton.setText(file.getName());
+ checkStartButtonValid();
+ }
+ });
+
+ startButton = new Button("Start game");
+ startButton.getStyleClass().add("btn-primary");
+ startButton.setDisable(true);
+
+ javafx.scene.layout.HBox title = ViewUtils.buildTitle();
+ title.getStyleClass().add("title-hero");
+ title.setAlignment(javafx.geometry.Pos.CENTER);
+
+ getChildren()
+ .addAll(
+ title,
+ fieldBox("Player name", nameField),
+ fieldBox("Starting amount ($)", startingAmountField),
+ fieldBox("Pre-run weeks", preRunWeeksField),
+ fieldBox("Stock file", filepickerButton),
+ startButton);
+
+ checkStartButtonValid();
+ }
+
+ private File loadDefaultStocksFile() {
+ try {
+ URL resource = Objects.requireNonNull(getClass().getResource("/data/default-stocks.csv"));
+ return Paths.get(resource.toURI()).toFile();
+ } catch (URISyntaxException e) {
+ logger.log(Level.SEVERE, "Error accessing default stocks file", e);
+ throw new IllegalStateException("Could not load default stocks file", e);
+ }
+ }
+
+ /** Enables/Disables start button */
+ private void checkStartButtonValid() {
+ boolean valid = true;
+
+ if (nameField.getText().isBlank()) {
+ valid = false;
+ }
+
+ if (selectedFile == null) {
+ valid = false;
+ }
+
+ try {
+ new BigDecimal(startingAmountField.getText());
+ } catch (NumberFormatException e) {
+ valid = false;
+ }
+
+ try {
+ if (Integer.parseInt(preRunWeeksField.getText()) < 0) {
+ valid = false;
+ }
+ } catch (NumberFormatException e) {
+ valid = false;
+ }
+
+ startButton.setDisable(!valid);
+ }
+
+ public String getName() {
+ return nameField.getText();
+ }
+
+ public String getStartingAmount() {
+ return startingAmountField.getText();
+ }
+
+ public int getPreRunWeeks() {
+ return Integer.parseInt(preRunWeeksField.getText());
+ }
+
+ public File getSelectedFile() {
+ return selectedFile;
+ }
+
+ public Button getStartButton() {
+ return startButton;
+ }
+
+ private static javafx.scene.layout.VBox fieldBox(String labelText, javafx.scene.Node field) {
+ Label label = new Label(labelText);
+ label.getStyleClass().add("section-title");
+ javafx.scene.layout.VBox box = new javafx.scene.layout.VBox(4, label, field);
+ box.setAlignment(Pos.CENTER_LEFT);
+ box.setMaxWidth(250);
+ return box;
+ }
+}
diff --git a/src/main/java/millions/view/ViewUtils.java b/src/main/java/millions/view/ViewUtils.java
new file mode 100644
index 0000000..a554e0d
--- /dev/null
+++ b/src/main/java/millions/view/ViewUtils.java
@@ -0,0 +1,122 @@
+package millions.view;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import javafx.geometry.Pos;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.ListCell;
+import javafx.scene.layout.HBox;
+import millions.model.Share;
+import millions.model.Stock;
+import millions.model.Transaction;
+
+/** Small utility helpers for view formatting and simple calculations. */
+public final class ViewUtils {
+
+ private ViewUtils() {}
+
+ public static String formatMoney(BigDecimal value) {
+ return value.setScale(2, RoundingMode.HALF_UP).toPlainString() + "$";
+ }
+
+ public static HBox buildTitle() {
+ Label million = new Label("MILLION");
+ million.getStyleClass().add("title-main");
+ Label dollar = new Label("$");
+ dollar.getStyleClass().add("title-dollar");
+ HBox box = new HBox(0, million, dollar);
+ box.setAlignment(Pos.CENTER_LEFT);
+ return box;
+ }
+
+ public static void configureOwnedSharesBox(ComboBox comboBox) {
+ comboBox.setCellFactory(
+ box ->
+ new ListCell<>() {
+ @Override
+ protected void updateItem(Share share, boolean empty) {
+ super.updateItem(share, empty);
+ getStyleClass().removeAll("status-success", "status-error");
+ if (empty || share == null) {
+ setText(null);
+ } else {
+ setText(formatOwnedShare(share));
+ getStyleClass().add(getProfitClass(share));
+ }
+ }
+ });
+ comboBox.setButtonCell(
+ new ListCell<>() {
+ @Override
+ protected void updateItem(Share share, boolean empty) {
+ super.updateItem(share, empty);
+ getStyleClass().removeAll("status-success", "status-error");
+ if (empty || share == null) {
+ setText("Select lot");
+ } else {
+ setText(formatOwnedShare(share));
+ getStyleClass().add(getProfitClass(share));
+ }
+ }
+ });
+ }
+
+ public static String formatStock(Stock stock) {
+ return stock.getSymbol()
+ + " - "
+ + stock.getCompany()
+ + " ("
+ + formatMoney(stock.getSalesPrice())
+ + ")";
+ }
+
+ public static String formatOwnedShare(Share share) {
+ BigDecimal profit = getShareProfit(share);
+ String sign = profit.compareTo(BigDecimal.ZERO) >= 0 ? "+" : "";
+ return share.getQuantity()
+ + " @ "
+ + formatMoney(share.getPurchasePrice())
+ + " "
+ + sign
+ + formatMoney(profit);
+ }
+
+ public static String getProfitClass(Share share) {
+ return getShareProfit(share).compareTo(BigDecimal.ZERO) >= 0 ? "status-success" : "status-error";
+ }
+
+ public static BigDecimal getShareProfit(Share share) {
+ BigDecimal currentPrice = share.getStock().getSalesPrice();
+ return currentPrice.subtract(share.getPurchasePrice()).multiply(share.getQuantity());
+ }
+
+ public static int clampQuantity(int quantity, int maxBuyable) {
+ int upperBound = Math.max(1, maxBuyable);
+ if (quantity < 1) {
+ return 1;
+ }
+ if (quantity > upperBound) {
+ return upperBound;
+ }
+ return quantity;
+ }
+
+ public static String formatPortfolioShare(Share share) {
+ return share.getStock().getSymbol()
+ + "|"
+ + share.getQuantity()
+ + "|"
+ + share.getPurchasePrice();
+ }
+
+ public static String formatTransaction(Transaction transaction) {
+ return transaction.getClass().getSimpleName()
+ + "|"
+ + transaction.getShare().getStock().getSymbol()
+ + "|"
+ + transaction.getShare().getQuantity()
+ + "|"
+ + transaction.getWeek();
+ }
+}
diff --git a/src/main/resources/data/default-stocks.csv b/src/main/resources/data/default-stocks.csv
new file mode 100644
index 0000000..4340bf3
--- /dev/null
+++ b/src/main/resources/data/default-stocks.csv
@@ -0,0 +1,20 @@
+# Default stock data for Millions
+# symbol,name,price,drift;volatility;cycleSpeed;cycleNoise;explosionProbability;explosionSize
+AAPL,Pear Inc.,276.43,0.0015;0.022;0.35;0.015;0.015;0.10
+MSFT,MacroHard,404.68,0.0015;0.018;0.30;0.013;0.012;0.08
+GOOGL,Googly Eyes Inc.,187.34,0.002;0.027;0.40;0.018;0.020;0.12
+AMZN,The Great Bazillion,214.10,0.0015;0.024;0.32;0.015;0.015;0.10
+TSLA,Formerly Twitter,342.58,0.002;0.045;0.50;0.027;0.030;0.20
+NVDA,GPUs,191.27,0.002;0.033;0.42;0.021;0.020;0.14
+META,Still Facebook,593.11,0.0015;0.021;0.28;0.014;0.012;0.09
+NFLX,And Chill,982.44,0.002;0.038;0.45;0.024;0.025;0.18
+AMD,Advanced Meme Devices,156.79,0.002;0.036;0.48;0.022;0.022;0.16
+JPM,Just Plain Money Chase,245.33,0.001;0.015;0.20;0.009;0.010;0.06
+WFC,Wealthy Folks Credit,82.15,0.001;0.015;0.18;0.009;0.010;0.05
+BAC,Bank of Awkward Capital,48.92,0.001;0.016;0.18;0.009;0.010;0.05
+DIS,Disknee,112.55,0.0015;0.021;0.30;0.013;0.012;0.08
+KO,Cocaine,67.14,0.001;0.014;0.22;0.008;0.009;0.05
+PEP,Pepe Cola Co.,159.03,0.001;0.015;0.24;0.009;0.010;0.06
+IBM,Incredibly Boring Machines,241.07,0.001;0.014;0.20;0.008;0.009;0.05
+ORCL,Databases?,171.62,0.0015;0.018;0.26;0.011;0.012;0.07
+SAP,Sadly Applying Patches,292.88,0.0015;0.021;0.28;0.013;0.012;0.08
diff --git a/src/main/resources/styles/millions.css b/src/main/resources/styles/millions.css
new file mode 100644
index 0000000..8e05692
--- /dev/null
+++ b/src/main/resources/styles/millions.css
@@ -0,0 +1,190 @@
+.root {
+ -fx-font-size: 14px;
+ -fx-background-color: bg;
+
+ bg: #1e1e2e;
+ bg-elevated: #252535;
+ bg-hover: #2a2a3e;
+ bg-component: #2e2e42;
+ bg-selected: #1a3a2a;
+ border: #3a3a4a;
+ text-primary: #e8e8f0;
+ text-muted: #8888a0;
+ accent: #22c55e;
+ danger: #ef4444;
+}
+
+.header-panel {
+ -fx-background-color: bg;
+ -fx-padding: 10px 16px;
+ -fx-border-color: accent border border border;
+ -fx-border-width: 2px 0 1px 0;
+ -fx-spacing: 20px;
+}
+
+.toolbar-panel {
+ -fx-background-color: bg;
+ -fx-padding: 10px 14px;
+ -fx-border-color: border;
+ -fx-border-width: 1px 0 0 0;
+}
+
+.label { -fx-text-fill: text-primary; }
+.status-success { -fx-text-fill: accent; }
+.status-error { -fx-text-fill: danger; }
+
+.title {
+ -fx-font-size: 32px;
+ -fx-font-weight: bold;
+ -fx-text-fill: accent;
+}
+
+.title-main, .title-dollar, .game-over-title {
+ -fx-font-weight: bold;
+ -fx-font-family: "Georgia";
+ -fx-font-style: italic;
+}
+
+.title-main { -fx-font-size: 32px; -fx-text-fill: text-primary; }
+.title-dollar { -fx-font-size: 36px; -fx-text-fill: accent; }
+.game-over-title { -fx-font-size: 56px; -fx-text-fill: text-primary; }
+
+.title-hero .title-main { -fx-font-size: 80px; }
+.title-hero .title-dollar { -fx-font-size: 88px; }
+
+.selected-stock-label {
+ -fx-font-size: 18px;
+ -fx-font-weight: bold;
+ -fx-text-fill: accent;
+}
+
+.section-title {
+ -fx-font-size: 11px;
+ -fx-font-weight: bold;
+ -fx-text-fill: text-muted;
+}
+
+.cost-preview { -fx-font-size: 11px; -fx-text-fill: text-muted; -fx-font-family: "monospace"; }
+.stat-value { -fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: text-primary; }
+
+.button {
+ -fx-background-color: bg-component;
+ -fx-text-fill: text-primary;
+ -fx-background-radius: 6px;
+ -fx-border-color: border;
+ -fx-border-radius: 6px;
+ -fx-border-width: 1px;
+ -fx-padding: 6px 14px;
+ -fx-cursor: hand;
+}
+
+.button:hover { -fx-background-color: #3a3a52; -fx-border-color: accent; }
+.button:pressed { -fx-background-color: #22283a; }
+.button:disabled { -fx-opacity: 0.4; }
+
+.btn-primary {
+ -fx-background-color: derive(accent, -20%);
+ -fx-text-fill: white;
+ -fx-border-color: accent;
+ -fx-font-weight: bold;
+}
+
+.btn-primary:hover { -fx-background-color: accent; -fx-border-color: derive(accent, 25%); }
+.btn-primary:pressed { -fx-background-color: derive(accent, -35%); }
+
+.btn-danger {
+ -fx-background-color: derive(danger, -65%);
+ -fx-text-fill: derive(danger, 40%);
+ -fx-border-color: danger;
+}
+
+.btn-danger:hover { -fx-background-color: derive(danger, -55%); -fx-border-color: derive(danger, 15%); }
+.btn-danger:pressed { -fx-background-color: derive(danger, -75%); }
+
+.text-field {
+ -fx-background-color: bg;
+ -fx-text-fill: text-primary;
+ -fx-prompt-text-fill: text-muted;
+ -fx-background-radius: 6px;
+ -fx-border-color: border;
+ -fx-border-radius: 6px;
+ -fx-border-width: 1px;
+ -fx-padding: 5px 10px;
+}
+
+.text-field:focused,
+.combo-box-base:focused { -fx-border-color: accent; }
+
+.combo-box, .combo-box-base {
+ -fx-background-color: bg-component;
+ -fx-background-radius: 6px;
+ -fx-border-color: border;
+ -fx-border-radius: 6px;
+ -fx-border-width: 1px;
+}
+
+.combo-box-base .list-cell { -fx-text-fill: text-primary; }
+.combo-box-popup .list-view { -fx-background-color: bg-elevated; -fx-border-color: border; }
+
+.tab-header-background { -fx-background-color: bg; }
+
+.tab-pane .tab {
+ -fx-background-color: bg-elevated;
+ -fx-background-radius: 6 6 0 0;
+ -fx-padding: 6px 16px;
+}
+
+.tab-pane .tab:selected {
+ -fx-background-color: bg;
+ -fx-border-color: accent transparent transparent transparent;
+ -fx-border-width: 2px 0 0 0;
+}
+
+.tab-pane .tab-label { -fx-text-fill: text-muted; -fx-font-size: 13px; -fx-font-weight: bold; }
+.tab-pane .tab:selected .tab-label { -fx-text-fill: text-primary; }
+
+.slider .track { -fx-background-color: border; -fx-background-radius: 4px; }
+.slider .thumb { -fx-background-color: accent; -fx-background-radius: 50%; -fx-effect: none; }
+
+.list-view, .table-view {
+ -fx-background-color: bg;
+ -fx-control-inner-background: bg;
+ -fx-border-color: border;
+ -fx-border-width: 1px;
+}
+
+.list-view { -fx-background-radius: 6px; }
+.table-view { -fx-table-cell-border-color: bg-component; -fx-table-header-border-color: border; }
+
+.table-view .column-header,
+.table-view .filler {
+ -fx-background-color: bg-elevated;
+ -fx-border-color: border;
+ -fx-border-width: 0 1px 1px 0;
+ -fx-padding: 6px 8px;
+}
+
+.table-row-cell {
+ -fx-background-color: bg;
+ -fx-text-background-color: text-primary;
+ -fx-border-color: transparent transparent bg-component transparent;
+ -fx-border-width: 1px;
+}
+
+/* Every other cell differently collored */
+.table-row-cell:odd, .list-cell:odd {
+ -fx-background-color: bg-elevated;
+}
+
+.table-row-cell:hover, .list-cell:hover {
+ -fx-background-color: bg-hover;
+}
+.table-row-cell:selected { -fx-background-color: bg-selected; }
+.table-cell { -fx-text-fill: text-primary; -fx-padding: 6px 8px; }
+
+.list-cell { -fx-background-color: transparent; -fx-text-fill: text-primary; -fx-padding: 6px 10px; }
+.list-cell:selected { -fx-background-color: bg-selected; -fx-text-fill: derive(accent, 25%); }
+
+.chart, .chart-plot-background, .chart-content { -fx-background-color: bg; }
+.chart { -fx-padding: 10px; }
+.chart-series-line { -fx-stroke: accent; -fx-stroke-width: 2px; }
diff --git a/src/test/java/millions/ExchangeTest.java b/src/test/java/millions/ExchangeTest.java
deleted file mode 100644
index a852a0d..0000000
--- a/src/test/java/millions/ExchangeTest.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package millions;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class ExchangeTest {}
diff --git a/src/test/java/millions/PlayerTest.java b/src/test/java/millions/PlayerTest.java
deleted file mode 100644
index 62a3263..0000000
--- a/src/test/java/millions/PlayerTest.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package millions;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class PlayerTest {}
diff --git a/src/test/java/millions/PortfolioTest.java b/src/test/java/millions/PortfolioTest.java
deleted file mode 100644
index 3fb1019..0000000
--- a/src/test/java/millions/PortfolioTest.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package millions;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class PortfolioTest {}
diff --git a/src/test/java/millions/PurchaseTest.java b/src/test/java/millions/PurchaseTest.java
deleted file mode 100644
index a67c37a..0000000
--- a/src/test/java/millions/PurchaseTest.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package millions;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class PurchaseTest {}
diff --git a/src/test/java/millions/SaleTest.java b/src/test/java/millions/SaleTest.java
deleted file mode 100644
index b808f7a..0000000
--- a/src/test/java/millions/SaleTest.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package millions;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class SaleTest {}
diff --git a/src/test/java/millions/ShareTest.java b/src/test/java/millions/ShareTest.java
deleted file mode 100644
index cf3d676..0000000
--- a/src/test/java/millions/ShareTest.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package millions;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class ShareTest {}
diff --git a/src/test/java/millions/StockTest.java b/src/test/java/millions/StockTest.java
deleted file mode 100644
index eda8962..0000000
--- a/src/test/java/millions/StockTest.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package millions;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class StockTest {}
diff --git a/src/test/java/millions/TransactionArchiveTest.java b/src/test/java/millions/TransactionArchiveTest.java
deleted file mode 100644
index bdc9ca8..0000000
--- a/src/test/java/millions/TransactionArchiveTest.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package millions;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class TransactionArchiveTest {}
diff --git a/src/test/java/millions/TransactionTest.java b/src/test/java/millions/TransactionTest.java
deleted file mode 100644
index 9074225..0000000
--- a/src/test/java/millions/TransactionTest.java
+++ /dev/null
@@ -1,5 +0,0 @@
-package millions;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-class TransactionTest {}
diff --git a/src/test/java/millions/controller/GameControllerTest.java b/src/test/java/millions/controller/GameControllerTest.java
new file mode 100644
index 0000000..fd63cc6
--- /dev/null
+++ b/src/test/java/millions/controller/GameControllerTest.java
@@ -0,0 +1,27 @@
+package millions.controller;
+
+import millions.controller.fileIO.UncheckedFileNotFoundException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.math.BigDecimal;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class GameControllerTest {
+ GameController gameController;
+ @BeforeEach
+ public void setUpController() {
+ this.gameController = new GameController();
+ }
+
+ @Test
+ public void startGameFileNotFoundTest() {
+ String name = "name";
+ BigDecimal startingMoney = new BigDecimal("100");
+ Path stockFilePath = Path.of("nonexistantfile");
+ int prerunWeeks = 1;
+ assertThrows(UncheckedFileNotFoundException.class, () -> {gameController.startGame(name,startingMoney,stockFilePath,prerunWeeks);});
+ }
+}
diff --git a/src/test/java/millions/controller/fileIO/CSV/CSVStockFileParserTest.java b/src/test/java/millions/controller/fileIO/CSV/CSVStockFileParserTest.java
new file mode 100644
index 0000000..efefb7d
--- /dev/null
+++ b/src/test/java/millions/controller/fileIO/CSV/CSVStockFileParserTest.java
@@ -0,0 +1,74 @@
+package millions.controller.fileIO.CSV;
+
+import millions.controller.fileIO.InvalidFormatException;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+
+import java.util.List;
+
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class CSVStockFileParserTest {
+
+ static String exampleString;
+ final CSVStockFileParser parser = new CSVStockFileParser();
+
+ @BeforeEach
+ public void setUpTestString() {
+ exampleString = "# Top 500 US Stocks by Market Cap\n";
+ exampleString += "# Ticker,Name,Price\n";
+ exampleString += "\n";
+ exampleString += "NVDA,Nvidia,191.27\n";
+ exampleString += "AAPL,Apple Inc.,276.43\n";
+ exampleString += "MSFT,Microsoft,404.68\n";
+
+ }
+
+ @Test
+ public void parseStockFileTest(){
+ List testList = List.of(exampleString.split("\n"));
+ assertEquals(3, parser.parse(testList).size());
+ }
+
+ @Test
+ public void InvalidFormatExceptionTest() {
+ exampleString += "Line with incorrect amount of data";
+ List testList = List.of(exampleString.split("\n"));
+ Exception e = assertThrows(InvalidFormatException.class, () -> {parser.parse(testList);});
+ String expectedMessage = "Incorrect format for CSV File: incorrect amount of data fields detected on one or more lines";
+ assertEquals(expectedMessage, e.getMessage());
+ }
+
+ @Test
+ public void NumberConversionExceptionTest() {
+ exampleString += "Company, Company Inc., NotANumber";
+ List testList = List.of(exampleString.split("\n"));
+ Exception e = assertThrows(InvalidFormatException.class, () -> {parser.parse(testList);});
+ String expectedMessage = "Error with number conversion on line: Company, Company Inc., NotANumber\n" +
+ "Last field must be a number";
+ assertEquals(expectedMessage, e.getMessage());
+ }
+
+ @Test
+ public void EmptySymbolFieldExceptionTest() {
+ exampleString += ",test,1";
+ List testList = List.of(exampleString.split("\n"));
+ Exception e = assertThrows(InvalidFormatException.class, () -> {parser.parse(testList);});
+ String expectedMessage = "Illegal argument on line: ,test,1\n" +
+ "Symbol cannot be null or blank";
+ assertEquals(expectedMessage, e.getMessage());
+ }
+ @Test
+ public void EmptyCompanyNameFieldExceptionTest() {
+ exampleString += "test,,1";
+ List testList = List.of(exampleString.split("\n"));
+ Exception e = assertThrows(InvalidFormatException.class, () -> {parser.parse(testList);});
+ String expectedMessage = "Illegal argument on line: test,,1\n" +
+ "Company cannot be null or blank";
+ assertEquals(expectedMessage, e.getMessage());
+ }
+}
diff --git a/src/test/java/millions/controller/fileIO/CSV/StockFileReaderTest.java b/src/test/java/millions/controller/fileIO/CSV/StockFileReaderTest.java
new file mode 100644
index 0000000..159c948
--- /dev/null
+++ b/src/test/java/millions/controller/fileIO/CSV/StockFileReaderTest.java
@@ -0,0 +1,35 @@
+package millions.controller.fileIO.CSV;
+
+import millions.controller.fileIO.StockFileReader;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class StockFileReaderTest {
+ @TempDir
+ static Path tempDir;
+
+ static Path sharedFile;
+ @BeforeAll
+ public static void setUpTestFile() throws Exception {
+ sharedFile = Files.createFile(tempDir.resolve("file.csv"));
+ String string = "# Top 500 US Stocks by Market Cap\n";
+ string += "# Ticker,Name,Price\n";
+ string += "\n";
+ string += "NVDA,Nvidia,191.27\n";
+ string += "AAPL,Apple Inc.,276.43\n";
+ string += "MSFT,Microsoft,404.68\n";
+ Files.writeString(sharedFile, string);
+ }
+
+ @Test
+ public void testReadStockFile() {
+ StockFileReader stockFileReader = new StockFileReader();
+ assertEquals(6, stockFileReader.readFile(sharedFile).size());
+ }
+}
diff --git a/src/test/java/millions/model/ExchangeListenerTest.java b/src/test/java/millions/model/ExchangeListenerTest.java
new file mode 100644
index 0000000..935ab43
--- /dev/null
+++ b/src/test/java/millions/model/ExchangeListenerTest.java
@@ -0,0 +1,91 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/***
+ * Small test class that implements the ExchangeListener. Adds to a list, so that we can easily check lengths and values
+ */
+class TestExchangeListener implements ExchangeListener {
+ List weekEvents = new ArrayList<>();
+ List transactionEvents = new ArrayList<>();
+
+ @Override
+ public void onWeekAdvanced(int newWeek) {
+ weekEvents.add(newWeek);
+ }
+
+ @Override
+ public void onTransactionCompleted(Transaction transaction) {
+ transactionEvents.add(transaction);
+ }
+}
+
+class ExchangeListenerTest {
+
+ private Exchange exchange;
+ private Player player;
+ private TestExchangeListener listener;
+
+ @BeforeEach
+ void setUp() {
+ Stock s1 = new Stock("AAPL", "Apple Inc.", BigDecimal.valueOf(100));
+ Stock s2 = new Stock("GOOG", "Alphabet Inc.", BigDecimal.valueOf(200));
+ Stock s3 = new Stock("NVDA", "NVidia Inc.", BigDecimal.valueOf(200));
+ exchange = new Exchange("NASDAQ", List.of(s1, s2, s3));
+ player = new Player("TestPlayer", BigDecimal.valueOf(10000));
+
+ listener = new TestExchangeListener();
+ exchange.addListener(listener);
+ }
+
+ @Test
+ void advanceNotifiesListener() {
+ exchange.advance();
+ assertEquals(1, listener.weekEvents.size());
+ assertEquals(2, listener.weekEvents.getFirst());
+
+ exchange.advance();
+ assertEquals(2, listener.weekEvents.size());
+ assertEquals(3, listener.weekEvents.get(1));
+ }
+
+ @Test
+ void buyNotifiesListener() {
+ exchange.buy("AAPL", player, 1);
+ assertEquals(1, listener.transactionEvents.size());
+ assertTrue(listener.transactionEvents.getFirst() instanceof Purchase);
+ exchange.buy("NVDA", player, 1);
+ assertTrue(
+ listener.transactionEvents.getLast().getShare().getStock().getSymbol().equals("NVDA"));
+ }
+
+ @Test
+ void sellNotifiesListener() {
+ exchange.buy("AAPL", player, 1);
+ listener.transactionEvents.clear();
+
+ Share share = player.getPortfolio().getShares().getFirst();
+ exchange.sell(share, player);
+
+ assertEquals(1, listener.transactionEvents.size());
+ assertTrue(listener.transactionEvents.getFirst() instanceof Sale);
+ }
+
+ @Test
+ void removeListenerStopsNotifications() {
+ exchange.advance();
+ assertEquals(1, listener.weekEvents.size());
+
+ exchange.removeListener(listener);
+ exchange.advance();
+ assertEquals(1, listener.weekEvents.size());
+ }
+}
diff --git a/src/test/java/millions/model/ExchangeTest.java b/src/test/java/millions/model/ExchangeTest.java
new file mode 100644
index 0000000..2e689a6
--- /dev/null
+++ b/src/test/java/millions/model/ExchangeTest.java
@@ -0,0 +1,135 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.stream.IntStream;
+
+import org.junit.jupiter.api.Test;
+
+class ExchangeTest {
+ @Test
+ public void testGetters() {
+ Stock s1 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300));
+ Stock s2 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00));
+ Stock s3 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02));
+
+ Exchange exchange = new Exchange("exchange", List.of(s1, s2));
+
+ assertFalse(exchange.hasStock("MSFT"));
+ assertTrue(exchange.getStock("DOGL").equals(s2));
+ assertNull(exchange.getStock("XYZ"));
+ assertTrue(exchange.findStocks("Amozon").isEmpty());
+
+ assertTrue(exchange.hasStock("DOGL"));
+ assertFalse(exchange.findStocks("Pear").isEmpty());
+ assertFalse(exchange.findStocks("PE").isEmpty());
+
+ assertTrue(exchange.findStocks("Inc").size() == 2);
+ }
+
+ @Test
+ public void happyPath() {
+ Stock s1 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300));
+ Stock s2 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00));
+ Stock s3 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02));
+
+ Exchange exchange = new Exchange("exchange", List.of(s1, s2, s3));
+ Player player = new Player("name", BigDecimal.valueOf(1000));
+
+ BigDecimal previousMoney = player.getMoney();
+ exchange.buy("PEAR", player, 2);
+ assertTrue(previousMoney.compareTo(player.getMoney()) > 0);
+
+ BigDecimal previousPrice = s1.getSalesPrice();
+ exchange.advance();
+ assertFalse(previousPrice.equals(s1.getSalesPrice()));
+
+ Share share = player.getPortfolio().getShares().getFirst();
+
+ previousMoney = player.getMoney();
+ exchange.sell(share, player);
+ assertTrue(previousMoney.compareTo(player.getMoney()) < 0);
+ assertTrue(player.getPortfolio().getShares().isEmpty());
+ }
+
+ @Test
+ public void testNullsAndInvalid() {
+ Stock s1 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02));
+
+ Exchange exchange = new Exchange("exchange", List.of(s1));
+ Player player = new Player("name", BigDecimal.valueOf(1000));
+
+ Share unownedShare = new Share(s1, BigDecimal.valueOf(1), s1.getSalesPrice());
+ assertThrows(IllegalStateException.class, () -> exchange.sell(unownedShare, player));
+
+ Player noMoney = new Player("nomoney", BigDecimal.valueOf(0));
+ assertThrows(IllegalStateException.class, () -> exchange.buy("MSFT", noMoney, 1));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new Exchange("", List.of(new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300)))));
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> new Exchange(null, List.of(new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300)))));
+
+ assertThrows(IllegalArgumentException.class, () -> exchange.buy("DOGL", player, 2));
+
+ assertThrows(IllegalArgumentException.class, () -> exchange.buy("DOGL", player, -2));
+
+ assertThrows(
+ IllegalArgumentException.class, () -> exchange.buy("DOGL", player, BigDecimal.valueOf(-2)));
+ }
+
+ @Test
+ public void testGetGainers() {
+ Stock s1 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02));
+ Stock s2 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300));
+ Stock s3 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00));
+
+ Exchange exchange = new Exchange("exchange", List.of(s1, s2, s3));
+ exchange.advance();
+ List gainers = exchange.getGainers(3);
+
+ boolean isSorted =
+ IntStream.range(0, gainers.size() - 1)
+ .allMatch(
+ i ->
+ gainers
+ .get(i)
+ .getLatestPriceChange()
+ .compareTo(gainers.get(i + 1).getLatestPriceChange())
+ >= 0);
+
+ assertTrue(isSorted);
+ assertEquals(3, gainers.size());
+ }
+
+ @Test
+ public void testGetLosers() {
+ Stock s1 = new Stock("MSFT", "EpsteinSoft Inc.", BigDecimal.valueOf(0.02));
+ Stock s2 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300));
+ Stock s3 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00));
+
+ Exchange exchange = new Exchange("exchange", List.of(s1, s2, s3));
+
+ List losers = exchange.getLosers(3);
+ for (Stock s : losers) {
+ System.out.println(s.getLatestPriceChange());
+ }
+ boolean isSorted =
+ IntStream.range(0, losers.size() - 1)
+ .allMatch(
+ i ->
+ losers
+ .get(i)
+ .getLatestPriceChange()
+ .compareTo(losers.get(i + 1).getLatestPriceChange())
+ <= 0);
+
+ assertTrue(isSorted);
+ assertEquals(3, losers.size());
+ }
+}
diff --git a/src/test/java/millions/model/PlayerListenerTest.java b/src/test/java/millions/model/PlayerListenerTest.java
new file mode 100644
index 0000000..71c94d6
--- /dev/null
+++ b/src/test/java/millions/model/PlayerListenerTest.java
@@ -0,0 +1,96 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/***
+ * Small test class that implements the PlayerListener. Adds to a list, so that we can easily check lengths and values
+ */
+
+class TestPlayerListener implements PlayerListener {
+ List moneyEvents = new ArrayList<>();
+ int portfolioChangedCount = 0;
+ List statusEvents = new ArrayList<>();
+
+ @Override
+ public void onMoneyChanged(BigDecimal newBalance) {
+ moneyEvents.add(newBalance);
+ }
+
+ @Override
+ public void onPortfolioChanged() {
+ portfolioChangedCount++;
+ }
+
+ @Override
+ public void onStatusChanged(String newStatus) {
+ statusEvents.add(newStatus);
+ }
+}
+
+class PlayerListenerTest {
+
+ private Player player;
+ private TestPlayerListener listener;
+
+ @BeforeEach
+ void setUp() {
+ player = new Player("TestPlayer", BigDecimal.valueOf(10000));
+ listener = new TestPlayerListener();
+ player.addListener(listener);
+ }
+
+ @Test
+ void addMoneyNotifiesListener() {
+ player.addMoney(BigDecimal.valueOf(500));
+ assertEquals(1, listener.moneyEvents.size());
+ assertEquals(BigDecimal.valueOf(10500), listener.moneyEvents.getFirst());
+ }
+
+ @Test
+ void withdrawMoneyNotifiesListener() {
+ player.withdrawMoney(BigDecimal.valueOf(300));
+ assertEquals(1, listener.moneyEvents.size());
+ assertEquals(BigDecimal.valueOf(9700), listener.moneyEvents.getFirst());
+ }
+
+ @Test
+ void addShareNotifiesPortfolioAndStatus() {
+ Stock stock = new Stock("AAPL", "Apple Inc.", BigDecimal.valueOf(100));
+ Share share = new Share(stock, BigDecimal.valueOf(1), BigDecimal.valueOf(100));
+
+ player.addShareToPortfolio(share);
+ assertEquals(1, listener.portfolioChangedCount);
+ assertEquals(1, listener.statusEvents.size());
+ }
+
+ @Test
+ void removeShareNotifiesPortfolioAndStatus() {
+ Stock stock = new Stock("AAPL", "Apple Inc.", BigDecimal.valueOf(100));
+ Share share = new Share(stock, BigDecimal.valueOf(1), BigDecimal.valueOf(100));
+
+ player.addShareToPortfolio(share);
+ listener.portfolioChangedCount = 0;
+ listener.statusEvents.clear();
+
+ player.removeShareFromPortfolio(share);
+ assertEquals(1, listener.portfolioChangedCount);
+ assertEquals(1, listener.statusEvents.size());
+ }
+
+ @Test
+ void removeListenerStopsNotifications() {
+ player.addMoney(BigDecimal.valueOf(100));
+ assertEquals(1, listener.moneyEvents.size());
+
+ player.removeListener(listener);
+ player.addMoney(BigDecimal.valueOf(100));
+ assertEquals(1, listener.moneyEvents.size());
+ }
+}
diff --git a/src/test/java/millions/model/PlayerTest.java b/src/test/java/millions/model/PlayerTest.java
new file mode 100644
index 0000000..2cefd2d
--- /dev/null
+++ b/src/test/java/millions/model/PlayerTest.java
@@ -0,0 +1,57 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+
+import org.junit.jupiter.api.Test;
+
+class PlayerTest {
+
+ @Test
+ public void happyPath() {
+ Player player = new Player("name", BigDecimal.valueOf(1000));
+ assertEquals(BigDecimal.valueOf(1000), player.getMoney());
+
+ player.addMoney(BigDecimal.valueOf(500));
+ assertEquals(BigDecimal.valueOf(1500), player.getMoney());
+
+ player.withdrawMoney(BigDecimal.valueOf(200));
+ assertEquals(BigDecimal.valueOf(1300), player.getMoney());
+ }
+
+ @Test
+ public void testGetters() {
+ Player player = new Player("name", BigDecimal.valueOf(1000));
+ assertEquals("name", player.getName());
+ assertEquals(BigDecimal.valueOf(1000), player.getMoney());
+ assertTrue(player.getPortfolio().getShares().isEmpty());
+ assertTrue(player.getTransactionArchive().isEmpty());
+ }
+
+ @Test
+ public void testNullsAndInvalid() {
+ assertThrows(IllegalArgumentException.class, () -> new Player(null, BigDecimal.valueOf(1000)));
+ assertThrows(IllegalArgumentException.class, () -> new Player("", BigDecimal.valueOf(1000)));
+ assertThrows(IllegalArgumentException.class, () -> new Player("name", null));
+ assertThrows(IllegalArgumentException.class, () -> new Player("name", BigDecimal.valueOf(-1)));
+ }
+
+ // @Test
+ // public void testStatus() {
+ // Player player = new Player("name", BigDecimal.valueOf(1000));
+ // assertEquals("Novice", player.getStatus());
+ //
+ // player.addMoney(BigDecimal.valueOf(200));
+ // assertEquals("Novice", player.getStatus());
+ //
+ // player.weeksTraded = 10;
+ // assertEquals("Investor", player.getStatus());
+ //
+ // player.addMoney(BigDecimal.valueOf(200));
+ // assertEquals("Investor", player.getStatus());
+ //
+ // player.weeksTraded = 20;
+ // assertEquals("Speculator", player.getStatus());
+ // }
+}
diff --git a/src/test/java/millions/model/PortfolioTest.java b/src/test/java/millions/model/PortfolioTest.java
new file mode 100644
index 0000000..a208066
--- /dev/null
+++ b/src/test/java/millions/model/PortfolioTest.java
@@ -0,0 +1,76 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+
+import org.junit.jupiter.api.Test;
+
+class PortfolioTest {
+
+ @Test
+ public void happyPath() {
+ Portfolio portfolio = new Portfolio();
+ Stock stock = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300));
+ Share share = new Share(stock, 10, BigDecimal.valueOf(300));
+
+ assertTrue(portfolio.addShare(share));
+ assertTrue(portfolio.contains(share));
+ assertEquals(1, portfolio.getShares().size());
+ assertEquals(share, portfolio.getShares().get(0));
+
+ assertTrue(portfolio.removeShare(share));
+ assertFalse(portfolio.contains(share));
+ assertTrue(portfolio.getShares().isEmpty());
+ }
+
+ @Test
+ public void testGettersAndSetters() {
+ Portfolio portfolio = new Portfolio();
+ Stock stock1 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300));
+ Stock stock2 = new Stock("DOGL", "DOOGLE Inc.", BigDecimal.valueOf(200.00));
+ Share share1 = new Share(stock1, 10, BigDecimal.valueOf(300));
+ Share share2 = new Share(stock2, 5, BigDecimal.valueOf(200));
+
+ portfolio.addShare(share1);
+ portfolio.addShare(share2);
+
+ assertEquals(2, portfolio.getShares().size());
+ assertTrue(portfolio.getShares().contains(share1));
+ assertTrue(portfolio.getShares().contains(share2));
+
+ assertEquals(1, portfolio.getShares("PEAR").size());
+ assertTrue(portfolio.getShares("PEAR").contains(share1));
+ assertFalse(portfolio.getShares("PEAR").contains(share2));
+
+ assertEquals(1, portfolio.getShares("DOGL").size());
+ assertFalse(portfolio.getShares("DOGL").contains(share1));
+ assertTrue(portfolio.getShares("DOGL").contains(share2));
+
+ assertTrue(portfolio.getShares("XYZ").isEmpty());
+
+ portfolio.removeShare(share1);
+ assertEquals(1, portfolio.getShares().size());
+ }
+
+ @Test
+ public void testGetNetWorth() {
+ Portfolio portfolio = new Portfolio();
+ Stock stock = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(100));
+ Share share = new Share(stock, 1, BigDecimal.valueOf(50));
+
+ portfolio.addShare(share);
+
+ assertEquals(new BigDecimal("84.3"), portfolio.getNetWorth());
+ }
+
+ @Test
+ public void testNullsAndInvalid() {
+ Stock stock1 = new Stock("PEAR", "Pear Inc.", BigDecimal.valueOf(300));
+ Share share1 = new Share(stock1, 10, BigDecimal.valueOf(300));
+ Portfolio portfolio = new Portfolio();
+ assertFalse(portfolio.removeShare(null));
+ assertFalse(portfolio.contains(null));
+ assertFalse(portfolio.removeShare(share1));
+ }
+}
diff --git a/src/test/java/millions/model/PurchaseTest.java b/src/test/java/millions/model/PurchaseTest.java
new file mode 100644
index 0000000..3c76e85
--- /dev/null
+++ b/src/test/java/millions/model/PurchaseTest.java
@@ -0,0 +1,41 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+
+import org.junit.jupiter.api.Test;
+
+class PurchaseTest {
+ @Test
+ public void testHappyPath() {
+ Stock stock = new Stock("TestStock", "TST", BigDecimal.valueOf(10));
+ Share share = new Share(stock, 2, BigDecimal.valueOf(10));
+ Player player = new Player("TestPlayer", BigDecimal.valueOf(100));
+ Purchase purchase = new Purchase(share, 1);
+ purchase.commit(player);
+ assertTrue(purchase.isCommitted());
+
+ // 1 less because of tax
+ assertEquals(79, player.getMoney().intValue());
+ assertTrue(player.getPortfolio().getShares().contains(share));
+ }
+
+ @Test
+ public void testNullsAndInvalid() {
+
+ Stock stock = new Stock("TestStock", "TST", BigDecimal.valueOf(10));
+ Share share = new Share(stock, 2, BigDecimal.valueOf(10));
+ Player player = new Player("TestPlayer", BigDecimal.valueOf(100));
+ Purchase purchase = new Purchase(share, 1);
+
+ // Double commit
+ purchase.commit(player);
+ assertThrows(IllegalStateException.class, () -> purchase.commit(player));
+
+ // Insufficient funds
+ Player poorPlayer = new Player("PoorPlayer", BigDecimal.valueOf(10));
+ Purchase expensivePurchase = new Purchase(new Share(stock, 20, BigDecimal.valueOf(10)), 1);
+ assertThrows(IllegalStateException.class, () -> expensivePurchase.commit(poorPlayer));
+ }
+}
diff --git a/src/test/java/millions/model/SaleTest.java b/src/test/java/millions/model/SaleTest.java
new file mode 100644
index 0000000..4a8a4d1
--- /dev/null
+++ b/src/test/java/millions/model/SaleTest.java
@@ -0,0 +1,33 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+
+import org.junit.jupiter.api.Test;
+
+class SaleTest {
+
+ @Test
+ public void testHappyPath() {
+ Stock stock = new Stock("TestStock", "TST", BigDecimal.valueOf(10));
+ Share share = new Share(stock, 2, BigDecimal.valueOf(10));
+ Player player = new Player("TestPlayer", BigDecimal.valueOf(100));
+ player.getPortfolio().addShare(share);
+ Sale sale = new Sale(share, 1);
+ sale.commit(player);
+ assertTrue(sale.isCommitted());
+
+ assertEquals(119, player.getMoney().intValue());
+ assertFalse(player.getPortfolio().getShares().contains(share));
+ }
+
+ @Test
+ public void testNullsAndInvalid() {
+ Stock stock = new Stock("TestStock", "TST", BigDecimal.valueOf(10));
+ Share share = new Share(stock, 2, BigDecimal.valueOf(10));
+ Player player = new Player("TestPlayer", BigDecimal.valueOf(100));
+ Sale sale = new Sale(share, 1);
+ assertThrows(IllegalStateException.class, () -> sale.commit(player));
+ }
+}
diff --git a/src/test/java/millions/model/ShareTest.java b/src/test/java/millions/model/ShareTest.java
new file mode 100644
index 0000000..3f82181
--- /dev/null
+++ b/src/test/java/millions/model/ShareTest.java
@@ -0,0 +1,35 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+
+import org.junit.jupiter.api.Test;
+
+class ShareTest {
+
+ @Test
+ public void testHappyPath() {
+ Stock stock = new Stock("AYO", "Ayhoo", BigDecimal.valueOf(10));
+ Share share = new Share(stock, 5, BigDecimal.valueOf(10));
+ assertEquals(BigDecimal.valueOf(5), share.getQuantity());
+ }
+
+ @Test
+ public void testGetters() {
+ Stock stock = new Stock("AYO", "Ayhoo", BigDecimal.valueOf(10));
+ Share share = new Share(stock, 5, BigDecimal.valueOf(10));
+ assertEquals(BigDecimal.valueOf(5), share.getQuantity());
+ assertEquals(stock, share.getStock());
+ assertEquals(BigDecimal.valueOf(10), share.getPurchasePrice());
+ }
+
+ @Test
+ public void testNullsAndInvalid() {
+ Stock stock = new Stock("AYO", "Ayhoo", BigDecimal.valueOf(10));
+ assertThrows(IllegalArgumentException.class, () -> new Share(null, 2, BigDecimal.valueOf(2)));
+ assertThrows(IllegalArgumentException.class, () -> new Share(stock, 2, null));
+ assertThrows(
+ IllegalArgumentException.class, () -> new Share(stock, null, BigDecimal.valueOf(2)));
+ }
+}
diff --git a/src/test/java/millions/model/StockTest.java b/src/test/java/millions/model/StockTest.java
new file mode 100644
index 0000000..c28a6bd
--- /dev/null
+++ b/src/test/java/millions/model/StockTest.java
@@ -0,0 +1,51 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class StockTest {
+ @Test
+ public void testHapyPath() {
+ Stock stock = new Stock("NVDA", "Nvadia", BigDecimal.valueOf(20));
+ Stock stock2 =
+ new Stock(
+ "NVDA", "Nvadia", new ArrayList(Arrays.asList(BigDecimal.valueOf(20))));
+ stock2.addNewSalesPrice(BigDecimal.valueOf(30));
+ assertEquals(BigDecimal.valueOf(30), stock2.getSalesPrice());
+ }
+
+ @Test
+ public void settersAndGetters() {
+ Stock stock = new Stock("NVDA", "Nvadia", BigDecimal.valueOf(20));
+ assertEquals("NVDA", stock.getSymbol());
+ assertEquals("Nvadia", stock.getCompany());
+ }
+
+ @Test
+ public void testGetPriceChange() {
+ ArrayList prices = new ArrayList<>(List.of(BigDecimal.valueOf(100), BigDecimal.valueOf(125)));
+ Stock stock = new Stock("AAPL", "Apple", prices);
+ assertEquals(BigDecimal.valueOf(25), stock.getLatestPriceChange());
+ stock.addNewSalesPrice(BigDecimal.valueOf(155));
+ assertEquals(BigDecimal.valueOf(30), stock.getLatestPriceChange());
+ }
+
+ @Test
+ public void testNullsAndInvalid() {
+
+ assertThrows(
+ IllegalArgumentException.class, () -> new Stock(null, "Nvadia", BigDecimal.valueOf(20)));
+ assertThrows(
+ IllegalArgumentException.class, () -> new Stock("NVDA", null, BigDecimal.valueOf(20)));
+ assertThrows(
+ IllegalArgumentException.class, () -> new Stock("NVDA", "", BigDecimal.valueOf(20)));
+ assertThrows(
+ IllegalArgumentException.class, () -> new Stock("", "Nvadia", BigDecimal.valueOf(20)));
+ }
+}
diff --git a/src/test/java/millions/model/TransactionArchiveTest.java b/src/test/java/millions/model/TransactionArchiveTest.java
new file mode 100644
index 0000000..426ed5b
--- /dev/null
+++ b/src/test/java/millions/model/TransactionArchiveTest.java
@@ -0,0 +1,47 @@
+package millions.model;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class TransactionArchiveTest {
+
+ TransactionArchive archive;
+ Purchase purchase;
+ Sale sale;
+
+ @BeforeEach
+ public void setUp() {
+ archive = new TransactionArchive();
+ Stock stock = new Stock("SUS", "Samsung", BigDecimal.valueOf(10));
+ Share share = new Share(stock, 2, BigDecimal.valueOf(10));
+ purchase = new Purchase(share, 1);
+ sale = new Sale(share, 2);
+ }
+
+ @Test
+ public void testHappyPath() {
+ assertTrue(archive.isEmpty());
+ archive.add(purchase);
+ assertFalse(archive.isEmpty());
+ }
+
+ @Test
+ public void testNullsAndInvalid() {
+ archive.add(purchase);
+ assertFalse(archive.add(purchase));
+ }
+
+ @Test
+ public void testGetters() {
+ archive.add(purchase);
+ archive.add(sale);
+ assertEquals(List.of(purchase), archive.getPurchases(1));
+ assertEquals(List.of(sale), archive.getSales(2));
+ assertEquals(2, archive.countDistinctWeeks());
+ }
+}
diff --git a/src/test/java/millions/calculators/PurchaseCalculatorTest.java b/src/test/java/millions/model/calculators/PurchaseCalculatorTest.java
similarity index 70%
rename from src/test/java/millions/calculators/PurchaseCalculatorTest.java
rename to src/test/java/millions/model/calculators/PurchaseCalculatorTest.java
index 2915b5e..37d8cca 100644
--- a/src/test/java/millions/calculators/PurchaseCalculatorTest.java
+++ b/src/test/java/millions/model/calculators/PurchaseCalculatorTest.java
@@ -1,4 +1,4 @@
-package millions.calculators;
+package millions.model.calculators;
import static org.junit.jupiter.api.Assertions.*;
diff --git a/src/test/java/millions/calculators/SaleCalculatorTest.java b/src/test/java/millions/model/calculators/SaleCalculatorTest.java
similarity index 68%
rename from src/test/java/millions/calculators/SaleCalculatorTest.java
rename to src/test/java/millions/model/calculators/SaleCalculatorTest.java
index 3d7cebb..ea19922 100644
--- a/src/test/java/millions/calculators/SaleCalculatorTest.java
+++ b/src/test/java/millions/model/calculators/SaleCalculatorTest.java
@@ -1,4 +1,4 @@
-package millions.calculators;
+package millions.model.calculators;
import static org.junit.jupiter.api.Assertions.*;
diff --git a/src/test/java/millions/calculators/TransactionCalculatorTest.java b/src/test/java/millions/model/calculators/TransactionCalculatorTest.java
similarity index 70%
rename from src/test/java/millions/calculators/TransactionCalculatorTest.java
rename to src/test/java/millions/model/calculators/TransactionCalculatorTest.java
index ad398f8..b65dc0e 100644
--- a/src/test/java/millions/calculators/TransactionCalculatorTest.java
+++ b/src/test/java/millions/model/calculators/TransactionCalculatorTest.java
@@ -1,4 +1,4 @@
-package millions.calculators;
+package millions.model.calculators;
import static org.junit.jupiter.api.Assertions.*;