From 8308cc5f0e55ee3189f909045e5d49acad298ff9 Mon Sep 17 00:00:00 2001 From: Kevin Dennis Mazali Date: Sat, 4 Apr 2026 17:32:35 +0200 Subject: [PATCH 1/4] fix: ignore unknown JSON fields on user account record --- src/main/java/model/persistence/UserAccountRecord.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/model/persistence/UserAccountRecord.java b/src/main/java/model/persistence/UserAccountRecord.java index 7b9a659..42e9d73 100644 --- a/src/main/java/model/persistence/UserAccountRecord.java +++ b/src/main/java/model/persistence/UserAccountRecord.java @@ -1,5 +1,7 @@ package model.persistence; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + /** * Persisted account metadata for one local profile. * @@ -8,6 +10,7 @@ * @param saltBase64 random salt used for PIN hashing * @param pinHashBase64 PBKDF2 hash of the PIN */ +@JsonIgnoreProperties(ignoreUnknown = true) public record UserAccountRecord( String username, String normalizedUsername, From c60c90d40cfa7d44d013b02806470b0f81a63f70 Mon Sep 17 00:00:00 2001 From: Kevin Dennis Mazali Date: Sat, 4 Apr 2026 17:32:39 +0200 Subject: [PATCH 2/4] feat: add mock stock financial info provider --- .../java/model/stockinfo/CompanyHealth.java | 28 +++++++ .../model/stockinfo/StockFinancialInfo.java | 22 +++++ .../stockinfo/StockFinancialInfoProvider.java | 82 +++++++++++++++++++ .../StockFinancialInfoProviderTest.java | 78 ++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 src/main/java/model/stockinfo/CompanyHealth.java create mode 100644 src/main/java/model/stockinfo/StockFinancialInfo.java create mode 100644 src/main/java/model/stockinfo/StockFinancialInfoProvider.java create mode 100644 src/test/java/model/stockinfo/StockFinancialInfoProviderTest.java diff --git a/src/main/java/model/stockinfo/CompanyHealth.java b/src/main/java/model/stockinfo/CompanyHealth.java new file mode 100644 index 0000000..b8020d7 --- /dev/null +++ b/src/main/java/model/stockinfo/CompanyHealth.java @@ -0,0 +1,28 @@ +package model.stockinfo; + +/** + * Qualitative label for mock company financial health shown in stock views. + */ +public enum CompanyHealth { + + /** Healthy mock fundamentals (sufficient profit margin). */ + STRONG("Strong"), + + /** Stressed mock fundamentals (low or negative margin). */ + WEAK("Weak"); + + private final String displayLabel; + + CompanyHealth(String displayLabel) { + this.displayLabel = displayLabel; + } + + /** + * Returns the short label shown in tables and detail panels. + * + * @return user-facing health text + */ + public String displayLabel() { + return displayLabel; + } +} diff --git a/src/main/java/model/stockinfo/StockFinancialInfo.java b/src/main/java/model/stockinfo/StockFinancialInfo.java new file mode 100644 index 0000000..7e17d9a --- /dev/null +++ b/src/main/java/model/stockinfo/StockFinancialInfo.java @@ -0,0 +1,22 @@ +package model.stockinfo; + +import java.math.BigDecimal; + +/** + * Mock revenue, profit, and health snapshot for a listed stock. Values are not persisted and are + * derived deterministically from the symbol. + * + * @param revenue mock total revenue in currency units (same scale as {@link #profit}) + * @param profit mock net profit in currency units + * @param health qualitative indicator derived from the mock margin + */ +public record StockFinancialInfo(BigDecimal revenue, BigDecimal profit, CompanyHealth health) { + + /** + * Creates a financial snapshot with defensive copies of decimal fields. + */ + public StockFinancialInfo { + revenue = revenue.stripTrailingZeros(); + profit = profit.stripTrailingZeros(); + } +} diff --git a/src/main/java/model/stockinfo/StockFinancialInfoProvider.java b/src/main/java/model/stockinfo/StockFinancialInfoProvider.java new file mode 100644 index 0000000..041b3fc --- /dev/null +++ b/src/main/java/model/stockinfo/StockFinancialInfoProvider.java @@ -0,0 +1,82 @@ +package model.stockinfo; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Objects; +import java.util.Random; +import model.Stock; + +/** + * Supplies deterministic mock fundamentals per stock symbol so UI can show simplified financials + * without extending {@link Stock} or persistence. + */ +public class StockFinancialInfoProvider { + + private static final BigDecimal MILLION = BigDecimal.valueOf(1_000_000L); + private static final BigDecimal MIN_REVENUE_M = BigDecimal.valueOf(10); + private static final BigDecimal REVENUE_SPAN_M = BigDecimal.valueOf(90); + /** Minimum positive profit-to-revenue ratio to classify as {@link CompanyHealth#STRONG}. */ + private static final BigDecimal STRONG_MARGIN = new BigDecimal("0.10"); + + /** + * Builds mock fundamentals for the given ticker symbol. + * + * @param symbol non-null stock symbol + * @return immutable mock financial snapshot + */ + public StockFinancialInfo forSymbol(String symbol) { + Objects.requireNonNull(symbol, "symbol"); + Random rnd = new Random(symbol.hashCode()); + BigDecimal revenueMillions = MIN_REVENUE_M.add(randomFraction(rnd).multiply(REVENUE_SPAN_M)) + .setScale(1, RoundingMode.HALF_UP); + BigDecimal revenue = revenueMillions.multiply(MILLION); + double margin = rnd.nextDouble() * 0.30 - 0.05; + BigDecimal profit = + revenue.multiply(BigDecimal.valueOf(margin)).setScale(2, RoundingMode.HALF_UP); + CompanyHealth health = classifyHealth(revenue, profit); + return new StockFinancialInfo(revenue, profit, health); + } + + /** + * Same as {@link #forSymbol(String)} using the stock's symbol. + * + * @param stock non-null stock + * @return mock financial snapshot + */ + public StockFinancialInfo forStock(Stock stock) { + Objects.requireNonNull(stock, "stock"); + return forSymbol(stock.getSymbol()); + } + + /** + * Formats a mock monetary amount for display (millions with one decimal when large). + * + * @param amount full currency amount + * @return compact USD-style string + */ + public String formatMoney(BigDecimal amount) { + Objects.requireNonNull(amount, "amount"); + BigDecimal abs = amount.abs(); + if (abs.compareTo(MILLION) >= 0) { + BigDecimal millions = amount.divide(MILLION, 1, RoundingMode.HALF_UP); + String sign = amount.signum() < 0 ? "-" : ""; + return sign + "$" + millions.abs().toPlainString() + "M"; + } + return "$" + amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private static BigDecimal randomFraction(Random rnd) { + return BigDecimal.valueOf(rnd.nextDouble()); + } + + private static CompanyHealth classifyHealth(BigDecimal revenue, BigDecimal profit) { + if (revenue.signum() <= 0) { + return CompanyHealth.WEAK; + } + if (profit.signum() <= 0) { + return CompanyHealth.WEAK; + } + BigDecimal margin = profit.divide(revenue, 4, RoundingMode.HALF_UP); + return margin.compareTo(STRONG_MARGIN) >= 0 ? CompanyHealth.STRONG : CompanyHealth.WEAK; + } +} diff --git a/src/test/java/model/stockinfo/StockFinancialInfoProviderTest.java b/src/test/java/model/stockinfo/StockFinancialInfoProviderTest.java new file mode 100644 index 0000000..3dac4b2 --- /dev/null +++ b/src/test/java/model/stockinfo/StockFinancialInfoProviderTest.java @@ -0,0 +1,78 @@ +package model.stockinfo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import model.Stock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StockFinancialInfoProviderTest { + + private StockFinancialInfoProvider provider; + + @BeforeEach + void setUp() { + provider = new StockFinancialInfoProvider(); + } + + @Test + void forSymbol_isDeterministic() { + StockFinancialInfo a = provider.forSymbol("AAPL"); + StockFinancialInfo b = provider.forSymbol("AAPL"); + assertEquals(a.revenue(), b.revenue()); + assertEquals(a.profit(), b.profit()); + assertEquals(a.health(), b.health()); + } + + @Test + void forSymbol_differentSymbols_canDiffer() { + StockFinancialInfo aapl = provider.forSymbol("AAPL"); + StockFinancialInfo msft = provider.forSymbol("MSFT"); + boolean someFieldDiffers = + !aapl.revenue().equals(msft.revenue()) + || !aapl.profit().equals(msft.profit()) + || !aapl.health().equals(msft.health()); + assertTrue(someFieldDiffers, "Expected mock data to vary across symbols"); + } + + @Test + void forStock_delegatesToSymbol() { + Stock stock = new Stock("TEST", "Test Co"); + assertEquals(provider.forSymbol("TEST"), provider.forStock(stock)); + } + + @Test + void health_followsMarginRule() { + StockFinancialInfo info = provider.forSymbol("ZZRULE"); + BigDecimal margin = + info.revenue().signum() > 0 + ? info.profit().divide(info.revenue(), 6, RoundingMode.HALF_UP) + : BigDecimal.ZERO; + if (info.revenue().signum() > 0 && info.profit().signum() > 0) { + if (margin.compareTo(new BigDecimal("0.10")) >= 0) { + assertEquals(CompanyHealth.STRONG, info.health()); + } else { + assertEquals(CompanyHealth.WEAK, info.health()); + } + } else { + assertEquals(CompanyHealth.WEAK, info.health()); + } + } + + @Test + void formatMoney_usesMillionsSuffix() { + BigDecimal amount = new BigDecimal("47500000.00"); + String formatted = provider.formatMoney(amount); + assertEquals("$47.5M", formatted); + } + + @Test + void formatMoney_negativeProfit() { + String formatted = provider.formatMoney(new BigDecimal("-2500000")); + assertEquals("-$2.5M", formatted); + } +} From d81feaee5328c003f4af1f1c1ad70d65dbd09860 Mon Sep 17 00:00:00 2001 From: Kevin Dennis Mazali Date: Sat, 4 Apr 2026 17:32:42 +0200 Subject: [PATCH 3/4] feat: show mock fundamentals in stock list and detail --- src/main/java/view/StockDetailView.java | 79 ++++++++++++++++++++++++- src/main/java/view/StocksListPanel.java | 20 ++++++- 2 files changed, 94 insertions(+), 5 deletions(-) diff --git a/src/main/java/view/StockDetailView.java b/src/main/java/view/StockDetailView.java index a947e50..0e0700a 100644 --- a/src/main/java/view/StockDetailView.java +++ b/src/main/java/view/StockDetailView.java @@ -16,16 +16,20 @@ import javafx.scene.text.FontWeight; import model.Stock; import model.marketevent.MarketEvent; +import model.stockinfo.StockFinancialInfo; +import model.stockinfo.StockFinancialInfoProvider; import recommendation.StockRecommendation; import recommendation.StockRecommendationService; import view.components.chart.StockChart; import view.components.recommendation.StockRecommendationLabel; /** - * Dedicated stock detail view showing summary data, trend-based recommendation, and price history. + * Dedicated stock detail view showing summary data, mock company fundamentals, trend-based + * recommendation, and price history. * *

The recommendation is computed through {@link StockRecommendationService}, keeping expert - * advice presentation separate from the stock's actual price-update logic. + * advice presentation separate from the stock's actual price-update logic. Mock revenue and profit + * come from {@link StockFinancialInfoProvider}. * * @author kaamyashinde * @version 1.0.0 @@ -34,12 +38,18 @@ public class StockDetailView extends BorderPane { private final StockRecommendationService recommendationService = new StockRecommendationService(); + private final StockFinancialInfoProvider financialInfoProvider = new StockFinancialInfoProvider(); private final Label titleLabel = new Label("Stock details"); private final Label subtitleLabel = new Label("Select a stock to inspect its latest trend."); private final Label dayLabel = new Label("Trading day: -"); private final Label latestPriceLabel = new Label("Latest price: -"); private final Label marketEventLabel = new Label("Latest market event: none"); + private final Label fundamentalsHeading = new Label("Company fundamentals"); + private final Label revenueLabel = new Label("Revenue: -"); + private final Label profitLabel = new Label("Profit: -"); + private final Label healthLabel = new Label("Health: -"); + private final VBox fundamentalsBox; private final Label marketHistoryHeading = new Label("Past events"); private final Label basisLabel = new Label("Recommendation basis: recent price trend"); private final StockRecommendationLabel recommendationLabel = @@ -72,7 +82,28 @@ public StockDetailView() { marketHistoryList.setMouseTransparent(true); marketHistoryList.setMaxHeight(140); - VBox header = new VBox(6, titleLabel, subtitleLabel, dayLabel, latestPriceLabel, marketEventLabel); + fundamentalsHeading.setFont(Font.font("System", FontWeight.BOLD, 14)); + revenueLabel.setWrapText(true); + profitLabel.setWrapText(true); + healthLabel.setWrapText(true); + fundamentalsBox = + new VBox(4, fundamentalsHeading, revenueLabel, profitLabel, healthLabel); + fundamentalsBox.setStyle( + "-fx-background-color: #f9fafc;" + + "-fx-border-color: #e2e6ee;" + + "-fx-background-radius: 10;" + + "-fx-border-radius: 10;" + + "-fx-padding: 12;"); + + VBox header = + new VBox( + 10, + titleLabel, + subtitleLabel, + dayLabel, + latestPriceLabel, + marketEventLabel, + fundamentalsBox); recommendationBox = new VBox(8, new Label("Recommendation"), recommendationLabel, basisLabel); recommendationBox.setStyle( "-fx-background-color: #f6f7fb;" @@ -133,6 +164,7 @@ public void showStock( subtitleLabel.setText("Select a stock to inspect its latest trend."); latestPriceLabel.setText("Latest price: -"); marketEventLabel.setText("Latest market event: none"); + clearFundamentalsLabels(); marketHistoryList.setItems(FXCollections.observableArrayList()); recommendationLabel.setRecommendation(StockRecommendation.HOLD); placeholderLabel.setText("Choose a stock from the list to view chart and recommendation details."); @@ -144,6 +176,7 @@ public void showStock( subtitleLabel.setText("Single-stock detail view with trend recommendation."); latestPriceLabel.setText("Latest price: " + formatLatestPrice(stock)); marketEventLabel.setText(buildMarketEventText(stock, marketEvent)); + applyFundamentalsLabels(stock); marketHistoryList.setItems(FXCollections.observableArrayList(buildMarketHistoryItems(marketHistory))); recommendationLabel.setRecommendation(recommendationService.recommend(stock)); @@ -234,12 +267,52 @@ public List getDisplayedMarketHistory() { return List.copyOf(marketHistoryList.getItems()); } + /** + * Returns the revenue line currently shown under company fundamentals. + * + * @return revenue label text + */ + public String getRevenueLabelText() { + return revenueLabel.getText(); + } + + /** + * Returns the profit line currently shown under company fundamentals. + * + * @return profit label text + */ + public String getProfitLabelText() { + return profitLabel.getText(); + } + + /** + * Returns the health line currently shown under company fundamentals. + * + * @return health label text + */ + public String getHealthLabelText() { + return healthLabel.getText(); + } + /** * Formats the latest price if one exists. * * @param stock stock whose latest price should be shown * @return formatted latest price or placeholder text */ + private void clearFundamentalsLabels() { + revenueLabel.setText("Revenue: -"); + profitLabel.setText("Profit: -"); + healthLabel.setText("Health: -"); + } + + private void applyFundamentalsLabels(Stock stock) { + StockFinancialInfo fin = financialInfoProvider.forStock(stock); + revenueLabel.setText("Revenue: " + financialInfoProvider.formatMoney(fin.revenue())); + profitLabel.setText("Profit: " + financialInfoProvider.formatMoney(fin.profit())); + healthLabel.setText("Health: " + fin.health().displayLabel()); + } + private static String formatLatestPrice(Stock stock) { if (stock.getHistoricalPrices().isEmpty()) { return "-"; diff --git a/src/main/java/view/StocksListPanel.java b/src/main/java/view/StocksListPanel.java index 61a739b..d8e5acf 100644 --- a/src/main/java/view/StocksListPanel.java +++ b/src/main/java/view/StocksListPanel.java @@ -22,10 +22,12 @@ import javafx.scene.text.Text; import model.Exchange; import model.Stock; +import model.stockinfo.StockFinancialInfoProvider; import model.marketevent.MarketEvent; /** - * JavaFX panel listing all stocks on an {@link Exchange}: symbol, company, and latest price. + * JavaFX panel listing all stocks on an {@link Exchange}: symbol, company, latest price, mock + * revenue, and health indicator. * Prices reflect the same {@link Stock} instances as the rest of the demo; use {@link #refresh()} * after trading days advance so the table re-renders updated values. * @@ -36,6 +38,7 @@ public class StocksListPanel extends BorderPane { private final Exchange exchange; + private final StockFinancialInfoProvider financialInfoProvider = new StockFinancialInfoProvider(); private final Label metaLabel = new Label(); private final TableView table = new TableView<>(); private final ObservableList rows = FXCollections.observableArrayList(); @@ -96,7 +99,20 @@ private void buildTable() { colPrice.setCellValueFactory( c -> new SimpleStringProperty(c.getValue().getSalesPrice().toPlainString())); - table.getColumns().setAll(List.of(colSym, colCompany, colPrice)); + TableColumn colRevenue = new TableColumn<>("Revenue (mock)"); + colRevenue.setCellValueFactory( + c -> + new SimpleStringProperty( + financialInfoProvider.formatMoney( + financialInfoProvider.forStock(c.getValue()).revenue()))); + + TableColumn colHealth = new TableColumn<>("Health"); + colHealth.setCellValueFactory( + c -> + new SimpleStringProperty( + financialInfoProvider.forStock(c.getValue()).health().displayLabel())); + + table.getColumns().setAll(List.of(colSym, colCompany, colPrice, colRevenue, colHealth)); } /** From 1b4429dd400e61f9ee1278a328a551f1f00a8c9b Mon Sep 17 00:00:00 2001 From: Kevin Dennis Mazali Date: Sat, 4 Apr 2026 17:32:44 +0200 Subject: [PATCH 4/4] test: cover fundamentals labels in stock detail view --- src/test/java/view/StockDetailViewTest.java | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/test/java/view/StockDetailViewTest.java b/src/test/java/view/StockDetailViewTest.java index 90f573a..f7a899d 100644 --- a/src/test/java/view/StockDetailViewTest.java +++ b/src/test/java/view/StockDetailViewTest.java @@ -12,6 +12,8 @@ import javafx.application.Platform; import model.Stock; import model.marketevent.MarketEvent; +import model.stockinfo.StockFinancialInfo; +import model.stockinfo.StockFinancialInfoProvider; import model.marketevent.SymbolMarketEventTarget; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -33,6 +35,41 @@ static void initJavaFx() throws InterruptedException { } } + @Test + void initialView_showsPlaceholderFundamentals() throws Exception { + StockDetailView view = + runOnFxThread( + () -> { + StockDetailView detailView = new StockDetailView(); + detailView.showStock(null, 0); + return detailView; + }); + + assertEquals("Revenue: -", view.getRevenueLabelText()); + assertEquals("Profit: -", view.getProfitLabelText()); + assertEquals("Health: -", view.getHealthLabelText()); + } + + @Test + void showStock_displaysMockFundamentalsMatchingProvider() throws Exception { + Stock stock = new Stock("AAPL", "Apple Inc."); + stock.addNewSalesPrice(new BigDecimal("100.00")); + StockFinancialInfoProvider provider = new StockFinancialInfoProvider(); + StockFinancialInfo expected = provider.forStock(stock); + + StockDetailView view = + runOnFxThread( + () -> { + StockDetailView detailView = new StockDetailView(); + detailView.showStock(stock, 1); + return detailView; + }); + + assertEquals("Revenue: " + provider.formatMoney(expected.revenue()), view.getRevenueLabelText()); + assertEquals("Profit: " + provider.formatMoney(expected.profit()), view.getProfitLabelText()); + assertEquals("Health: " + expected.health().displayLabel(), view.getHealthLabelText()); + } + @Test void refreshRecomputesRecommendationFromUpdatedHistory() throws Exception { Stock stock = new Stock("AAPL", "Apple Inc.");