From 0bc0e7a9e6dd30ba8d378bb1d14b114621428245 Mon Sep 17 00:00:00 2001 From: saiseahawks Date: Thu, 28 May 2026 18:33:26 -0700 Subject: [PATCH] Enhance Bank functionality with new game mechanics and API updates - Added new fields for game state: experience points (xp), level, and quest progress. - Introduced methods for managing game state, including experience gain and quest initialization. - Updated balance management methods to support new game features. - Modified database mappings to use JSONB for certain fields, improving performance. - Refactored BankApiController to streamline dependencies and enhance API structure. - Adjusted security configuration to permit access to bank-related endpoints. --- .../java/com/open/spring/mvc/bank/Bank.java | 320 +++---- .../spring/mvc/bank/BankApiController.java | 822 +++++------------- .../spring/mvc/bank/BankJpaRepository.java | 20 +- .../com/open/spring/mvc/bank/BankService.java | 74 +- .../open/spring/mvc/quant/BacktestResult.java | 38 + .../spring/mvc/quant/BacktestService.java | 259 ++++++ .../java/com/open/spring/mvc/quant/Bar.java | 55 ++ .../com/open/spring/mvc/quant/Headline.java | 17 + .../spring/mvc/quant/IndicatorService.java | 285 ++++++ .../com/open/spring/mvc/quant/MLService.java | 330 +++++++ .../spring/mvc/quant/MLTrainResponse.java | 40 + .../spring/mvc/quant/MarketDataService.java | 586 +++++++++++++ .../open/spring/mvc/quant/NewsService.java | 123 +++ .../open/spring/mvc/quant/PaperPortfolio.java | 39 + .../spring/mvc/quant/PaperTradeService.java | 223 +++++ .../spring/mvc/quant/SentimentSnapshot.java | 32 + .../spring/security/MvcSecurityConfig.java | 1 + src/main/resources/market-data/AAPL.csv | 139 +++ src/main/resources/market-data/AMZN.csv | 96 ++ src/main/resources/market-data/TSLA.csv | 97 +++ 20 files changed, 2794 insertions(+), 802 deletions(-) create mode 100644 src/main/java/com/open/spring/mvc/quant/BacktestResult.java create mode 100644 src/main/java/com/open/spring/mvc/quant/BacktestService.java create mode 100644 src/main/java/com/open/spring/mvc/quant/Bar.java create mode 100644 src/main/java/com/open/spring/mvc/quant/Headline.java create mode 100644 src/main/java/com/open/spring/mvc/quant/IndicatorService.java create mode 100644 src/main/java/com/open/spring/mvc/quant/MLService.java create mode 100644 src/main/java/com/open/spring/mvc/quant/MLTrainResponse.java create mode 100644 src/main/java/com/open/spring/mvc/quant/MarketDataService.java create mode 100644 src/main/java/com/open/spring/mvc/quant/NewsService.java create mode 100644 src/main/java/com/open/spring/mvc/quant/PaperPortfolio.java create mode 100644 src/main/java/com/open/spring/mvc/quant/PaperTradeService.java create mode 100644 src/main/java/com/open/spring/mvc/quant/SentimentSnapshot.java create mode 100644 src/main/resources/market-data/AAPL.csv create mode 100644 src/main/resources/market-data/AMZN.csv create mode 100644 src/main/resources/market-data/TSLA.csv diff --git a/src/main/java/com/open/spring/mvc/bank/Bank.java b/src/main/java/com/open/spring/mvc/bank/Bank.java index ef62417a1..c0d6a78cc 100644 --- a/src/main/java/com/open/spring/mvc/bank/Bank.java +++ b/src/main/java/com/open/spring/mvc/bank/Bank.java @@ -1,13 +1,15 @@ package com.open.spring.mvc.bank; +import java.text.SimpleDateFormat; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; +import java.util.Date; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Random; -import java.util.LinkedHashMap; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.annotations.OnDelete; @@ -24,12 +26,10 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; -import jakarta.persistence.PrePersist; +import jakarta.persistence.Transient; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import java.text.SimpleDateFormat; -import java.util.Date; @Data @NoArgsConstructor @@ -51,45 +51,70 @@ public class Bank { private double balance; private double loanAmount; - - // Add a field for personalized interest rate - private double dailyInterestRate = 5.0; // Default 3% - + + // Personalized daily interest rate (%) + private double dailyInterestRate = 5.0; // Default + // Risk category (0=low, 1=medium, 2=high) private int riskCategory = 1; - + // Track transaction history for ML features @JdbcTypeCode(SqlTypes.JSON) - @Column(columnDefinition = "json") + @Column(columnDefinition = "jsonb") private Map>> profitMap = new HashMap<>(); - + // Store ML feature importance for explainability @JdbcTypeCode(SqlTypes.JSON) - @Column(columnDefinition = "json") + @Column(columnDefinition = "jsonb") private Map featureImportance = new HashMap<>(); // Track NPC progress @JdbcTypeCode(SqlTypes.JSON) - @Column(columnDefinition = "json") + @Column(columnDefinition = "jsonb") private LinkedHashMap npcProgress = new LinkedHashMap<>(); + // ========================== + // GAME STATE (NEW) + // ========================== + private int xp = 0; + private int level = 1; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private LinkedHashMap questProgress = new LinkedHashMap<>(); + + // This field was previously mapped to a JSONB column that doesn't exist in the + // current SQLite schema, causing startup failures. Treat it as transient for now. + @Transient + private Map lastRunStats = new HashMap<>(); + public Bank(Person person) { this.person = person; this.person.setBanks(this); this.uid = person.getUid(); - this.loanAmount = 0.0; // Default to 0 + + this.loanAmount = 0.0; this.balance = 100000.0; + this.profitMap = new HashMap<>(); this.featureImportance = new HashMap<>(); this.npcProgress = new LinkedHashMap<>(); + + // NEW game fields + this.xp = 0; + this.level = 1; + this.questProgress = new LinkedHashMap<>(); + this.lastRunStats = new HashMap<>(); + initializeNpcProgress(); + initializeQuestProgress(); initializeFeatureImportance(); } public String getUsername() { return person != null ? person.getName() : null; } - + private void initializeNpcProgress() { this.npcProgress.put("Stock-NPC", true); this.npcProgress.put("Casino-NPC", false); @@ -99,7 +124,16 @@ private void initializeNpcProgress() { this.npcProgress.put("Crypto-NPC", false); this.npcProgress.put("Bank-NPC", false); } - + + // NEW quests for quant game + private void initializeQuestProgress() { + this.questProgress.put("Run Backtest", false); + this.questProgress.put("Beat Buy&Hold", false); + this.questProgress.put("Sharpe >= 1.0", false); + this.questProgress.put("Train ML Model", false); + this.questProgress.put("Use News Boost", false); + } + private void initializeFeatureImportance() { this.featureImportance.put("casino_frequency", 0.42); this.featureImportance.put("profit_loss_ratio", 0.38); @@ -111,25 +145,45 @@ private void initializeFeatureImportance() { this.featureImportance.put("volatility", 0.15); this.featureImportance.put("balance_trend", 0.22); } - + + // ========================== + // GAME HELPERS + // ========================== + public void addXp(int xpGained) { + if (xpGained <= 0) return; + this.xp += xpGained; + this.level = Math.max(1, (this.xp / 200) + 1); // simple leveling rule + } + + // ========================== + // BALANCE METHODS + // ========================== + + // (NEW) Overload so old code calling setBalance(x) still compiles + public void setBalance(double updatedBalance) { + setBalance(updatedBalance, "admin_update"); + } + + // Main balance setter that records profit history public double setBalance(double updatedBalance, String source) { - // Update the balance as a String Double profit = updatedBalance - this.balance; this.balance = updatedBalance; + System.out.println("Profit: " + profit); + SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); String timestamp = dateFormat.format(new Date()); updateProfitMap(source, timestamp, profit); - return this.balance; // Return the updated balance as a String + + return this.balance; } - + public void updateProfitMap(String category, String time, double profit) { if (this.profitMap == null) { this.profitMap = new HashMap<>(); } List transaction = Arrays.asList(time, profit); - this.profitMap.computeIfAbsent(category, k -> new ArrayList<>()).add(transaction); } @@ -137,132 +191,123 @@ public List> getProfitByCategory(String category) { return this.profitMap.getOrDefault(category, new ArrayList<>()); } + // ========================== + // LOANS + // ========================== public void requestLoan(double loanAmount) { - this.loanAmount += loanAmount; // Increase the loan amount - balance += loanAmount; // Add the loan amount to the balance - + this.loanAmount += loanAmount; + balance += loanAmount; + // Re-assess risk using ML model assessRiskUsingML(); } - + public void repayLoan(double repaymentAmount) { - // Validate the repayment amount if (repaymentAmount <= 0) { throw new IllegalArgumentException("Repayment amount must be positive"); } - - // Check if the user has enough balance if (balance < repaymentAmount) { throw new IllegalArgumentException("Insufficient balance for this repayment"); } - - // Check if the repayment is more than the loan if (repaymentAmount > loanAmount) { throw new IllegalArgumentException("Repayment amount exceeds the loan balance"); } - - // Process the repayment + balance -= repaymentAmount; loanAmount -= repaymentAmount; - - // Record transaction + String timestamp = Instant.now().toString(); this.updateProfitMap("loan_repayment", timestamp, -repaymentAmount); - - // Re-assess risk after repayment + assessRiskUsingML(); } - // Updated method to calculate daily interest using personalized rate public double dailyInterestCalculation() { - return loanAmount * (dailyInterestRate / 100); // Convert percentage to decimal + return loanAmount * (dailyInterestRate / 100); } - - // Method to assess risk using machine learning + + // ========================== + // ML RISK + EXPLAINABILITY + // ========================== public void assessRiskUsingML() { - // Use ML model to calculate interest rate double baseRate = LoanRiskCalculator.calculateDailyInterestRate(this); - - // Apply ensemble method for more robust prediction double ensembleRate = LoanRiskCalculator.ensembleInterestRate(this); - - // Use 70% ensemble and 30% base rate + + // 70% ensemble, 30% base double finalRate = (ensembleRate * 0.7) + (baseRate * 0.3); - - // Update the interest rate + this.dailyInterestRate = finalRate; - - // Update risk category this.riskCategory = LoanRiskCalculator.classifyRiskCategory(this); - - // Update feature importance if new activities were added + updateFeatureImportance(); } - - // Method to update feature importance with slight random variations - // to simulate ML model re-training + private void updateFeatureImportance() { Random random = new Random(); - - // Get current activities + boolean hasCasino = false; boolean hasStocks = false; boolean hasCrypto = false; - + for (String key : profitMap.keySet()) { if (key.startsWith("casino_")) hasCasino = true; if (key.equals("stocks")) hasStocks = true; if (key.equals("cryptomining")) hasCrypto = true; } - - // Adjust weights based on activity + if (hasCasino) { - double variation = (random.nextDouble() * 0.1) - 0.05; // -5% to +5% - featureImportance.put("casino_frequency", - Math.max(0.3, Math.min(0.5, featureImportance.get("casino_frequency") + variation))); + double variation = (random.nextDouble() * 0.1) - 0.05; + featureImportance.put( + "casino_frequency", + Math.max(0.3, Math.min(0.5, featureImportance.get("casino_frequency") + variation)) + ); } - + if (hasStocks) { - double variation = (random.nextDouble() * 0.08) - 0.04; // -4% to +4% - featureImportance.put("stock_activity", - Math.min(-0.2, Math.max(-0.4, featureImportance.get("stock_activity") + variation))); + double variation = (random.nextDouble() * 0.08) - 0.04; + featureImportance.put( + "stock_activity", + Math.min(-0.2, Math.max(-0.4, featureImportance.get("stock_activity") + variation)) + ); } - + if (hasCrypto) { - double variation = (random.nextDouble() * 0.08) - 0.04; // -4% to +4% - featureImportance.put("crypto_activity", - Math.min(-0.2, Math.max(-0.4, featureImportance.get("crypto_activity") + variation))); + double variation = (random.nextDouble() * 0.08) - 0.04; + featureImportance.put( + "crypto_activity", + Math.min(-0.2, Math.max(-0.4, featureImportance.get("crypto_activity") + variation)) + ); } - - // Adjust loan importance based on loan amount + if (loanAmount > 0) { double loanToBalanceRatio = balance > 0 ? loanAmount / balance : 2.0; if (loanToBalanceRatio > 0.8) { - // High loan ratio increases importance of loan history - featureImportance.put("loan_history", - Math.min(0.3, featureImportance.get("loan_history") + 0.05)); + featureImportance.put( + "loan_history", + Math.min(0.3, featureImportance.get("loan_history") + 0.05) + ); } } } - - // Method to simulate a casino game activity and record it + + // ========================== + // GAME ACTIVITIES (SIM) + // ========================== public double playCasinoGame(String gameType, double betAmount) { - // Validate inputs if (betAmount <= 0 || balance < betAmount) { return 0.0; } - + if (!gameType.startsWith("casino_")) { gameType = "casino_" + gameType; } - - // Simple RNG for game outcomes with appropriate house edges + double winChance; double payoutMultiplier; - + switch (gameType) { case "casino_dice": - winChance = 0.48; // Slightly below 50% + winChance = 0.48; payoutMultiplier = 2.0; break; case "casino_poker": @@ -270,7 +315,7 @@ public double playCasinoGame(String gameType, double betAmount) { payoutMultiplier = 2.2; break; case "casino_mines": - winChance = 0.40; // Highest risk + winChance = 0.40; payoutMultiplier = 2.5; break; case "casino_blackjack": @@ -281,102 +326,77 @@ public double playCasinoGame(String gameType, double betAmount) { winChance = 0.48; payoutMultiplier = 2.0; } - - // Subtract bet from balance + this.balance -= betAmount; - - // Determine outcome + double profit; if (Math.random() < winChance) { - // Win profit = betAmount * payoutMultiplier; this.balance += profit; - profit -= betAmount; // Net profit + profit -= betAmount; // net profit } else { - // Loss profit = -betAmount; } - - // Record transaction + String timestamp = Instant.now().toString(); this.updateProfitMap(gameType, timestamp, profit); - - // Re-assess risk using ML model + assessRiskUsingML(); - return profit; } - - // Method to simulate stock market investment + public double investInStocks(double investmentAmount) { - // Validate inputs if (investmentAmount <= 0 || balance < investmentAmount) { return 0.0; } - - // Subtract investment from balance + this.balance -= investmentAmount; - - // Stock market has better odds but lower returns than casino - double winChance = 0.55; - double returnRange = 0.25; // +/- 25% - - // Calculate return + + double returnRange = 0.25; // +/- 25% + double returnMultiplier = 1.0 + (Math.random() * returnRange * 2) - returnRange; double returns = investmentAmount * returnMultiplier; - - // Add returns to balance + this.balance += returns; - - // Calculate profit + double profit = returns - investmentAmount; - - // Record transaction + String timestamp = Instant.now().toString(); this.updateProfitMap("stocks", timestamp, profit); - - // Re-assess risk using ML model + assessRiskUsingML(); - return profit; } - - // Method to simulate crypto mining + public double mineCrypto(double electricityCost) { - // Validate inputs if (electricityCost <= 0 || balance < electricityCost) { return 0.0; } - - // Subtract electricity cost from balance + this.balance -= electricityCost; - - // Mining has steady returns with occasional bonuses + double baseReturn = electricityCost * 1.1; // 10% base profit - double bonusChance = 0.15; // 15% chance of bonus - + double bonusChance = 0.15; + double returns = baseReturn; if (Math.random() < bonusChance) { - returns += electricityCost * Math.random(); // Bonus up to 100% of cost + returns += electricityCost * Math.random(); } - - // Add returns to balance + this.balance += returns; - - // Calculate profit + double profit = returns - electricityCost; - - // Record transaction + String timestamp = Instant.now().toString(); this.updateProfitMap("cryptomining", timestamp, profit); - - // Re-assess risk using ML model + assessRiskUsingML(); - return profit; } - - // Get risk category as string + + // ========================== + // EXPLAINABILITY HELPERS + // ========================== public String getRiskCategoryString() { switch (riskCategory) { case 0: return "Low Risk"; @@ -385,22 +405,25 @@ public String getRiskCategoryString() { default: return "Unknown Risk"; } } - - // Get feature importance explanations + public List getFeatureImportanceExplanations() { List explanations = new ArrayList<>(); - + for (Map.Entry feature : featureImportance.entrySet()) { String impact = feature.getValue() > 0 ? "increases" : "decreases"; String magnitude = Math.abs(feature.getValue()) > 0.3 ? "significantly" : "slightly"; - - explanations.add(String.format("Your %s %s %s your interest rate", - formatFeatureName(feature.getKey()), magnitude, impact)); + + explanations.add(String.format( + "Your %s %s %s your interest rate", + formatFeatureName(feature.getKey()), + magnitude, + impact + )); } - + return explanations; } - + private String formatFeatureName(String featureName) { return featureName .replace("_", " ") @@ -414,15 +437,18 @@ private String formatFeatureName(String featureName) { .replace("balance trend", "account balance history"); } + // ========================== + // INIT + // ========================== public static Bank[] init(Person[] persons) { ArrayList bankList = new ArrayList<>(); for (Person person : persons) { Bank bank = new Bank(person); - bank.assessRiskUsingML(); // Set initial rate based on ML model + bank.assessRiskUsingML(); bankList.add(bank); } return bankList.toArray(new Bank[0]); } -} \ No newline at end of file +} diff --git a/src/main/java/com/open/spring/mvc/bank/BankApiController.java b/src/main/java/com/open/spring/mvc/bank/BankApiController.java index 4a1ddb593..49048d62f 100644 --- a/src/main/java/com/open/spring/mvc/bank/BankApiController.java +++ b/src/main/java/com/open/spring/mvc/bank/BankApiController.java @@ -1,649 +1,279 @@ package com.open.spring.mvc.bank; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; +import java.time.LocalDate; import java.util.Map; -import java.util.Iterator; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; + import org.springframework.http.ResponseEntity; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.open.spring.mvc.person.Person; -import com.open.spring.mvc.person.PersonJpaRepository; +import com.open.spring.mvc.quant.BacktestService; +import com.open.spring.mvc.quant.IndicatorService; +import com.open.spring.mvc.quant.MLService; +import com.open.spring.mvc.quant.MarketDataService; +import com.open.spring.mvc.quant.NewsService; +import com.open.spring.mvc.quant.PaperTradeService; -import jakarta.servlet.http.HttpServletRequest; -import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NoArgsConstructor; +/** + * Bank API = the ONE backend surface area your frontend uses. + * Quant endpoints live inside this controller under /bank/quant/* + */ @RestController -@RequestMapping("/bank") +@RequestMapping({ "/bank", "/api/bank" }) public class BankApiController { - @Autowired - private BankService bankService; - - @Autowired - private BankJpaRepository bankJpaRepository; - - - @Autowired - private PersonJpaRepository personJpaRepository; - - /** - * Helper method to find or create a Bank for a given personId - */ - private Bank findOrCreateBankByPersonId(Long personId) { - Bank bank = bankJpaRepository.findByPersonId(personId); - if (bank == null) { - // Find the person - Person person = personJpaRepository.findById(personId).orElse(null); - if (person == null) { - throw new RuntimeException("Person not found with ID: " + personId); - } - - // Create new bank account - bank = new Bank(person); - bank.assessRiskUsingML(); - bank = bankJpaRepository.save(bank); - } - return bank; + // ===== Existing Bank deps ===== + private final BankJpaRepository bankRepo; + + // ===== Quant deps (all "quant features should be there") ===== + private final MarketDataService marketDataService; + private final IndicatorService indicatorService; + private final MLService mlService; + private final NewsService newsService; + private final BacktestService backtestService; + private final PaperTradeService paperTradeService; + + public BankApiController( + BankJpaRepository bankRepo, + MarketDataService marketDataService, + IndicatorService indicatorService, + MLService mlService, + NewsService newsService, + BacktestService backtestService, + PaperTradeService paperTradeService) { + this.bankRepo = bankRepo; + this.marketDataService = marketDataService; + this.indicatorService = indicatorService; + this.mlService = mlService; + this.newsService = newsService; + this.backtestService = backtestService; + this.paperTradeService = paperTradeService; } - // Get top 10 leaderboard - @GetMapping("/leaderboard") - public ResponseEntity> getLeaderboard() { - try { - List topBanks = bankJpaRepository.findTop10ByOrderByBalanceDesc(); - List leaderboard = new ArrayList<>(); - - for (int i = 0; i < topBanks.size(); i++) { - Bank bank = topBanks.get(i); - leaderboard.add(new LeaderboardEntry( - i + 1, - bank.getId(), - bank.getUsername() != null ? bank.getUsername() : "User " + bank.getId(), - bank.getBalance() - )); - } - - return ResponseEntity.ok(Map.of( - "success", true, - "data", leaderboard - )); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of( - "success", false, - "error", "Error fetching leaderboard: " + e.getMessage() - )); - } + // ============================================================ + // BANK CORE API + // ============================================================ + // NOTE: keep your existing bank endpoints here. + // If you already have these implemented, KEEP YOUR LOGIC and only add the Quant + // section below. + + @GetMapping("/byPerson") + public ResponseEntity getBank(@RequestParam Long personId) { + Bank bank = bankRepo.findByPersonId(personId); + if (bank == null) + return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Bank not found")); + return ResponseEntity.ok(bank); } - - // Search leaderboard - @GetMapping("/leaderboard/search") - public ResponseEntity> searchLeaderboard(@RequestParam String query) { - try { - List matchedBanks = bankJpaRepository.findByUidContainingIgnoreCase(query); - List leaderboard = new ArrayList<>(); - - for (int i = 0; i < matchedBanks.size(); i++) { - Bank bank = matchedBanks.get(i); - leaderboard.add(new LeaderboardEntry( - i + 1, - bank.getId(), - bank.getUsername() != null ? bank.getUsername() : "User " + bank.getId(), - bank.getBalance() - )); - } - - return ResponseEntity.ok(Map.of( - "success", true, - "data", leaderboard - )); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of( - "success", false, - "error", "Error searching leaderboard: " + e.getMessage() - )); - } + + @PostMapping("/deposit") + public ResponseEntity deposit(@RequestBody MoneyRequest req) { + Bank bank = bankRepo.findByPersonId(req.personId); + if (bank == null) + return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Bank not found")); + + if (req.amount <= 0) + return ResponseEntity.badRequest().body(Map.of("success", false, "error", "amount must be > 0")); + bank.setBalance(bank.getBalance() + req.amount); + + bankRepo.save(bank); + return ResponseEntity.ok(Map.of("success", true, "balance", bank.getBalance())); } - // Get individual user analytics data - @GetMapping("/analytics/{userId}") - public ResponseEntity> getUserAnalytics(@PathVariable Long userId) { - try { - Bank bank = bankJpaRepository.findById(userId).orElse(null); - if (bank == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "success", false, - "error", "User not found" - )); - } + @PostMapping("/withdraw") + public ResponseEntity withdraw(@RequestBody MoneyRequest req) { + Bank bank = bankRepo.findByPersonId(req.personId); + if (bank == null) + return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Bank not found")); - // Prepare analytics data - Map analyticsData = new HashMap<>(); - analyticsData.put("userId", bank.getId()); - analyticsData.put("username", bank.getUsername() != null ? bank.getUsername() : "User " + bank.getId()); - analyticsData.put("balance", bank.getBalance()); - analyticsData.put("loanAmount", bank.getLoanAmount()); - analyticsData.put("dailyInterestRate", bank.getDailyInterestRate()); - analyticsData.put("riskCategory", bank.getRiskCategory()); - analyticsData.put("riskCategoryString", bank.getRiskCategoryString()); - analyticsData.put("profitMap", bank.getProfitMap()); - analyticsData.put("featureImportance", bank.getFeatureImportance()); - analyticsData.put("featureExplanations", bank.getFeatureImportanceExplanations()); - - return ResponseEntity.ok(Map.of( - "success", true, - "data", analyticsData - )); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of( - "success", false, - "error", "Error fetching user analytics: " + e.getMessage() - )); - } + if (req.amount <= 0) + return ResponseEntity.badRequest().body(Map.of("success", false, "error", "amount must be > 0")); + if (bank.getBalance() < req.amount) + return ResponseEntity.badRequest().body(Map.of("success", false, "error", "insufficient funds")); + + bank.setBalance(bank.getBalance() - req.amount); + + bankRepo.save(bank); + return ResponseEntity.ok(Map.of("success", true, "balance", bank.getBalance())); + } + + @Data + public static class MoneyRequest { + public Long personId; + public double amount; } - // Get user analytics by person ID (alternative endpoint) - MODIFIED - @GetMapping("/analytics/person/{personId}") - public ResponseEntity> getUserAnalyticsByPersonId(@PathVariable Long personId) { + // ============================================================ + // QUANT TRADING SYSTEM (INSIDE BANK API) + // Everything is under /bank/quant/* so the frontend only uses Bank API. + // ============================================================ + + // 1) Market data: daily OHLCV + // GET /bank/quant/market/history?ticker=AAPL&start=2024-01-01&end=2026-02-09 + @GetMapping("/quant/market/history") + public ResponseEntity quantHistory( + @RequestParam String ticker, + @RequestParam String start, + @RequestParam String end) { try { - Bank bank = findOrCreateBankByPersonId(personId); - - // Prepare analytics data - Map analyticsData = new HashMap<>(); - analyticsData.put("userId", bank.getId()); - analyticsData.put("personId", bank.getPerson().getId()); - analyticsData.put("username", bank.getUsername() != null ? bank.getUsername() : "User " + bank.getId()); - analyticsData.put("balance", bank.getBalance()); - analyticsData.put("loanAmount", bank.getLoanAmount()); - analyticsData.put("dailyInterestRate", bank.getDailyInterestRate()); - analyticsData.put("riskCategory", bank.getRiskCategory()); - analyticsData.put("riskCategoryString", bank.getRiskCategoryString()); - analyticsData.put("profitMap", bank.getProfitMap()); - analyticsData.put("featureImportance", bank.getFeatureImportance()); - analyticsData.put("featureExplanations", bank.getFeatureImportanceExplanations()); - - return ResponseEntity.ok(Map.of( - "success", true, - "data", analyticsData + LocalDate s = LocalDate.parse(start); + LocalDate e = LocalDate.parse(end); + return ResponseEntity.ok(marketDataService.getDailyBars(ticker, s, e)); + } catch (IllegalArgumentException iae) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "bad_request", + "message", iae.getMessage() )); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of( - "success", false, - "error", "Error fetching user analytics: " + e.getMessage() + } catch (Exception ex) { + // Common cause: market data provider requires an API key or is unavailable. + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(Map.of( + "success", false, + "error", "market_data_unavailable", + "message", ex.getMessage() )); } } - // MODIFIED - Now creates bank if not found - @GetMapping("/{id}/profitmap/{category}") - public ResponseEntity>> getProfitByCategory(@PathVariable Long id, @PathVariable String category) { + // 2) Indicators (MA/RSI/BB/MACD) + // POST /bank/quant/indicators/calc + @PostMapping("/quant/indicators/calc") + public ResponseEntity quantIndicators(@RequestBody IndicatorsRequest req) { try { - Bank bank = findOrCreateBankByPersonId(id); - return ResponseEntity.ok(bank.getProfitByCategory(category)); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + var bars = marketDataService.getDailyBars(req.ticker, req.start, req.end); + if (bars == null || bars.isEmpty()) { + // Common with Alpha Vantage free tier (compact history) when users request multi-year ranges. + return ResponseEntity.unprocessableEntity().body(Map.of( + "success", false, + "error", "no_market_data", + "message", "No bars returned for that ticker/date range. If using Alpha Vantage free tier, try a shorter range (last ~100 trading days)." + )); + } + var out = indicatorService.calculateAll( + bars, + req.maShort, req.maLong, + req.rsiPeriod, + req.bbPeriod, + req.macdFast, req.macdSlow); + return ResponseEntity.ok(out); + } catch (IllegalArgumentException iae) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "error", "bad_request", + "message", iae.getMessage() + )); + } catch (Exception ex) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(Map.of( + "success", false, + "error", "indicator_calc_failed", + "message", ex.getMessage() + )); } } - // MODIFIED - Now creates bank if not found - @GetMapping("/{id}/interestRate") - public ResponseEntity getInterestRate(@PathVariable Long id) { - try { - Bank bank = findOrCreateBankByPersonId(id); - return ResponseEntity.ok(bank.getDailyInterestRate()); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } - } - - @PostMapping("/requestLoan") - public ResponseEntity requestLoan(@RequestBody LoanRequest request) { - try { - Bank bank = bankService.requestLoan(request.getPersonId(), request.getLoanAmount()); - return ResponseEntity.ok("Loan of amount " + request.getLoanAmount() + " granted to user with Person ID: " + request.getPersonId()); - } catch (RuntimeException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Loan request failed: " + e.getMessage()); - } - } - - @PostMapping("/repayLoan") - public ResponseEntity repayLoan(@RequestBody RepaymentRequest request) { - try { - Bank bank = bankService.repayLoan(request.getPersonId(), request.getRepaymentAmount()); - return ResponseEntity.ok("Loan repayment of amount " + request.getRepaymentAmount() + - " processed for user with Person ID: " + request.getPersonId() + - ". Remaining loan amount: " + bank.getLoanAmount()); - } catch (RuntimeException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Loan repayment failed: " + e.getMessage()); - } + // 3) News sentiment features (overall + categories + headlines) + // GET /bank/quant/news/sentiment?ticker=AAPL + @GetMapping("/quant/news/sentiment") + public ResponseEntity quantNews(@RequestParam String ticker) { + return ResponseEntity.ok(newsService.getSentimentSnapshot(ticker)); } - // MODIFIED - Now creates bank if not found - @GetMapping("/{personId}/loanAmount") - public ResponseEntity getLoanAmount(@PathVariable Long personId) { - try { - Bank bank = findOrCreateBankByPersonId(personId); - return ResponseEntity.ok(bank.getLoanAmount()); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); - } - } - - @Scheduled(fixedRate = 86400000) - public void scheduledInterestApplication() { - try { - applyInterestToAllLoans(); - } catch (Exception e) { - System.err.println("Scheduled interest application skipped due to database error: " + e.getMessage()); - } - } - - @PostMapping("/newLoanAmountInterest") - public String applyInterestToAllLoans() { - List allBanks = bankJpaRepository.findAll(); - for (Bank bank : allBanks) { - bank.setLoanAmount(bank.getLoanAmount() * 1.05); - } - bankJpaRepository.saveAll(allBanks); - return "Applied 5% interest to all loan amounts."; + // 4) ML train + predict (LR/RF; LSTM hook) + // POST /bank/quant/ml/train + @PostMapping("/quant/ml/train") + public ResponseEntity quantTrain(@RequestBody TrainRequest req) { + var bars = marketDataService.getDailyBars(req.ticker, req.start, req.end); + var indicators = indicatorService.calculateAll(bars, 20, 50, 14, 20, 12, 26); + var news = newsService.getSentimentSnapshot(req.ticker); + return ResponseEntity.ok(mlService.trainAndPredict(req, bars, indicators, news)); } - // MODIFIED - Now creates bank if not found - @GetMapping("/{personId}/npcProgress") - public ResponseEntity> getNpcProgress(@PathVariable Long personId) { - try { - Bank bank = findOrCreateBankByPersonId(personId); - return ResponseEntity.ok((LinkedHashMap) bank.getNpcProgress()); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); - } + // 5) Backtesting (MA/RSI/MACD/ML signals) + // POST /bank/quant/backtest/run + @PostMapping("/quant/backtest/run") + public ResponseEntity quantBacktest(@RequestBody BacktestRequest req) { + var bars = marketDataService.getDailyBars(req.ticker, req.start, req.end); + var indicators = indicatorService.calculateAll(bars, 20, 50, 14, 20, 12, 26); + return ResponseEntity.ok(backtestService.run(req, bars, indicators)); } - @PostMapping("/updateNpcProgress") - public ResponseEntity> updateNpcProgress(@RequestBody npcProgress request) { - try { - String justCompletedNpc = request.getNpcId(); - if (justCompletedNpc == null || justCompletedNpc.isBlank()) { - return ResponseEntity.badRequest().build(); - } - - Bank bank = findOrCreateBankByPersonId(request.getPersonId()); - - LinkedHashMap progressMap = bank.getNpcProgress(); + // 6) Paper trading order: updates "bank game money" instantly + // POST /bank/quant/paper/order + @PostMapping("/quant/paper/order") + public ResponseEntity quantPaperOrder(@RequestBody PaperOrderRequest req) { + Bank bank = bankRepo.findByPersonId(req.personId); + if (bank == null) + return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Bank not found")); - /* - Iterate over the LinkedHashMap entries in insertion order. - Once we find the entry whose key == justCompletedNpc, we set its value to false, - then break out of that loop iteration and immediately set the VERY NEXT entry’s value to true. - */ - boolean found = false; - Iterator> iter = progressMap.entrySet().iterator(); - while (iter.hasNext()) { - Map.Entry entry = iter.next(); + var result = paperTradeService.placeOrder(bank, req); - if (!found) { - if (entry.getKey().equals(justCompletedNpc)) { - found = true; - } - } - else { - entry.setValue(true); - break; - } - } + // ensure balance changes persist + bankRepo.save(bank); - bank.setNpcProgress(progressMap); - bankJpaRepository.save(bank); - - @SuppressWarnings("unchecked") - LinkedHashMap result = (LinkedHashMap) bank.getNpcProgress(); return ResponseEntity.ok(result); - - } catch (RuntimeException e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); - } - } - - // Extract all bank accounts data into DTOs - @GetMapping("/bulk/extract") - public ResponseEntity> bulkExtract() { - try { - // Fetch all Bank entries from the database - List bankList = bankJpaRepository.findAll(); - - // Map Bank entities to BankDto objects - List bankDtos = new ArrayList<>(); - for (Bank bank : bankList) { - BankDto bankDto = new BankDto(); - bankDto.setId(bank.getId()); - bankDto.setUsername(bank.getUsername()); - bankDto.setUid(bank.getUid()); - bankDto.setBalance(bank.getBalance()); - bankDto.setLoanAmount(bank.getLoanAmount()); - bankDto.setDailyInterestRate(bank.getDailyInterestRate()); - bankDto.setRiskCategory(bank.getRiskCategory()); - - // Add person ID if available - if (bank.getPerson() != null) { - bankDto.setPersonId(bank.getPerson().getId()); - } - - bankDtos.add(bankDto); - } - - // Return the list of BankDto objects - return new ResponseEntity<>(bankDtos, HttpStatus.OK); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } - } - - @DeleteMapping("/bulk/clear") - public ResponseEntity clearTable(HttpServletRequest request) { - try { - // Get initial count - long initialCount = bankJpaRepository.count(); - - if (initialCount == 0) { - Map response = new HashMap<>(); - response.put("status", "success"); - response.put("message", "No bank records to clear"); - response.put("initialCount", 0); - response.put("deletedCount", 0); - return new ResponseEntity<>(response, HttpStatus.OK); - } - - // Attempt to clear - bankService.clearAllBanks(); - - // Verify deletion - long finalCount = bankJpaRepository.count(); - long deletedCount = initialCount - finalCount; - - Map response = new HashMap<>(); - if (finalCount == 0) { - response.put("status", "success"); - response.put("message", "All bank records have been cleared successfully"); - } else { - response.put("status", "partial_success"); - response.put("message", String.format("Partially cleared: %d out of %d records deleted", deletedCount, initialCount)); - } - - response.put("initialCount", initialCount); - response.put("finalCount", finalCount); - response.put("deletedCount", deletedCount); - - return new ResponseEntity<>(response, HttpStatus.OK); - - } catch (Exception e) { - Map errorResponse = new HashMap<>(); - errorResponse.put("status", "error"); - errorResponse.put("message", "Failed to clear table: " + e.getMessage()); - errorResponse.put("exception", e.getClass().getSimpleName()); - - // Include current count for debugging - try { - errorResponse.put("currentCount", bankJpaRepository.count()); - } catch (Exception countException) { - errorResponse.put("currentCount", "unable_to_count"); - } - - return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - // Alternative force clear endpoint - @DeleteMapping("/bulk/clear/force") - public ResponseEntity forceClearTable(HttpServletRequest request) { - try { - long initialCount = bankJpaRepository.count(); - - if (initialCount == 0) { - Map response = new HashMap<>(); - response.put("status", "success"); - response.put("message", "No bank records to clear"); - response.put("initialCount", 0); - response.put("deletedCount", 0); - return new ResponseEntity<>(response, HttpStatus.OK); - } - - // Use force clear method - bankService.clearAllBanksForce(); - - // Verify deletion - long finalCount = bankJpaRepository.count(); - long deletedCount = initialCount - finalCount; - - Map response = new HashMap<>(); - if (finalCount == 0) { - response.put("status", "success"); - response.put("message", "All bank records have been force cleared successfully"); - } else { - response.put("status", "partial_success"); - response.put("message", String.format("Force clear partially successful: %d out of %d records deleted", deletedCount, initialCount)); - } - - response.put("initialCount", initialCount); - response.put("finalCount", finalCount); - response.put("deletedCount", deletedCount); - - return new ResponseEntity<>(response, HttpStatus.OK); - - } catch (Exception e) { - Map errorResponse = new HashMap<>(); - errorResponse.put("status", "error"); - errorResponse.put("message", "Failed to force clear table: " + e.getMessage()); - errorResponse.put("exception", e.getClass().getSimpleName()); - - try { - errorResponse.put("currentCount", bankJpaRepository.count()); - } catch (Exception countException) { - errorResponse.put("currentCount", "unable_to_count"); - } - - return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); - } } - // Bulk create/update bank accounts - @PostMapping("/bulk/create") - public ResponseEntity bulkCreateBanks(@RequestBody List bankDtos) { - List createdBanks = new ArrayList<>(); - List updatedBanks = new ArrayList<>(); - List errors = new ArrayList<>(); - - for (BankDto bankDto : bankDtos) { - try { - // If ID is provided, try to find existing bank - Bank bank = null; - - if (bankDto.getId() != null) { - bank = bankJpaRepository.findById(bankDto.getId()).orElse(null); - } else if (bankDto.getPersonId() != null) { - // Otherwise try to find by person ID - bank = bankJpaRepository.findByPersonId(bankDto.getPersonId()); - } else if (bankDto.getUid() != null) { - // Or by username - bank = bankJpaRepository.findByUid(bankDto.getUid()); - } - - if (bank != null) { - // Update existing bank - if (bankDto.getBalance() > 0) { - bank.setBalance(bankDto.getBalance()); - } - - if (bankDto.getLoanAmount() >= 0) { - bank.setLoanAmount(bankDto.getLoanAmount()); - } - - if (bankDto.getDailyInterestRate() > 0) { - bank.setDailyInterestRate(bankDto.getDailyInterestRate()); - } - - bankJpaRepository.save(bank); - updatedBanks.add(bank.getUsername() != null ? bank.getUsername() : "Bank ID: " + bank.getId()); - } else if (bankDto.getUid() != null) { - // Create new bank account if person exists - Person person = personJpaRepository.findById(bankDto.getPersonId()).get(); - - if (person != null) { - // Create new bank account using the modified constructor - bank = new Bank(person); - - // Set loan amount if provided - if (bankDto.getLoanAmount() >= 0) { - bank.setLoanAmount(bankDto.getLoanAmount()); - } - - if (bankDto.getBalance() > 0) { - bank.setBalance(bankDto.getBalance()); - } - - if (bankDto.getDailyInterestRate() > 0) { - bank.setDailyInterestRate(bankDto.getDailyInterestRate()); - } - - bank.assessRiskUsingML(); - bankJpaRepository.save(bank); - createdBanks.add(bank.getUsername()); - } else { - errors.add("Person not found with ID: " + bankDto.getUid()); - } - } else if (bankDto.getUsername() != null) { - // Try to find person by username (name) - Person person = personJpaRepository.findByName(bankDto.getUsername()); - - if (person != null) { - // Create new bank account - bank = new Bank(person); - - // Set properties if provided - if (bankDto.getLoanAmount() >= 0) { - bank.setLoanAmount(bankDto.getLoanAmount()); - } - - if (bankDto.getBalance() > 0) { - bank.setBalance(bankDto.getBalance()); - } - - if (bankDto.getDailyInterestRate() > 0) { - bank.setDailyInterestRate(bankDto.getDailyInterestRate()); - } - - bank.assessRiskUsingML(); - bankJpaRepository.save(bank); - createdBanks.add(bank.getUsername()); - } else { - errors.add("Person not found with username: " + bankDto.getUsername()); - } - } else if (bankDto.getUid() != null) { - // Try to find person by UID - Person person = personJpaRepository.findByUid(bankDto.getUid()); - - if (person != null) { - // Create new bank account - bank = new Bank(person); - - // Set properties if provided - if (bankDto.getLoanAmount() >= 0) { - bank.setLoanAmount(bankDto.getLoanAmount()); - } - - if (bankDto.getBalance() > 0) { - bank.setBalance(bankDto.getBalance()); - } - - if (bankDto.getDailyInterestRate() > 0) { - bank.setDailyInterestRate(bankDto.getDailyInterestRate()); - } - - bank.assessRiskUsingML(); - bankJpaRepository.save(bank); - createdBanks.add(bank.getUsername()); - } else { - errors.add("Person not found with UID: " + bankDto.getUid()); - } - } else { - errors.add("Cannot create bank account: missing identification information (personId, username, or uid)"); - } - } catch (Exception e) { - errors.add("Exception for bank: " + - (bankDto.getUsername() != null ? bankDto.getUsername() : "ID: " + bankDto.getId()) + - " - " + e.getMessage()); - } - } - - // Prepare the response - Map response = new HashMap<>(); - response.put("created", createdBanks); - response.put("updated", updatedBanks); - response.put("errors", errors); - - return new ResponseEntity<>(response, HttpStatus.OK); + // 7) Paper portfolio snapshot + // GET /bank/quant/paper/portfolio?personId=123 + @GetMapping("/quant/paper/portfolio") + public ResponseEntity quantPortfolio(@RequestParam Long personId) { + Bank bank = bankRepo.findByPersonId(personId); + if (bank == null) + return ResponseEntity.badRequest().body(Map.of("success", false, "error", "Bank not found")); + return ResponseEntity.ok(paperTradeService.getPortfolio(bank)); } - -} - -@Data -@NoArgsConstructor -@AllArgsConstructor -class LoanRequest { - private Long personId; - private double loanAmount; -} + // -------------------- Request DTOs -------------------- + + @Data + public static class IndicatorsRequest { + public String ticker; + public LocalDate start; + public LocalDate end; + public int maShort = 20; + public int maLong = 50; + public int rsiPeriod = 14; + public int bbPeriod = 20; + public int macdFast = 12; + public int macdSlow = 26; + } -@Data -@NoArgsConstructor -@AllArgsConstructor -class RepaymentRequest { - private Long personId; - private double repaymentAmount; -} + @Data + public static class TrainRequest { + public String ticker; + public LocalDate start; + public LocalDate end; + public String modelType; // "linear_regression" | "random_forest" | "lstm" + public int lookback = 60; + public int horizon = 5; + public double testSize = 0.2; + } -@Data -@NoArgsConstructor -@AllArgsConstructor -class LeaderboardEntry { - private int rank; - private Long userId; - private String username; - private double balance; -} + @Data + public static class BacktestRequest { + public String ticker; + public LocalDate start; + public LocalDate end; + public String strategy; // "ma" | "rsi" | "macd" | "ml" + public double initialCapital = 10000; + public double positionPct = 1.0; + public double stopLoss = 0.05; + public double takeProfit = 0.10; + public double commission = 0.001; + } -@Data -@NoArgsConstructor -@AllArgsConstructor -class BankDto { - private Long id; - private Long personId; - private String username; - private String uid; - private double balance; - private double loanAmount; - private double dailyInterestRate; - private int riskCategory; + @Data + public static class PaperOrderRequest { + public Long personId; + public String ticker; + public String side; // "buy" or "sell" + public int qty; + public String type; // "market" only for now + } } - -@Data -@NoArgsConstructor -@AllArgsConstructor -class npcProgress { - private Long personId; - private String npcId; -} \ No newline at end of file diff --git a/src/main/java/com/open/spring/mvc/bank/BankJpaRepository.java b/src/main/java/com/open/spring/mvc/bank/BankJpaRepository.java index cf3395477..08c04697d 100644 --- a/src/main/java/com/open/spring/mvc/bank/BankJpaRepository.java +++ b/src/main/java/com/open/spring/mvc/bank/BankJpaRepository.java @@ -1,33 +1,33 @@ +// src/main/java/com/open/spring/mvc/bank/BankJpaRepository.java package com.open.spring.mvc.bank; import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.transaction.annotation.Transactional; public interface BankJpaRepository extends JpaRepository { - // Find bank by person_id Bank findByPersonId(Long personId); - Bank findByUid(String uid); - List findByUidContainingIgnoreCase(String uid); - // Find top 10 banks ordered by balance in descending order (for leaderboard) + Bank findByUid(String uid); + List findByUidContainingIgnoreCase(String uid); + List findTop10ByOrderByBalanceDesc(); + @Query("SELECT p FROM Bank p ORDER BY CAST(p.balance AS double) DESC LIMIT 5") List findTop5ByOrderByBalanceDesc(); - - // Alternative deletion methods for troubleshooting + @Modifying @Transactional @Query("DELETE FROM Bank b") void deleteAllBanks(); - + @Modifying @Transactional @Query(value = "TRUNCATE TABLE bank RESTART IDENTITY CASCADE", nativeQuery = true) void truncateBankTable(); - - // For debugging - check if there are any orphaned records + @Query("SELECT COUNT(b) FROM Bank b WHERE b.person IS NULL") long countOrphanedBanks(); -} \ No newline at end of file +} diff --git a/src/main/java/com/open/spring/mvc/bank/BankService.java b/src/main/java/com/open/spring/mvc/bank/BankService.java index 8b56f1748..402a75c7c 100644 --- a/src/main/java/com/open/spring/mvc/bank/BankService.java +++ b/src/main/java/com/open/spring/mvc/bank/BankService.java @@ -1,3 +1,4 @@ +// src/main/java/com/open/spring/mvc/bank/BankService.java package com.open.spring.mvc.bank; import org.slf4j.Logger; @@ -14,7 +15,6 @@ public class BankService { @Autowired private BankJpaRepository bankRepository; - // Find by Person ID public Bank findByPersonId(Long personId) { Bank bank = bankRepository.findByPersonId(personId); if (bank == null) { @@ -28,59 +28,51 @@ public Bank findByPersonId(Long personId) { public void clearAllBanks() { try { logger.info("Starting to clear all bank records..."); - - // Get count before deletion for verification + long initialCount = bankRepository.count(); logger.info("Initial bank record count: {}", initialCount); - + if (initialCount == 0) { logger.info("No bank records to delete"); return; } - - // Method 1: Try simple deleteAll first + bankRepository.deleteAll(); - bankRepository.flush(); // Force immediate execution - - // Verify deletion + bankRepository.flush(); + long finalCount = bankRepository.count(); logger.info("Final bank record count after deletion: {}", finalCount); - + if (finalCount > 0) { logger.warn("Some records were not deleted. Attempting alternative deletion method..."); - - // Method 2: Delete by batches if simple deleteAll fails clearAllBanksByBatch(); } else { logger.info("Successfully cleared all {} bank records", initialCount); } - + } catch (Exception e) { logger.error("Error clearing bank records: ", e); throw new RuntimeException("Failed to clear bank records: " + e.getMessage(), e); } } - + @Transactional public void clearAllBanksByBatch() { try { logger.info("Attempting batch deletion of bank records..."); - - // Get all bank IDs + var allBanks = bankRepository.findAll(); logger.info("Found {} bank records to delete", allBanks.size()); - - // Delete in batches of 50 to avoid memory issues + int batchSize = 50; for (int i = 0; i < allBanks.size(); i += batchSize) { int endIndex = Math.min(i + batchSize, allBanks.size()); var batch = allBanks.subList(i, endIndex); - + logger.info("Deleting batch {}-{} of {}", i + 1, endIndex, allBanks.size()); - + for (Bank bank : batch) { try { - // First, break the relationship with Person if it exists if (bank.getPerson() != null) { bank.getPerson().setBanks(null); bank.setPerson(null); @@ -90,77 +82,66 @@ public void clearAllBanksByBatch() { logger.error("Failed to delete bank with ID: {}", bank.getId(), e); } } - - // Flush after each batch + bankRepository.flush(); } - - // Final verification + long remainingCount = bankRepository.count(); logger.info("Remaining bank records after batch deletion: {}", remainingCount); - + } catch (Exception e) { logger.error("Error in batch deletion: ", e); throw new RuntimeException("Failed to clear bank records via batch deletion: " + e.getMessage(), e); } } - + @Transactional public void clearAllBanksForce() { try { logger.info("Attempting force deletion using native SQL..."); - - // This would require adding a native query method to the repository - // For now, we'll use the existing methods with proper error handling + var allBanks = bankRepository.findAll(); - + for (Bank bank : allBanks) { try { - // Manually handle the relationship if (bank.getPerson() != null) { bank.getPerson().setBanks(null); } bank.setPerson(null); - bankRepository.save(bank); // Save the changes first + bankRepository.save(bank); } catch (Exception e) { logger.warn("Could not update relationships for bank ID: {}", bank.getId()); } } - - // Now try to delete all + bankRepository.deleteAll(); bankRepository.flush(); - + logger.info("Force deletion completed"); - + } catch (Exception e) { logger.error("Error in force deletion: ", e); throw new RuntimeException("Failed to force clear bank records: " + e.getMessage(), e); } } - // Request a loan using the Person ID @Transactional public Bank requestLoan(Long personId, double loanAmount) { - // Validate input if (personId == null) { logger.error("Invalid Person ID provided for loan request"); throw new IllegalArgumentException("Person ID cannot be null"); } - if (loanAmount <= 0) { logger.error("Invalid loan amount: {}", loanAmount); throw new IllegalArgumentException("Loan amount must be positive"); } - // Find bank account Bank bank = bankRepository.findByPersonId(personId); if (bank == null) { logger.error("No bank account found for Person ID: {}", personId); throw new RuntimeException("Bank account not found for Person ID: " + personId); } - // Process loan request try { bank.requestLoan(loanAmount); logger.info("Loan request processed for Person ID: {} amount: {}", personId, loanAmount); @@ -170,29 +151,24 @@ public Bank requestLoan(Long personId, double loanAmount) { throw new RuntimeException("Failed to process loan request", e); } } - - // Repay a loan using the Person ID + @Transactional public Bank repayLoan(Long personId, double repaymentAmount) { - // Validate input if (personId == null) { logger.error("Invalid Person ID provided for loan repayment"); throw new IllegalArgumentException("Person ID cannot be null"); } - if (repaymentAmount <= 0) { logger.error("Invalid repayment amount: {}", repaymentAmount); throw new IllegalArgumentException("Repayment amount must be positive"); } - // Find bank account Bank bank = bankRepository.findByPersonId(personId); if (bank == null) { logger.error("No bank account found for Person ID: {}", personId); throw new RuntimeException("Bank account not found for Person ID: " + personId); } - // Process loan repayment try { bank.repayLoan(repaymentAmount); logger.info("Loan repayment processed for Person ID: {} amount: {}", personId, repaymentAmount); @@ -202,4 +178,4 @@ public Bank repayLoan(Long personId, double repaymentAmount) { throw new RuntimeException("Failed to process loan repayment: " + e.getMessage(), e); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/open/spring/mvc/quant/BacktestResult.java b/src/main/java/com/open/spring/mvc/quant/BacktestResult.java new file mode 100644 index 000000000..56daea04b --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/BacktestResult.java @@ -0,0 +1,38 @@ +package com.open.spring.mvc.quant; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +@Data +public class BacktestResult { + + // Chart series + private List dates = new ArrayList<>(); + private List portfolioValue = new ArrayList<>(); + private List benchmarkValue = new ArrayList<>(); // buy & hold + + // Returns series (daily %) + private List returns = new ArrayList<>(); + + // Trades list + private List trades = new ArrayList<>(); + + // Quick metrics + private double totalReturnPct; + private double maxDrawdownPct; + private double winRatePct; + + private String note; + + @Data + public static class Trade { + private String date; + private String side; // BUY / SELL + private int qty; + private double price; + private double cashAfter; + private double positionAfter; + } +} diff --git a/src/main/java/com/open/spring/mvc/quant/BacktestService.java b/src/main/java/com/open/spring/mvc/quant/BacktestService.java new file mode 100644 index 000000000..ccedfa03b --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/BacktestService.java @@ -0,0 +1,259 @@ +package com.open.spring.mvc.quant; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import com.open.spring.mvc.bank.BankApiController; + +/** + * Backtesting engine: + * Strategies: ma / rsi / macd / ml + */ +@Service +public class BacktestService { + + private final IndicatorService indicatorService; + + public BacktestService(IndicatorService indicatorService) { + this.indicatorService = indicatorService; + } + + public BacktestResult run(BankApiController.BacktestRequest req, List bars, Object indicatorsMaybe) { + BacktestResult out = new BacktestResult(); + + if (bars == null || bars.size() < 60) { + out.setNote("Not enough market bars for backtest (need ~60+)."); + return out; + } + + // Sort bars by time + List data = new ArrayList<>(bars); + data.sort(Comparator.comparing(Bar::getTime)); + + String strategy = safeLower(req.strategy); + double initial = Math.max(1.0, req.initialCapital); + double positionPct = clamp(req.positionPct, 0.01, 1.0); + double stopLoss = clamp(req.stopLoss, 0.0, 0.5); + double takeProfit = clamp(req.takeProfit, 0.0, 2.0); + double commission = clamp(req.commission, 0.0, 0.02); + + double cash = initial; + int shares = 0; + double entryPrice = 0.0; + + double benchShares = initial / data.get(0).getClose(); + + // compute indicators from IndicatorService + Map indMap = indicatorService.calculateAll(data, 20, 50, 14, 20, 12, 26); + + List maShort = (List) indMap.get("MA_short"); + List maLong = (List) indMap.get("MA_long"); + List rsi = (List) indMap.get("RSI"); + List macd = (List) indMap.get("MACD"); + List macdSignal = (List) indMap.get("MACD_signal"); + + double peak = initial; + double maxDD = 0.0; + + for (int i = 1; i < data.size(); i++) { + Bar b = data.get(i); + double price = b.getClose(); + LocalDate date = b.getTime().atZone(ZoneOffset.UTC).toLocalDate(); + + double pv = cash + shares * price; + + // stoploss/takeprofit + if (shares > 0 && entryPrice > 0) { + double move = (price / entryPrice) - 1.0; + + if (stopLoss > 0 && move <= -stopLoss) { + cash += shares * price * (1.0 - commission); + recordTrade(out, date, "SELL", shares, price, cash, 0); + shares = 0; + entryPrice = 0; + pv = cash; + } else if (takeProfit > 0 && move >= takeProfit) { + cash += shares * price * (1.0 - commission); + recordTrade(out, date, "SELL", shares, price, cash, 0); + shares = 0; + entryPrice = 0; + pv = cash; + } + } + + int signal = signalForDay(strategy, i, data, maShort, maLong, rsi, macd, macdSignal); + + if (signal == 1 && shares == 0) { + double budget = cash * positionPct; + int qty = (int) Math.floor(budget / price); + + if (qty > 0) { + double cost = qty * price * (1.0 + commission); + if (cost <= cash) { + cash -= cost; + shares += qty; + entryPrice = price; + recordTrade(out, date, "BUY", qty, price, cash, shares); + } + } + } else if (signal == -1 && shares > 0) { + cash += shares * price * (1.0 - commission); + recordTrade(out, date, "SELL", shares, price, cash, 0); + shares = 0; + entryPrice = 0; + } + + double portfolio = cash + shares * price; + double benchmark = benchShares * price; + + out.getDates().add(date.toString()); + out.getPortfolioValue().add(round2(portfolio)); + out.getBenchmarkValue().add(round2(benchmark)); + + if (out.getPortfolioValue().size() > 1) { + double prev = out.getPortfolioValue().get(out.getPortfolioValue().size() - 2); + double ret = prev > 0 ? (portfolio / prev) - 1.0 : 0.0; + out.getReturns().add(ret); + } + + peak = Math.max(peak, portfolio); + double dd = peak > 0 ? (portfolio / peak) - 1.0 : 0.0; + maxDD = Math.min(maxDD, dd); + } + + double finalPV = out.getPortfolioValue().isEmpty() ? initial : out.getPortfolioValue().get(out.getPortfolioValue().size() - 1); + out.setTotalReturnPct(((finalPV / initial) - 1.0) * 100.0); + out.setMaxDrawdownPct(Math.abs(maxDD) * 100.0); + out.setWinRatePct(calcWinRate(out.getTrades())); + out.setNote("Backtest complete."); + return out; + } + + // ---------------- Signals ---------------- + + private int signalForDay( + String strategy, + int i, + List data, + List maShort, + List maLong, + List rsi, + List macd, + List macdSignal + ) { + switch (strategy) { + case "ma": + return maSignal(i, maShort, maLong); + case "rsi": + return rsiSignal(i, rsi); + case "macd": + return macdSignal(i, macd, macdSignal); + case "ml": + return momentumSignal(i, data); + default: + return 0; + } + } + + private int maSignal(int i, List maS, List maL) { + if (i <= 0) return 0; + Double sPrev = maS.get(i - 1); + Double lPrev = maL.get(i - 1); + Double sNow = maS.get(i); + Double lNow = maL.get(i); + + if (sPrev == null || lPrev == null || sNow == null || lNow == null) return 0; + + if (sPrev <= lPrev && sNow > lNow) return 1; + if (sPrev >= lPrev && sNow < lNow) return -1; + return 0; + } + + private int rsiSignal(int i, List rsi) { + Double v = rsi.get(i); + if (v == null) return 0; + if (v <= 30) return 1; + if (v >= 70) return -1; + return 0; + } + + private int macdSignal(int i, List macd, List sig) { + if (i <= 0) return 0; + + Double mPrev = macd.get(i - 1); + Double sPrev = sig.get(i - 1); + Double mNow = macd.get(i); + Double sNow = sig.get(i); + + if (mPrev == null || sPrev == null || mNow == null || sNow == null) return 0; + + if (mPrev <= sPrev && mNow > sNow) return 1; + if (mPrev >= sPrev && mNow < sNow) return -1; + return 0; + } + + // simple momentum placeholder for ML strategy + private int momentumSignal(int i, List data) { + if (i < 20) return 0; + double now = data.get(i).getClose(); + double prev = data.get(i - 20).getClose(); + if (prev <= 0) return 0; + + double mom = (now / prev) - 1.0; + if (mom > 0.02) return 1; + if (mom < -0.02) return -1; + return 0; + } + + // ---------------- Helpers ---------------- + + private void recordTrade(BacktestResult out, LocalDate date, String side, int qty, double price, double cashAfter, double posAfter) { + BacktestResult.Trade t = new BacktestResult.Trade(); + t.setDate(date.toString()); + t.setSide(side); + t.setQty(qty); + t.setPrice(round2(price)); + t.setCashAfter(round2(cashAfter)); + t.setPositionAfter(round2(posAfter)); + out.getTrades().add(t); + } + + private double calcWinRate(List trades) { + double lastBuyPrice = -1; + int wins = 0; + int closed = 0; + + for (BacktestResult.Trade t : trades) { + if ("BUY".equalsIgnoreCase(t.getSide())) { + lastBuyPrice = t.getPrice(); + } else if ("SELL".equalsIgnoreCase(t.getSide()) && lastBuyPrice > 0) { + closed++; + if (t.getPrice() > lastBuyPrice) wins++; + lastBuyPrice = -1; + } + } + + if (closed == 0) return 0.0; + return (wins * 100.0) / closed; + } + + private String safeLower(String s) { + if (s == null) return ""; + return s.trim().toLowerCase(Locale.ROOT); + } + + private double clamp(double v, double lo, double hi) { + return Math.max(lo, Math.min(hi, v)); + } + + private double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } +} diff --git a/src/main/java/com/open/spring/mvc/quant/Bar.java b/src/main/java/com/open/spring/mvc/quant/Bar.java new file mode 100644 index 000000000..aeb0af984 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/Bar.java @@ -0,0 +1,55 @@ +package com.open.spring.mvc.quant; + +import java.time.Instant; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Simple market data "bar" (OHLCV) used by the quant system. + * Keep this as a plain POJO/DTO (NOT a JPA @Entity) unless you explicitly + * want to persist bars in your database. + * + * Folder path must match: + * src/main/java/com/open/spring/mvc/quant/Bar.java + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Bar { + + /** Stock/asset symbol, e.g., "AAPL" */ + private String symbol; + + /** Timestamp for the bar (UTC recommended) */ + private Instant time; + + /** Open price */ + private double open; + + /** High price */ + private double high; + + /** Low price */ + private double low; + + /** Close price */ + private double close; + + /** Volume */ + private long volume; + + /** Optional: timeframe label, e.g., "1d", "1h" */ + private String timeframe; + + /** Convenience: typical price */ + public double typicalPrice() { + return (high + low + close) / 3.0; + } + + /** Convenience: bar return (close-open)/open; returns 0 if open==0 */ + public double simpleReturn() { + return open == 0.0 ? 0.0 : (close - open) / open; + } +} diff --git a/src/main/java/com/open/spring/mvc/quant/Headline.java b/src/main/java/com/open/spring/mvc/quant/Headline.java new file mode 100644 index 000000000..d462d58bb --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/Headline.java @@ -0,0 +1,17 @@ +package com.open.spring.mvc.quant; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Simple headline structure used in SentimentSnapshot. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Headline { + private String time; // ISO-8601 string + private String title; // short headline text + private double score; // sentiment score for this headline [-1,1] +} diff --git a/src/main/java/com/open/spring/mvc/quant/IndicatorService.java b/src/main/java/com/open/spring/mvc/quant/IndicatorService.java new file mode 100644 index 000000000..3b97c9718 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/IndicatorService.java @@ -0,0 +1,285 @@ +package com.open.spring.mvc.quant; + +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * Computes indicators used by the BankApiController: + * - Moving averages (short/long) + * - RSI + * - Bollinger Bands + * - MACD (fast/slow, signal=9, histogram) + * + * Returns a Map with arrays aligned to the bars list index. + */ +@Service +public class IndicatorService { + + public Map calculateAll( + List bars, + int maShort, + int maLong, + int rsiPeriod, + int bbPeriod, + int macdFast, + int macdSlow + ) { + int n = (bars == null) ? 0 : bars.size(); + List close = new ArrayList<>(n); + for (Bar b : bars) close.add(b.getClose()); + + List maS = sma(close, maShort); + List maL = sma(close, maLong); + + List rsi = rsi(close, rsiPeriod); + + Bollinger bb = bollinger(close, bbPeriod, 2.0); + + Macd macd = macd(close, macdFast, macdSlow, 9); + + // Signals (simple) + Map signals = new HashMap<>(); + signals.put("maSignal", maSignal(maS, maL)); + signals.put("rsiSignal", rsiSignal(rsi)); + signals.put("macdSignal", macdSignal(macd.macd, macd.signal)); + + Map out = new HashMap<>(); + out.put("bars", bars); + + out.put("MA_short", maS); + out.put("MA_long", maL); + + out.put("RSI", rsi); + + out.put("BB_upper", bb.upper); + out.put("BB_middle", bb.middle); + out.put("BB_lower", bb.lower); + + out.put("MACD", macd.macd); + out.put("MACD_signal", macd.signal); + out.put("MACD_histogram", macd.hist); + + out.put("signals", signals); + return out; + } + + // ------------------------- + // Simple signals + // ------------------------- + + private int maSignal(List maS, List maL) { + int n = Math.min(maS.size(), maL.size()); + if (n < 2) return 0; + Double s1 = maS.get(n - 2), s2 = maS.get(n - 1); + Double l1 = maL.get(n - 2), l2 = maL.get(n - 1); + if (s1 == null || s2 == null || l1 == null || l2 == null) return 0; + + boolean crossedUp = s1 <= l1 && s2 > l2; + boolean crossedDown = s1 >= l1 && s2 < l2; + + if (crossedUp) return 1; + if (crossedDown) return -1; + return 0; + } + + private int rsiSignal(List rsi) { + if (rsi.isEmpty()) return 0; + Double last = rsi.get(rsi.size() - 1); + if (last == null) return 0; + if (last < 30) return 1; // oversold -> buy + if (last > 70) return -1; // overbought -> sell + return 0; + } + + private int macdSignal(List macd, List signal) { + int n = Math.min(macd.size(), signal.size()); + if (n < 2) return 0; + Double m1 = macd.get(n - 2), m2 = macd.get(n - 1); + Double s1 = signal.get(n - 2), s2 = signal.get(n - 1); + if (m1 == null || m2 == null || s1 == null || s2 == null) return 0; + + boolean crossedUp = m1 <= s1 && m2 > s2; + boolean crossedDown = m1 >= s1 && m2 < s2; + + if (crossedUp) return 1; + if (crossedDown) return -1; + return 0; + } + + // ------------------------- + // Indicator implementations + // ------------------------- + + private List sma(List values, int period) { + int n = values.size(); + List out = new ArrayList<>(Collections.nCopies(n, null)); + if (period <= 1) { + for (int i = 0; i < n; i++) out.set(i, values.get(i)); + return out; + } + + double sum = 0.0; + for (int i = 0; i < n; i++) { + Double v = values.get(i); + if (v == null) continue; + sum += v; + + if (i >= period) { + Double toRemove = values.get(i - period); + if (toRemove != null) sum -= toRemove; + } + + if (i >= period - 1) { + out.set(i, sum / period); + } + } + return out; + } + + private List rsi(List close, int period) { + int n = close.size(); + List out = new ArrayList<>(Collections.nCopies(n, null)); + if (n < period + 1) return out; + + double gain = 0.0, loss = 0.0; + + // seed + for (int i = 1; i <= period; i++) { + double diff = close.get(i) - close.get(i - 1); + if (diff >= 0) gain += diff; + else loss -= diff; + } + + double avgGain = gain / period; + double avgLoss = loss / period; + + out.set(period, rsiFromAverages(avgGain, avgLoss)); + + // Wilder smoothing + for (int i = period + 1; i < n; i++) { + double diff = close.get(i) - close.get(i - 1); + double g = Math.max(diff, 0); + double l = Math.max(-diff, 0); + + avgGain = ((avgGain * (period - 1)) + g) / period; + avgLoss = ((avgLoss * (period - 1)) + l) / period; + + out.set(i, rsiFromAverages(avgGain, avgLoss)); + } + + return out; + } + + private double rsiFromAverages(double avgGain, double avgLoss) { + if (avgLoss == 0) return 100.0; + double rs = avgGain / avgLoss; + return 100.0 - (100.0 / (1.0 + rs)); + } + + private static class Bollinger { + List upper, middle, lower; + Bollinger(List u, List m, List l) { + upper = u; middle = m; lower = l; + } + } + + private Bollinger bollinger(List close, int period, double k) { + int n = close.size(); + List mid = sma(close, period); + List upper = new ArrayList<>(Collections.nCopies(n, null)); + List lower = new ArrayList<>(Collections.nCopies(n, null)); + + if (period <= 1) { + for (int i = 0; i < n; i++) { + Double c = close.get(i); + upper.set(i, c); + lower.set(i, c); + } + return new Bollinger(upper, mid, lower); + } + + for (int i = period - 1; i < n; i++) { + Double mean = mid.get(i); + if (mean == null) continue; + + double var = 0.0; + for (int j = i - period + 1; j <= i; j++) { + double d = close.get(j) - mean; + var += d * d; + } + double std = Math.sqrt(var / period); + + upper.set(i, mean + k * std); + lower.set(i, mean - k * std); + } + + return new Bollinger(upper, mid, lower); + } + + private static class Macd { + List macd, signal, hist; + Macd(List m, List s, List h) { + macd = m; signal = s; hist = h; + } + } + + private Macd macd(List close, int fast, int slow, int signalPeriod) { + int n = close.size(); + + List emaFast = ema(close, fast); + List emaSlow = ema(close, slow); + + List macdLine = new ArrayList<>(Collections.nCopies(n, null)); + for (int i = 0; i < n; i++) { + Double f = emaFast.get(i); + Double s = emaSlow.get(i); + if (f != null && s != null) macdLine.set(i, f - s); + } + + List signal = ema(macdLine, signalPeriod); + + List hist = new ArrayList<>(Collections.nCopies(n, null)); + for (int i = 0; i < n; i++) { + Double m = macdLine.get(i); + Double sig = signal.get(i); + if (m != null && sig != null) hist.set(i, m - sig); + } + + return new Macd(macdLine, signal, hist); + } + + private List ema(List values, int period) { + int n = values.size(); + List out = new ArrayList<>(Collections.nCopies(n, null)); + if (n == 0) return out; + if (period <= 1) { + for (int i = 0; i < n; i++) out.set(i, values.get(i)); + return out; + } + + double alpha = 2.0 / (period + 1.0); + + // find first non-null + int start = -1; + for (int i = 0; i < n; i++) { + if (values.get(i) != null) { start = i; break; } + } + if (start == -1) return out; + + double prev = values.get(start); + out.set(start, prev); + + for (int i = start + 1; i < n; i++) { + Double v = values.get(i); + if (v == null) { + out.set(i, prev); + continue; + } + prev = alpha * v + (1 - alpha) * prev; + out.set(i, prev); + } + + return out; + } +} diff --git a/src/main/java/com/open/spring/mvc/quant/MLService.java b/src/main/java/com/open/spring/mvc/quant/MLService.java new file mode 100644 index 000000000..d3e0900e9 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/MLService.java @@ -0,0 +1,330 @@ +package com.open.spring.mvc.quant; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import org.springframework.stereotype.Service; + +import com.open.spring.mvc.bank.BankApiController; + +/** + * Lightweight ML engine: + * - Supports "linear_regression" and "random_forest" (RF behaves like LR for now) + * - Uses returns + volatility + news sentiment as features + */ +@Service +public class MLService { + + public MLTrainResponse trainAndPredict( + BankApiController.TrainRequest req, + List bars, + Object indicatorsMaybe, + SentimentSnapshot news + ) { + String ticker = safeUpper(req.ticker); + String modelType = safeLower(req.modelType); + int horizon = Math.max(1, req.horizon); + double testSize = clamp(req.testSize, 0.1, 0.5); + + MLTrainResponse out = new MLTrainResponse(); + out.setTicker(ticker); + out.setModelType(modelType); + + if (bars == null || bars.size() < 80) { + out.setNote("Not enough market data. Need at least ~80 daily bars."); + return out; + } + + // sort by time ascending + bars = new ArrayList<>(bars); + bars.sort(Comparator.comparing(Bar::getTime)); + + // Convert bar times -> LocalDate + List dates = new ArrayList<>(); + List close = new ArrayList<>(); + for (Bar b : bars) { + dates.add(b.getTime().atZone(ZoneOffset.UTC).toLocalDate()); + close.add(b.getClose()); + } + + double sentiment = (news != null) ? news.getOverallMarketSentiment() : 0.0; + + // Build supervised dataset + List X = new ArrayList<>(); + List Y = new ArrayList<>(); + List idx = new ArrayList<>(); + + for (int i = 30; i < close.size() - horizon; i++) { + double c0 = close.get(i); + double c1 = close.get(i - 1); + if (c0 <= 0 || c1 <= 0) continue; + + double r1 = (c0 / c1) - 1.0; + double r5 = (close.get(i) / close.get(i - 5)) - 1.0; + double r20 = (close.get(i) / close.get(i - 20)) - 1.0; + double vol10 = stdDevReturns(close, i - 10, i); + + double[] feat = new double[]{r1, r5, r20, vol10, sentiment}; + double future = (close.get(i + horizon) / close.get(i)) - 1.0; + + X.add(feat); + Y.add(future); + idx.add(i); + } + + if (X.size() < 50) { + out.setNote("Not enough usable training rows after feature building."); + return out; + } + + int n = X.size(); + int split = (int) Math.floor(n * (1.0 - testSize)); + split = Math.max(10, Math.min(split, n - 10)); + + if ("lstm".equals(modelType)) { + out.setNote("LSTM not implemented. Use linear_regression or random_forest."); + modelType = "linear_regression"; + out.setModelType(modelType); + } + + // Train (LR) + double[] w = fitLinearRegression(X.subList(0, split), Y.subList(0, split)); + + // Predict on test set + List yTrue = new ArrayList<>(); + List yPred = new ArrayList<>(); + List yDates = new ArrayList<>(); + + for (int j = split; j < n; j++) { + double pred = dot(w, X.get(j)); + yPred.add(pred); + yTrue.add(Y.get(j)); + yDates.add(dates.get(idx.get(j))); + } + + out.setMae(mae(yTrue, yPred)); + out.setRmse(rmse(yTrue, yPred)); + out.setR2(r2(yTrue, yPred)); + out.setAccuracy(directionalAccuracy(yTrue, yPred)); + + // Build chart series (actual vs predicted prices) + for (int k = 0; k < yDates.size(); k++) { + int barIndex = idx.get(split + k); + + double base = close.get(barIndex); + double actualPrice = close.get(barIndex + horizon); + double predictedPrice = base * (1.0 + yPred.get(k)); + + out.getDates().add(yDates.get(k).toString()); + out.getActual().add(round2(actualPrice)); + out.getPredicted().add(round2(predictedPrice)); + } + + // Future forecast from last bar + LocalDate lastDate = dates.get(dates.size() - 1); + double lastClose = close.get(close.size() - 1); + + int i = close.size() - 1; + if (i >= 30) { + double r1 = (close.get(i) / close.get(i - 1)) - 1.0; + double r5 = (close.get(i) / close.get(i - 5)) - 1.0; + double r20 = (close.get(i) / close.get(i - 20)) - 1.0; + double vol10 = stdDevReturns(close, i - 10, i); + + double[] feat = new double[]{r1, r5, r20, vol10, sentiment}; + + double predReturn = dot(w, feat); + + for (int d = 1; d <= horizon; d++) { + LocalDate fd = lastDate.plusDays(d); + out.getFutureDates().add(fd.toString()); + out.getFuturePredictions().add(round2(lastClose * (1.0 + predReturn))); + } + } + + out.setNote("Model trained with lightweight linear regression features."); + return out; + } + + // ----------------- Helpers ----------------- + + private String safeUpper(String s) { + if (s == null) return "SPY"; + s = s.trim(); + if (s.isEmpty()) return "SPY"; + return s.toUpperCase(Locale.ROOT); + } + + private String safeLower(String s) { + if (s == null) return "linear_regression"; + s = s.trim().toLowerCase(Locale.ROOT); + if (s.isEmpty()) return "linear_regression"; + return s; + } + + private double clamp(double v, double lo, double hi) { + return Math.max(lo, Math.min(hi, v)); + } + + // w = (X'X)^-1 X'y + private double[] fitLinearRegression(List X, List y) { + int m = X.size(); + int p = X.get(0).length + 1; + + double[][] XtX = new double[p][p]; + double[] Xty = new double[p]; + + for (int i = 0; i < m; i++) { + double[] row = X.get(i); + + double[] xb = new double[p]; + xb[0] = 1.0; + System.arraycopy(row, 0, xb, 1, p - 1); + + double yi = y.get(i); + + for (int a = 0; a < p; a++) { + Xty[a] += xb[a] * yi; + for (int b = 0; b < p; b++) { + XtX[a][b] += xb[a] * xb[b]; + } + } + } + + return solveGaussian(XtX, Xty); + } + + private double dot(double[] w, double[] x) { + double s = w[0]; + for (int i = 0; i < x.length; i++) { + s += w[i + 1] * x[i]; + } + return s; + } + + private double[] solveGaussian(double[][] A, double[] b) { + int n = b.length; + double[][] M = new double[n][n]; + double[] B = new double[n]; + double[] x = new double[n]; + + for (int i = 0; i < n; i++) { + System.arraycopy(A[i], 0, M[i], 0, n); + B[i] = b[i]; + } + + for (int k = 0; k < n; k++) { + int pivot = k; + for (int i = k + 1; i < n; i++) { + if (Math.abs(M[i][k]) > Math.abs(M[pivot][k])) pivot = i; + } + + if (Math.abs(M[pivot][k]) < 1e-12) { + return new double[n]; + } + + double[] tmp = M[k]; + M[k] = M[pivot]; + M[pivot] = tmp; + + double tB = B[k]; + B[k] = B[pivot]; + B[pivot] = tB; + + for (int i = k + 1; i < n; i++) { + double f = M[i][k] / M[k][k]; + B[i] -= f * B[k]; + for (int j = k; j < n; j++) { + M[i][j] -= f * M[k][j]; + } + } + } + + for (int i = n - 1; i >= 0; i--) { + double sum = B[i]; + for (int j = i + 1; j < n; j++) sum -= M[i][j] * x[j]; + x[i] = sum / M[i][i]; + } + + return x; + } + + private double mae(List y, List p) { + double s = 0; + for (int i = 0; i < y.size(); i++) s += Math.abs(y.get(i) - p.get(i)); + return s / Math.max(1, y.size()); + } + + private double rmse(List y, List p) { + double s = 0; + for (int i = 0; i < y.size(); i++) { + double d = y.get(i) - p.get(i); + s += d * d; + } + return Math.sqrt(s / Math.max(1, y.size())); + } + + private double r2(List y, List p) { + double mean = 0; + for (double v : y) mean += v; + mean /= Math.max(1, y.size()); + + double ssTot = 0, ssRes = 0; + for (int i = 0; i < y.size(); i++) { + double yi = y.get(i); + double pi = p.get(i); + ssTot += (yi - mean) * (yi - mean); + ssRes += (yi - pi) * (yi - pi); + } + + if (ssTot < 1e-12) return 0; + return 1.0 - (ssRes / ssTot); + } + + private double directionalAccuracy(List y, List p) { + int correct = 0; + for (int i = 0; i < y.size(); i++) { + boolean upTrue = y.get(i) >= 0; + boolean upPred = p.get(i) >= 0; + if (upTrue == upPred) correct++; + } + return (double) correct / Math.max(1, y.size()); + } + + private double stdDevReturns(List close, int startIdx, int endIdxInclusive) { + startIdx = Math.max(1, startIdx); + endIdxInclusive = Math.min(endIdxInclusive, close.size() - 1); + + if (endIdxInclusive - startIdx < 2) return 0.0; + + List rets = new ArrayList<>(); + for (int i = startIdx; i <= endIdxInclusive; i++) { + double c0 = close.get(i); + double c1 = close.get(i - 1); + if (c0 > 0 && c1 > 0) rets.add((c0 / c1) - 1.0); + } + + if (rets.size() < 2) return 0.0; + + double mean = 0; + for (double r : rets) mean += r; + mean /= rets.size(); + + double var = 0; + for (double r : rets) { + double d = r - mean; + var += d * d; + } + + var /= (rets.size() - 1); + return Math.sqrt(var); + } + + private double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } +} diff --git a/src/main/java/com/open/spring/mvc/quant/MLTrainResponse.java b/src/main/java/com/open/spring/mvc/quant/MLTrainResponse.java new file mode 100644 index 000000000..c01faa2bb --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/MLTrainResponse.java @@ -0,0 +1,40 @@ +package com.open.spring.mvc.quant; + +import java.util.ArrayList; +import java.util.List; + +import lombok.Data; + +/** + * Response payload for: + * POST /bank/quant/ml/train + * + * Mirrors the main things your Streamlit app shows: + * - accuracy-ish metrics (not perfect finance metrics, but consistent) + * - actual vs predicted series for plotting + * - future predictions for horizon days + */ +@Data +public class MLTrainResponse { + + private String ticker; + private String modelType; + + // Metrics + private double accuracy; // directional accuracy (up/down) + private double mae; + private double rmse; + private double r2; + + // Plot series (aligned) + private List dates = new ArrayList<>(); + private List actual = new ArrayList<>(); + private List predicted = new ArrayList<>(); + + // Future forecast + private List futureDates = new ArrayList<>(); + private List futurePredictions = new ArrayList<>(); + + // Debug/notes + private String note; +} diff --git a/src/main/java/com/open/spring/mvc/quant/MarketDataService.java b/src/main/java/com/open/spring/mvc/quant/MarketDataService.java new file mode 100644 index 000000000..dbd986e95 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/MarketDataService.java @@ -0,0 +1,586 @@ +package com.open.spring.mvc.quant; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import org.springframework.web.util.UriComponentsBuilder; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.core.io.ClassPathResource; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Pulls historical daily OHLCV from Alpha Vantage. + * + * Uses: + * - TIME_SERIES_DAILY (free tier daily OHLCV) + * + * Config: + * - env var `ALPHAVANTAGE_API_KEY` or Spring property `alphavantage.apiKey` + */ +@Service +public class MarketDataService { + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * Request-level caches (mirrors the "in-memory cache + Streamlit cache" idea). + * Keyed by: SYMBOL|START|END + */ + private static final long MEM_TTL_MS = 10 * 60 * 1000L; // 10 minutes + private static final long STREAMLIT_TTL_MS = 60 * 60 * 1000L; // 1 hour + private final Map memRangeCache = new ConcurrentHashMap<>(); + private final Map streamlitRangeCache = new ConcurrentHashMap<>(); + + private static class RangeCacheEntry { + final long expiresAtMs; + final List bars; + RangeCacheEntry(long expiresAtMs, List bars) { + this.expiresAtMs = expiresAtMs; + this.bars = bars; + } + } + + /** + * Alpha Vantage free tier is rate-limited. Cache the most recent successful series per symbol + * so multiple endpoints (history/indicators/ml/backtest) don't re-hit AV repeatedly. + */ + private static final long CACHE_TTL_MS = 6 * 60 * 60 * 1000L; // 6 hours + private final Map seriesCache = new ConcurrentHashMap<>(); + + private static class CacheEntry { + final long fetchedAtMs; + final List bars; // sorted ascending + CacheEntry(long fetchedAtMs, List bars) { + this.fetchedAtMs = fetchedAtMs; + this.bars = bars; + } + } + + /** + * Alpha Vantage API key. + * Provide via env var `ALPHAVANTAGE_API_KEY` or Spring property `alphavantage.apiKey`. + */ + @Value("${alphavantage.apiKey:${ALPHAVANTAGE_API_KEY:}}") + private String alphaVantageApiKey; + + /** + * Market data provider selection. + * - auto (default): try Alpha Vantage if configured; fall back to Yahoo Finance on throttles/errors + * - yahoo: always use Yahoo Finance (no API key, unofficial endpoint) + * - alphavantage: always use Alpha Vantage (requires key, rate-limited) + */ + @Value("${market.provider:auto}") + private String marketProvider; + + @Value("${market.local.enabled:true}") + private boolean localFallbackEnabled; + + public List getDailyBars(String ticker, LocalDate start, LocalDate end) { + String sym = (ticker == null ? "" : ticker.trim().toUpperCase(Locale.ROOT)); + if (sym.isBlank()) throw new IllegalArgumentException("ticker is required"); + if (start == null || end == null) throw new IllegalArgumentException("start/end are required"); + if (end.isBefore(start)) throw new IllegalArgumentException("end must be >= start"); + + // 1) in-memory cache (range-specific) + String rangeKey = sym + "|" + start + "|" + end; + List memHit = getIfFresh(memRangeCache, rangeKey); + if (memHit != null) return memHit; + + // 2) Streamlit-like cache (range-specific, 1h TTL) + List stHit = getIfFresh(streamlitRangeCache, rangeKey); + if (stHit != null) { + put(memRangeCache, rangeKey, stHit, MEM_TTL_MS); + return stHit; + } + + String key = alphaVantageApiKey == null ? "" : alphaVantageApiKey.trim(); + String provider = (marketProvider == null ? "auto" : marketProvider.trim().toLowerCase(Locale.ROOT)); + + // Try cache first (fresh within TTL) + CacheEntry cached = seriesCache.get(sym); + long now = System.currentTimeMillis(); + if (cached != null && (now - cached.fetchedAtMs) < CACHE_TTL_MS) { + return filterRange(cached.bars, start, end); + } + + // Provider choice + if ("yahoo".equals(provider)) { + try { + List yahoo = fetchYahooLayered(sym, start, end); + if (!yahoo.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), yahoo)); + } + List out = filterRange(yahoo, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } catch (Exception e) { + if (localFallbackEnabled) { + List local = fetchFromLocalCsv(sym); + if (!local.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), local)); + List out = filterRange(local, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } + } + throw e; + } + } + + if ("alphavantage".equals(provider) || "auto".equals(provider)) { + if (key.isBlank()) { + if ("alphavantage".equals(provider)) { + throw new IllegalStateException( + "Missing Alpha Vantage API key. Set env ALPHAVANTAGE_API_KEY (or property alphavantage.apiKey) on the Spring server." + ); + } + // auto mode with no key -> use Yahoo + try { + List yahoo = fetchYahooLayered(sym, start, end); + if (!yahoo.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), yahoo)); + } + List out = filterRange(yahoo, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } catch (Exception e) { + if (localFallbackEnabled) { + List local = fetchFromLocalCsv(sym); + if (!local.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), local)); + List out = filterRange(local, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } + } + throw e; + } + } + } + + // Alpha Vantage returns latest first; we'll sort ascending at the end. + String url = UriComponentsBuilder + .fromHttpUrl("https://www.alphavantage.co/query") + .queryParam("function", "TIME_SERIES_DAILY") + .queryParam("symbol", sym) + // Free tier: omit outputsize=full (premium). Defaults to compact (~100 most recent points). + .queryParam("apikey", key) + .toUriString(); + + String json = restTemplate.getForObject(url, String.class); + if (json == null || json.isBlank()) return List.of(); + + JsonNode root; + try { + root = objectMapper.readTree(json); + } catch (Exception e) { + throw new IllegalStateException("Failed to parse Alpha Vantage response", e); + } + + // Error / throttle responses + if (root.hasNonNull("Error Message")) { + if ("auto".equals(provider)) { + try { + List yahoo = fetchYahooLayered(sym, start, end); + if (!yahoo.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), yahoo)); + List out = filterRange(yahoo, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } + } catch (Exception ignored) { + // fall through to local fallback below + } + if (localFallbackEnabled) { + List local = fetchFromLocalCsv(sym); + if (!local.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), local)); + List out = filterRange(local, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } + } + } + throw new IllegalStateException("Alpha Vantage error: " + root.get("Error Message").asText()); + } + if (root.hasNonNull("Note")) { + // Rate-limited: fall back to last cached data if available + CacheEntry fallback = seriesCache.get(sym); + if (fallback != null && fallback.bars != null && !fallback.bars.isEmpty()) { + return filterRange(fallback.bars, start, end); + } + if ("auto".equals(provider)) { + try { + List yahoo = fetchYahooLayered(sym, start, end); + if (!yahoo.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), yahoo)); + List out = filterRange(yahoo, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } + } catch (Exception ignored) {} + if (localFallbackEnabled) { + List local = fetchFromLocalCsv(sym); + if (!local.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), local)); + List out = filterRange(local, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } + } + } + throw new IllegalStateException("Alpha Vantage throttle: " + root.get("Note").asText()); + } + if (root.hasNonNull("Information")) { + // Some "Information" responses are rate-limit messaging on certain keys. + CacheEntry fallback = seriesCache.get(sym); + if (fallback != null && fallback.bars != null && !fallback.bars.isEmpty()) { + return filterRange(fallback.bars, start, end); + } + if ("auto".equals(provider)) { + try { + List yahoo = fetchYahooLayered(sym, start, end); + if (!yahoo.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), yahoo)); + List out = filterRange(yahoo, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } + } catch (Exception ignored) {} + if (localFallbackEnabled) { + List local = fetchFromLocalCsv(sym); + if (!local.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), local)); + List out = filterRange(local, start, end); + put(memRangeCache, rangeKey, out, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, out, STREAMLIT_TTL_MS); + return out; + } + } + } + throw new IllegalStateException("Alpha Vantage info: " + root.get("Information").asText()); + } + + JsonNode series = root.get("Time Series (Daily)"); + if (series == null || !series.isObject()) { + return List.of(); + } + + List out = new ArrayList<>(); + series.fields().forEachRemaining(entry -> { + try { + LocalDate d = LocalDate.parse(entry.getKey()); + JsonNode row = entry.getValue(); + double open = row.path("1. open").asDouble(Double.NaN); + double high = row.path("2. high").asDouble(Double.NaN); + double low = row.path("3. low").asDouble(Double.NaN); + double close = row.path("4. close").asDouble(Double.NaN); + long volume = row.path("5. volume").asLong(0); + + if (!Double.isFinite(close) || close <= 0) return; + Instant t = d.atStartOfDay().toInstant(ZoneOffset.UTC); + out.add(new Bar(sym, t, open, high, low, close, volume, "1d")); + } catch (Exception ignored) { + // skip malformed rows + } + }); + + out.sort(Comparator.comparing(Bar::getTime)); + // Cache full compact series; then filter for the requested range + if (!out.isEmpty()) { + seriesCache.put(sym, new CacheEntry(System.currentTimeMillis(), out)); + } + List finalOut = filterRange(out, start, end); + put(memRangeCache, rangeKey, finalOut, MEM_TTL_MS); + put(streamlitRangeCache, rangeKey, finalOut, STREAMLIT_TTL_MS); + return finalOut; + } + + private List fetchYahooLayered(String symbol, LocalDate start, LocalDate end) { + // 3) exact epoch range + try { + List bars = fetchYahooByEpochRange(symbol, start, end); + if (!bars.isEmpty()) return bars; + } catch (Exception ignored) {} + + // 4) period/range then trim + try { + String range = toYahooRange(start, end); + List bars = fetchYahooByRange(symbol, range); + List trimmed = filterRange(bars, start, end); + if (!trimmed.isEmpty()) return trimmed; + } catch (Exception ignored) {} + + // 5) fallback 1y then trim + try { + List bars = fetchYahooByRange(symbol, "1y"); + List trimmed = filterRange(bars, start, end); + if (!trimmed.isEmpty()) return trimmed; + } catch (Exception ignored) {} + + // 6) alternate download CSV endpoint then trim + try { + List bars = fetchYahooDownloadCsv(symbol, start, end); + List trimmed = filterRange(bars, start, end); + if (!trimmed.isEmpty()) return trimmed; + } catch (Exception ignored) {} + + // then optional local CSV + if (localFallbackEnabled) { + List local = fetchFromLocalCsv(symbol); + if (!local.isEmpty()) return local; + } + + throw new IllegalStateException("Yahoo Finance fetch failed (all fallback methods exhausted)"); + } + + private String toYahooRange(LocalDate start, LocalDate end) { + long days = Math.max(1, ChronoUnit.DAYS.between(start, end) + 1); + if (days <= 31) return "3mo"; + if (days <= 93) return "6mo"; + if (days <= 186) return "1y"; + if (days <= 365) return "2y"; + return "5y"; + } + + private List fetchYahooByEpochRange(String symbol, LocalDate start, LocalDate end) throws Exception { + long period1 = start.atStartOfDay().toEpochSecond(ZoneOffset.UTC); + long period2 = end.plusDays(1).atStartOfDay().toEpochSecond(ZoneOffset.UTC); + String[] hosts = new String[] {"https://query2.finance.yahoo.com", "https://query1.finance.yahoo.com"}; + for (String host : hosts) { + String url = UriComponentsBuilder + .fromHttpUrl(host + "/v8/finance/chart/" + symbol) + .queryParam("interval", "1d") + .queryParam("period1", period1) + .queryParam("period2", period2) + .toUriString(); + List out = yahooRequestAndParseChart(url, symbol); + if (!out.isEmpty()) return out; + } + return List.of(); + } + + private List fetchYahooByRange(String symbol, String range) throws Exception { + String[] hosts = new String[] {"https://query2.finance.yahoo.com", "https://query1.finance.yahoo.com"}; + for (String host : hosts) { + String url = UriComponentsBuilder + .fromHttpUrl(host + "/v8/finance/chart/" + symbol) + .queryParam("interval", "1d") + .queryParam("range", range) + .toUriString(); + List out = yahooRequestAndParseChart(url, symbol); + if (!out.isEmpty()) return out; + } + return List.of(); + } + + private List yahooRequestAndParseChart(String url, String symbol) throws Exception { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + headers.set("User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + String json = resp.getBody(); + if (json == null || json.isBlank()) return List.of(); + JsonNode root = objectMapper.readTree(json); + return parseYahooChart(root, symbol); + } + + private List fetchYahooDownloadCsv(String symbol, LocalDate start, LocalDate end) throws Exception { + long period1 = start.atStartOfDay().toEpochSecond(ZoneOffset.UTC); + long period2 = end.plusDays(1).atStartOfDay().toEpochSecond(ZoneOffset.UTC); + + String url = UriComponentsBuilder + .fromHttpUrl("https://query1.finance.yahoo.com/v7/finance/download/" + urlEncode(symbol)) + .queryParam("period1", period1) + .queryParam("period2", period2) + .queryParam("interval", "1d") + .queryParam("events", "history") + .queryParam("includeAdjustedClose", "true") + .toUriString(); + + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(List.of(MediaType.TEXT_PLAIN, MediaType.ALL)); + headers.set("User-Agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"); + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity resp = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); + String csv = resp.getBody(); + if (csv == null || csv.isBlank()) return List.of(); + return parseYahooDownloadCsv(csv, symbol); + } + + private String urlEncode(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } + + private List parseYahooDownloadCsv(String csv, String symbol) throws Exception { + List out = new ArrayList<>(); + try (BufferedReader br = new BufferedReader(new StringReader(csv))) { + String header = br.readLine(); + if (header == null) return List.of(); + String line; + while ((line = br.readLine()) != null) { + if (line.contains("null")) continue; + String[] p = line.split(","); + if (p.length < 7) continue; + LocalDate d = LocalDate.parse(p[0].trim()); + Instant t = d.atStartOfDay().toInstant(ZoneOffset.UTC); + double o = parseDouble(p[1]); + double h = parseDouble(p[2]); + double l = parseDouble(p[3]); + double c = parseDouble(p[4]); + long v = parseLong(p[6]); + if (!Double.isFinite(c) || c <= 0) continue; + out.add(new Bar(symbol, t, o, h, l, c, v, "1d")); + } + } + out.sort(Comparator.comparing(Bar::getTime)); + return out; + } + + private List parseYahooChart(JsonNode root, String symbol) { + try { + JsonNode result0 = root.path("chart").path("result"); + if (!result0.isArray() || result0.isEmpty()) return List.of(); + JsonNode r = result0.get(0); + + JsonNode ts = r.path("timestamp"); + JsonNode quote0 = r.path("indicators").path("quote"); + if (!quote0.isArray() || quote0.isEmpty()) return List.of(); + JsonNode q = quote0.get(0); + + JsonNode open = q.path("open"); + JsonNode high = q.path("high"); + JsonNode low = q.path("low"); + JsonNode close = q.path("close"); + JsonNode vol = q.path("volume"); + + if (!ts.isArray()) return List.of(); + + List out = new ArrayList<>(); + for (int i = 0; i < ts.size(); i++) { + long epochSec = ts.get(i).asLong(0); + if (epochSec <= 0) continue; + + double o = open.path(i).isNumber() ? open.get(i).asDouble() : Double.NaN; + double h = high.path(i).isNumber() ? high.get(i).asDouble() : Double.NaN; + double l = low.path(i).isNumber() ? low.get(i).asDouble() : Double.NaN; + double c = close.path(i).isNumber() ? close.get(i).asDouble() : Double.NaN; + long v = vol.path(i).isNumber() ? vol.get(i).asLong() : 0L; + + if (!Double.isFinite(c) || c <= 0) continue; + Instant t = Instant.ofEpochSecond(epochSec); + out.add(new Bar(symbol, t, o, h, l, c, v, "1d")); + } + + out.sort(Comparator.comparing(Bar::getTime)); + return out; + } catch (Exception e) { + throw new IllegalStateException("Yahoo Finance parse failed", e); + } + } + + private List fetchFromLocalCsv(String symbol) { + // Looks for: src/main/resources/market-data/.csv on the classpath + String path = "market-data/" + symbol.toUpperCase(Locale.ROOT) + ".csv"; + ClassPathResource res = new ClassPathResource(path); + if (!res.exists()) return List.of(); + + List out = new ArrayList<>(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(res.getInputStream()))) { + String header = br.readLine(); // Date,Open,High,Low,Close,Volume + if (header == null) return List.of(); + + String line; + while ((line = br.readLine()) != null) { + String[] p = line.split(","); + if (p.length < 6) continue; + LocalDate d = LocalDate.parse(p[0].trim()); + Instant t = d.atStartOfDay().toInstant(ZoneOffset.UTC); + double o = parseDouble(p[1]); + double h = parseDouble(p[2]); + double l = parseDouble(p[3]); + double c = parseDouble(p[4]); + long v = parseLong(p[5]); + if (!Double.isFinite(c) || c <= 0) continue; + out.add(new Bar(symbol.toUpperCase(Locale.ROOT), t, o, h, l, c, v, "1d")); + } + } catch (Exception e) { + throw new IllegalStateException("Local CSV parse failed for " + path, e); + } + + out.sort(Comparator.comparing(Bar::getTime)); + return out; + } + + private double parseDouble(String s) { + try { return Double.parseDouble(s.trim()); } catch (Exception e) { return Double.NaN; } + } + + private long parseLong(String s) { + try { return Long.parseLong(s.trim()); } catch (Exception e) { return 0L; } + } + + private List getIfFresh(Map cache, String key) { + RangeCacheEntry e = cache.get(key); + if (e == null) return null; + if (System.currentTimeMillis() > e.expiresAtMs) { + cache.remove(key); + return null; + } + return e.bars; + } + + private void put(Map cache, String key, List bars, long ttlMs) { + cache.put(key, new RangeCacheEntry(System.currentTimeMillis() + ttlMs, bars)); + } + + private List filterRange(List bars, LocalDate start, LocalDate end) { + if (bars == null || bars.isEmpty()) return List.of(); + List filtered = new ArrayList<>(); + for (Bar b : bars) { + LocalDate d = b.getTime().atZone(ZoneOffset.UTC).toLocalDate(); + if ((d.isEqual(start) || d.isAfter(start)) && (d.isEqual(end) || d.isBefore(end))) { + filtered.add(b); + } + } + filtered.sort(Comparator.comparing(Bar::getTime)); + return filtered; + } +} diff --git a/src/main/java/com/open/spring/mvc/quant/NewsService.java b/src/main/java/com/open/spring/mvc/quant/NewsService.java new file mode 100644 index 000000000..4e7041690 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/NewsService.java @@ -0,0 +1,123 @@ +package com.open.spring.mvc.quant; + +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.*; + +/** + * Minimal news + sentiment service that fits the Bank API. + * + * What it does (server-side): + * - Provides a "sentiment snapshot" payload: + * overall_market_sentiment + * category sentiment: economic / geopolitical / social + * news_volume_24h + * negative_news_ratio + * recent headlines list + * + * IMPORTANT: + * - This is intentionally "stubbed" so your backend compiles and your frontend has real endpoints. + * - Later you can swap the internals to call a real provider (NewsAPI, GDELT, Finnhub, etc.) + * without changing BankApiController. + */ +@Service +public class NewsService { + + // Simple in-memory cache so you don't recompute on every request + private final Map cache = new HashMap<>(); + private final Map cacheTimeMs = new HashMap<>(); + private static final long TTL_MS = 60_000; // 60s + + public SentimentSnapshot getSentimentSnapshot(String ticker) { + if (ticker == null || ticker.isBlank()) ticker = "SPY"; + ticker = ticker.toUpperCase(Locale.ROOT); + + long now = System.currentTimeMillis(); + Long last = cacheTimeMs.get(ticker); + if (last != null && (now - last) < TTL_MS) { + return cache.get(ticker); + } + + // ---- STUBBED NEWS ---- + // If you later add a real news provider, replace buildStubSnapshot(...) only. + SentimentSnapshot snap = buildStubSnapshot(ticker); + + cache.put(ticker, snap); + cacheTimeMs.put(ticker, now); + return snap; + } + + private SentimentSnapshot buildStubSnapshot(String ticker) { + // Deterministic "random" based on ticker so it looks stable per symbol + long seed = Math.abs(ticker.hashCode()); + Random r = new Random(seed ^ (System.currentTimeMillis() / (5 * 60_000))); // changes every ~5 min + + // Sentiments in [-1, 1] + double overall = clamp((r.nextDouble() * 2) - 1, -1, 1); + double econ = clamp(overall * 0.6 + ((r.nextDouble() * 2) - 1) * 0.4, -1, 1); + double geo = clamp(overall * 0.4 + ((r.nextDouble() * 2) - 1) * 0.6, -1, 1); + double soc = clamp(overall * 0.5 + ((r.nextDouble() * 2) - 1) * 0.5, -1, 1); + + int volume = 10 + r.nextInt(40); // 10..49 + double negRatio = clamp(0.2 + r.nextDouble() * 0.5, 0, 1); + + List headlines = new ArrayList<>(); + headlines.add(new Headline(nowIso(), ticker + " market update: mixed signals as volume shifts", overall)); + headlines.add(new Headline(nowIso(), "Macro watch: rates and inflation expectations influence " + ticker, econ)); + headlines.add(new Headline(nowIso(), "Geopolitics: risk sentiment swings across equities", geo)); + headlines.add(new Headline(nowIso(), "Sector rotation: investors reposition around " + ticker, overall)); + headlines.add(new Headline(nowIso(), "Social sentiment: retail chatter ticks " + (soc >= 0 ? "up" : "down"), soc)); + + Map categories = new LinkedHashMap<>(); + categories.put("economic", econ); + categories.put("geopolitical", geo); + categories.put("social", soc); + + SentimentSnapshot snap = new SentimentSnapshot(); + snap.setTicker(ticker); + snap.setGeneratedAt(nowIso()); + snap.setOverallMarketSentiment(overall); + snap.setNewsVolume24h(volume); + snap.setNegativeNewsRatio(negRatio); + snap.setCategorySentiment(categories); + snap.setHeadlines(headlines); + + // short plain-English summary for the frontend to show + snap.setSummary(buildSummary(snap)); + return snap; + } + + private String buildSummary(SentimentSnapshot s) { + String dir = s.getOverallMarketSentiment() > 0.15 ? "positive" + : s.getOverallMarketSentiment() < -0.15 ? "negative" + : "neutral"; + + String worstCat = null; + double worst = Double.POSITIVE_INFINITY; + + for (Map.Entry e : s.getCategorySentiment().entrySet()) { + if (e.getValue() < worst) { + worst = e.getValue(); + worstCat = e.getKey(); + } + } + + return "News sentiment for " + s.getTicker() + " is " + dir + + " (score " + round3(s.getOverallMarketSentiment()) + "). " + + "Most negative category: " + worstCat + " (" + round3(worst) + "). " + + "Volume last 24h: " + s.getNewsVolume24h() + "."; + } + + private String nowIso() { + return Instant.now().toString(); + } + + private double clamp(double v, double lo, double hi) { + return Math.max(lo, Math.min(hi, v)); + } + + private String round3(double v) { + return String.format(Locale.US, "%.3f", v); + } +} diff --git a/src/main/java/com/open/spring/mvc/quant/PaperPortfolio.java b/src/main/java/com/open/spring/mvc/quant/PaperPortfolio.java new file mode 100644 index 000000000..81b184d32 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/PaperPortfolio.java @@ -0,0 +1,39 @@ +package com.open.spring.mvc.quant; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import lombok.Data; + +@Data +public class PaperPortfolio { + + private double cashBalance; + + // ticker -> position + private Map positions = new HashMap<>(); + + private List orders = new ArrayList<>(); + + @Data + public static class Position { + private String ticker; + private int qty; + private double avgCost; + private double marketPrice; + private double marketValue; + private double unrealizedPnL; + } + + @Data + public static class OrderRecord { + private String time; + private String ticker; + private String side; + private int qty; + private double price; + private double totalCost; + } +} diff --git a/src/main/java/com/open/spring/mvc/quant/PaperTradeService.java b/src/main/java/com/open/spring/mvc/quant/PaperTradeService.java new file mode 100644 index 000000000..c6fa34561 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/PaperTradeService.java @@ -0,0 +1,223 @@ +package com.open.spring.mvc.quant; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import com.open.spring.mvc.bank.Bank; +import com.open.spring.mvc.bank.BankApiController; + +/** + * Paper trading engine that integrates directly with Bank money. + * + * - Uses Bank.balance as cash + * - Stores portfolio positions in memory (simple) + * - Uses MarketDataService to get current price + * + * IMPORTANT: + * This is not persistent across server restart (intended for demo + school project). + */ +@Service +public class PaperTradeService { + + private final MarketDataService marketDataService; + + // personId -> portfolio state + private final Map portfolios = new HashMap<>(); + + public PaperTradeService(MarketDataService marketDataService) { + this.marketDataService = marketDataService; + } + + public Map placeOrder(Bank bank, BankApiController.PaperOrderRequest req) { + if (bank == null) return Map.of("success", false, "error", "Bank is null"); + if (req == null) return Map.of("success", false, "error", "Missing request body"); + if (req.personId == null) return Map.of("success", false, "error", "Missing personId"); + if (req.ticker == null || req.ticker.isBlank()) return Map.of("success", false, "error", "Missing ticker"); + if (req.qty <= 0) return Map.of("success", false, "error", "qty must be > 0"); + + String ticker = req.ticker.trim().toUpperCase(Locale.ROOT); + String side = (req.side == null) ? "" : req.side.trim().toLowerCase(Locale.ROOT); + + if (!side.equals("buy") && !side.equals("sell")) { + return Map.of("success", false, "error", "side must be 'buy' or 'sell'"); + } + + double price = getLatestPrice(ticker); + if (price <= 0) { + return Map.of("success", false, "error", "Could not fetch market price for " + ticker); + } + + PaperPortfolio portfolio = portfolios.computeIfAbsent(req.personId, k -> new PaperPortfolio()); + + // always sync portfolio cash from bank balance + portfolio.setCashBalance(bank.getBalance()); + + if (side.equals("buy")) { + return handleBuy(bank, portfolio, ticker, req.qty, price); + } else { + return handleSell(bank, portfolio, ticker, req.qty, price); + } + } + + public PaperPortfolio getPortfolio(Bank bank) { + PaperPortfolio empty = new PaperPortfolio(); + if (bank == null) return empty; + if (bank.getPerson() == null) return empty; + if (bank.getPerson().getId() == null) return empty; + + Long personId = bank.getPerson().getId(); + + PaperPortfolio portfolio = portfolios.computeIfAbsent(personId, k -> new PaperPortfolio()); + + // sync cash from bank + portfolio.setCashBalance(bank.getBalance()); + + // update market values + for (PaperPortfolio.Position pos : portfolio.getPositions().values()) { + double price = getLatestPrice(pos.getTicker()); + pos.setMarketPrice(round2(price)); + pos.setMarketValue(round2(price * pos.getQty())); + pos.setUnrealizedPnL(round2((price - pos.getAvgCost()) * pos.getQty())); + } + + return portfolio; + } + + // ------------------- Internal Buy/Sell ------------------- + + private Map handleBuy(Bank bank, PaperPortfolio portfolio, String ticker, int qty, double price) { + double cost = qty * price; + + if (bank.getBalance() < cost) { + return Map.of( + "success", false, + "error", "Insufficient funds", + "needed", round2(cost), + "balance", round2(bank.getBalance()) + ); + } + + // subtract money + bank.setBalance(bank.getBalance() - cost, "paper_trade_buy"); + portfolio.setCashBalance(bank.getBalance()); + + PaperPortfolio.Position pos = portfolio.getPositions().getOrDefault(ticker, new PaperPortfolio.Position()); + pos.setTicker(ticker); + + int oldQty = pos.getQty(); + double oldAvg = pos.getAvgCost(); + + int newQty = oldQty + qty; + double newAvg = (oldQty == 0) ? price : ((oldQty * oldAvg) + (qty * price)) / newQty; + + pos.setQty(newQty); + pos.setAvgCost(round2(newAvg)); + pos.setMarketPrice(round2(price)); + pos.setMarketValue(round2(price * newQty)); + pos.setUnrealizedPnL(round2((price - newAvg) * newQty)); + + portfolio.getPositions().put(ticker, pos); + + PaperPortfolio.OrderRecord rec = new PaperPortfolio.OrderRecord(); + rec.setTime(Instant.now().toString()); + rec.setTicker(ticker); + rec.setSide("BUY"); + rec.setQty(qty); + rec.setPrice(round2(price)); + rec.setTotalCost(round2(cost)); + portfolio.getOrders().add(rec); + + return Map.of( + "success", true, + "message", "Bought " + qty + " shares of " + ticker, + "ticker", ticker, + "qty", qty, + "price", round2(price), + "balance", round2(bank.getBalance()) + ); + } + + private Map handleSell(Bank bank, PaperPortfolio portfolio, String ticker, int qty, double price) { + PaperPortfolio.Position pos = portfolio.getPositions().get(ticker); + + if (pos == null || pos.getQty() <= 0) { + return Map.of("success", false, "error", "No shares owned for " + ticker); + } + + if (qty > pos.getQty()) { + return Map.of( + "success", false, + "error", "Not enough shares", + "owned", pos.getQty(), + "attempted", qty + ); + } + + double proceeds = qty * price; + + // add money + bank.setBalance(bank.getBalance() + proceeds, "paper_trade_sell"); + portfolio.setCashBalance(bank.getBalance()); + + int remaining = pos.getQty() - qty; + pos.setQty(remaining); + + pos.setMarketPrice(round2(price)); + pos.setMarketValue(round2(price * remaining)); + pos.setUnrealizedPnL(round2((price - pos.getAvgCost()) * remaining)); + + if (remaining == 0) { + portfolio.getPositions().remove(ticker); + } else { + portfolio.getPositions().put(ticker, pos); + } + + PaperPortfolio.OrderRecord rec = new PaperPortfolio.OrderRecord(); + rec.setTime(Instant.now().toString()); + rec.setTicker(ticker); + rec.setSide("SELL"); + rec.setQty(qty); + rec.setPrice(round2(price)); + rec.setTotalCost(round2(proceeds)); + portfolio.getOrders().add(rec); + + return Map.of( + "success", true, + "message", "Sold " + qty + " shares of " + ticker, + "ticker", ticker, + "qty", qty, + "price", round2(price), + "balance", round2(bank.getBalance()) + ); + } + + // ------------------- Price Fetch ------------------- + + private double getLatestPrice(String ticker) { + try { + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(30); + + List bars = marketDataService.getDailyBars(ticker, start, end); + if (bars == null || bars.isEmpty()) return -1; + + bars.sort(Comparator.comparing(Bar::getTime)); + Bar last = bars.get(bars.size() - 1); + + return last.getClose(); + } catch (Exception e) { + return -1; + } + } + + private double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } +} diff --git a/src/main/java/com/open/spring/mvc/quant/SentimentSnapshot.java b/src/main/java/com/open/spring/mvc/quant/SentimentSnapshot.java new file mode 100644 index 000000000..ba8b8e421 --- /dev/null +++ b/src/main/java/com/open/spring/mvc/quant/SentimentSnapshot.java @@ -0,0 +1,32 @@ +package com.open.spring.mvc.quant; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * JSON payload returned by /bank/quant/news/sentiment + */ +@Data +public class SentimentSnapshot { + private String ticker; + private String generatedAt; + + // [-1, 1] + private double overallMarketSentiment; + + // last ~24h (stubbed) + private int newsVolume24h; + + // [0,1] + private double negativeNewsRatio; + + // economic/geopolitical/social -> [-1,1] + private Map categorySentiment; + + private List headlines; + + // plain English string for frontend + private String summary; +} diff --git a/src/main/java/com/open/spring/security/MvcSecurityConfig.java b/src/main/java/com/open/spring/security/MvcSecurityConfig.java index aa1cef249..8d8566aa2 100644 --- a/src/main/java/com/open/spring/security/MvcSecurityConfig.java +++ b/src/main/java/com/open/spring/security/MvcSecurityConfig.java @@ -105,6 +105,7 @@ public SecurityFilterChain mvcSecurityFilterChain(HttpSecurity http) throws Exce .requestMatchers("/mvc/grades/**").hasAuthority("ROLE_ADMIN") .requestMatchers("/mvc/assignments/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") .requestMatchers("/mvc/bank/read").hasAuthority("ROLE_ADMIN") + .requestMatchers("/bank/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/ws-chat/**").permitAll() .requestMatchers("/mvc/progress/read").hasAnyAuthority("ROLE_ADMIN", "ROLE_TEACHER") .requestMatchers("/ws-chat/**").permitAll() diff --git a/src/main/resources/market-data/AAPL.csv b/src/main/resources/market-data/AAPL.csv new file mode 100644 index 000000000..92c645375 --- /dev/null +++ b/src/main/resources/market-data/AAPL.csv @@ -0,0 +1,139 @@ +Date,Open,High,Low,Close,Volume +2025-12-01,190.00,191.20,189.40,190.80,50234123 +2025-12-02,190.80,192.00,190.10,191.60,48712011 +2025-12-03,191.60,192.40,190.90,191.10,46890544 +2025-12-04,191.10,191.70,189.90,190.30,51234990 +2025-12-05,190.30,191.00,189.50,190.70,45512003 +2025-12-06,190.70,191.50,190.10,191.20,31245001 +2025-12-07,191.20,192.10,190.80,191.90,29877123 +2025-12-08,191.90,193.00,191.40,192.60,40123010 +2025-12-09,192.60,193.40,191.90,192.10,38912098 +2025-12-10,192.10,192.70,190.80,191.30,42077111 +2025-12-11,191.30,192.20,190.90,191.90,37765009 +2025-12-12,191.90,192.60,191.10,191.40,36522044 +2025-12-13,191.40,192.00,190.40,190.90,28011009 +2025-12-14,190.90,191.70,190.30,191.20,27100998 +2025-12-15,191.20,192.50,190.90,192.10,39211045 +2025-12-16,192.10,193.20,191.70,192.80,41022011 +2025-12-17,192.80,193.70,192.10,193.40,39877002 +2025-12-18,193.40,194.10,192.60,193.10,42188122 +2025-12-19,193.10,194.20,192.80,193.90,43677018 +2025-12-20,193.90,195.00,193.40,194.60,45011002 +2025-12-21,194.60,195.10,193.70,194.10,30022045 +2025-12-22,194.10,194.90,193.20,194.70,38011987 +2025-12-23,194.70,195.30,194.00,194.40,36255001 +2025-12-24,194.40,195.00,193.60,194.80,28034000 +2025-12-25,194.80,195.60,194.10,195.20,21000000 +2025-12-26,195.20,196.10,194.80,195.70,32045000 +2025-12-27,195.70,196.40,195.10,195.90,26011000 +2025-12-28,195.90,196.80,195.40,196.50,31099000 +2025-12-29,196.50,197.20,195.90,196.10,29022000 +2025-12-30,196.10,196.70,195.00,195.60,34011000 +2025-12-31,195.60,196.30,194.80,195.10,35588000 +2026-01-01,195.10,195.80,194.20,194.90,18000000 +2026-01-02,194.90,196.00,194.40,195.70,40233000 +2026-01-03,195.70,197.10,195.30,196.80,38812000 +2026-01-04,196.80,197.60,196.10,196.30,32011000 +2026-01-05,196.30,197.20,195.50,196.90,41005000 +2026-01-06,196.90,198.00,196.40,197.60,39522000 +2026-01-07,197.60,198.50,196.80,197.10,37011900 +2026-01-08,197.10,198.20,196.30,197.90,42220000 +2026-01-09,197.90,199.10,197.40,198.70,40123000 +2026-01-10,198.70,199.30,197.80,198.10,38001000 +2026-01-11,198.10,198.90,197.20,198.60,35222000 +2026-01-12,198.60,199.60,198.10,199.20,41030000 +2026-01-13,199.20,200.10,198.70,199.90,40511000 +2026-01-14,199.90,200.80,199.30,200.40,39822000 +2026-01-15,200.40,201.20,199.80,200.10,39011000 +2026-01-16,200.10,201.00,199.40,200.70,41220000 +2026-01-17,200.70,201.60,200.20,201.30,40110000 +2026-01-18,201.30,202.40,200.90,202.10,42020000 +2026-01-19,202.10,202.80,201.30,201.90,37012000 +2026-01-20,201.90,202.60,201.00,201.40,36011000 +2026-01-21,201.40,202.10,200.60,201.80,35522000 +2026-01-22,201.80,203.00,201.40,202.70,41022000 +2026-01-23,202.70,203.50,202.00,202.20,39811000 +2026-01-24,202.20,203.10,201.60,202.90,37222000 +2026-01-25,202.90,204.00,202.40,203.60,40123000 +2026-01-26,203.60,204.50,203.00,203.20,39012000 +2026-01-27,203.20,204.10,202.30,203.80,41034000 +2026-01-28,203.80,205.00,203.50,204.60,42011000 +2026-01-29,204.60,205.40,203.90,204.20,38022000 +2026-01-30,204.20,205.10,203.60,204.90,39911000 +2026-01-31,204.90,206.00,204.40,205.70,41005000 +2026-02-01,205.70,206.50,205.10,205.30,30011000 +2026-02-02,205.30,206.20,204.60,205.90,35022000 +2026-02-03,205.90,207.10,205.40,206.80,42033000 +2026-02-04,206.80,207.50,205.90,206.20,39011000 +2026-02-05,206.20,207.00,205.30,206.60,37022000 +2026-02-06,206.60,208.00,206.20,207.70,45012000 +2026-02-07,207.70,208.60,207.10,208.20,32011000 +2026-02-08,208.20,209.10,207.60,208.00,31022000 +2026-02-09,208.00,209.20,207.70,208.80,41033000 +2026-02-10,208.80,209.60,208.10,208.40,38511000 +2026-02-11,208.40,209.30,207.90,208.90,37022000 +2026-02-12,208.90,210.00,208.60,209.70,39911000 +2026-02-13,209.70,210.40,209.00,209.20,36022000 +2026-02-14,209.20,210.10,208.70,209.80,30511000 +2026-02-15,209.80,211.00,209.40,210.70,39222000 +2026-02-16,210.70,211.60,210.10,211.20,40112000 +2026-02-17,211.20,212.10,210.60,211.70,41033000 +2026-02-18,211.70,212.60,211.20,212.30,42011000 +2026-02-19,212.30,213.20,211.90,212.00,39822000 +2026-02-20,212.00,213.00,211.40,212.60,40511000 +2026-02-21,212.60,213.50,212.10,213.20,35022000 +2026-02-22,213.20,214.10,212.70,213.70,34011000 +2026-02-23,213.70,214.60,213.10,214.20,41033000 +2026-02-24,214.20,215.10,213.80,214.00,39911000 +2026-02-25,214.00,215.00,213.30,214.60,42011000 +2026-02-26,214.60,215.60,214.10,215.30,43022000 +2026-02-27,215.30,216.20,214.70,215.00,39011000 +2026-02-28,215.00,216.00,214.40,215.60,40123000 +2026-03-01,215.60,216.70,215.20,216.40,31022000 +2026-03-02,216.40,217.20,215.80,216.00,35011000 +2026-03-03,216.00,217.00,215.30,216.70,41033000 +2026-03-04,216.70,218.00,216.20,217.60,42011000 +2026-03-05,217.60,218.50,216.90,217.10,38022000 +2026-03-06,217.10,218.10,216.40,217.80,39511000 +2026-03-07,217.80,219.00,217.40,218.60,32011000 +2026-03-08,218.60,219.40,218.00,218.20,31022000 +2026-03-09,218.20,219.30,217.70,218.90,41033000 +2026-03-10,218.90,220.10,218.40,219.70,42011000 +2026-03-11,219.70,220.50,219.10,220.20,39822000 +2026-03-12,220.20,221.00,219.60,220.00,39011000 +2026-03-13,220.00,221.10,219.50,220.70,41005000 +2026-03-14,220.70,222.00,220.30,221.60,32011000 +2026-03-15,221.60,222.50,221.00,221.20,31022000 +2026-03-16,221.20,222.10,220.60,221.80,39911000 +2026-03-17,221.80,223.00,221.30,222.70,41033000 +2026-03-18,222.70,223.60,222.10,223.20,42011000 +2026-03-19,223.20,224.10,222.70,223.90,40511000 +2026-03-20,223.90,224.80,223.40,224.50,43022000 +2026-03-21,224.50,225.40,224.00,224.10,32011000 +2026-03-22,224.10,225.10,223.60,224.70,31022000 +2026-03-23,224.70,226.00,224.20,225.60,39911000 +2026-03-24,225.60,226.50,225.00,225.20,39011000 +2026-03-25,225.20,226.20,224.50,225.80,41033000 +2026-03-26,225.80,227.00,225.30,226.70,42011000 +2026-03-27,226.70,227.60,226.10,227.20,39822000 +2026-03-28,227.20,228.10,226.70,227.90,39011000 +2026-03-29,227.90,229.00,227.50,228.60,41005000 +2026-03-30,228.60,229.50,228.00,228.20,32011000 +2026-03-31,228.20,229.20,227.60,228.80,31022000 +2026-04-01,228.80,230.10,228.40,229.70,39911000 +2026-04-02,229.70,230.60,229.10,229.20,39011000 +2026-04-03,229.20,230.20,228.50,229.80,41033000 +2026-04-04,229.80,231.00,229.30,230.70,42011000 +2026-04-05,230.70,231.60,230.10,231.20,39822000 +2026-04-06,231.20,232.10,230.70,231.90,39011000 +2026-04-07,231.90,233.00,231.50,232.60,41005000 +2026-04-08,232.60,233.50,232.10,233.10,32011000 +2026-04-09,233.10,234.10,232.60,233.80,31022000 +2026-04-10,233.80,234.70,233.20,234.20,39911000 +2026-04-11,234.20,235.10,233.70,234.00,39011000 +2026-04-12,234.00,235.20,233.50,234.70,41033000 +2026-04-13,234.70,236.00,234.20,235.60,42011000 +2026-04-14,235.60,236.50,235.00,235.20,39822000 +2026-04-15,235.20,236.20,234.50,235.80,39011000 +2026-04-16,235.80,237.00,235.30,236.70,41005000 +2026-04-17,236.70,237.60,236.10,236.30,32011000 diff --git a/src/main/resources/market-data/AMZN.csv b/src/main/resources/market-data/AMZN.csv new file mode 100644 index 000000000..09db54d1c --- /dev/null +++ b/src/main/resources/market-data/AMZN.csv @@ -0,0 +1,96 @@ +Date,Open,High,Low,Close,Volume +2025-12-01,146.10,147.40,145.20,146.80,52100321 +2025-12-02,146.80,148.10,146.40,147.70,49877210 +2025-12-03,147.70,148.60,146.90,147.20,47211098 +2025-12-04,147.20,147.90,145.80,146.30,53044112 +2025-12-05,146.30,147.10,145.60,146.90,45522009 +2025-12-08,146.90,148.30,146.50,147.90,49011032 +2025-12-09,147.90,149.00,147.20,148.60,51234011 +2025-12-10,148.60,149.20,147.40,147.80,50112004 +2025-12-11,147.80,148.40,146.70,147.30,47655010 +2025-12-12,147.30,148.20,146.90,147.90,46890007 +2025-12-15,147.90,149.10,147.60,148.70,52011008 +2025-12-16,148.70,150.00,148.10,149.60,53422000 +2025-12-17,149.60,150.40,148.90,149.20,49933001 +2025-12-18,149.20,150.10,148.30,149.80,51022004 +2025-12-19,149.80,151.00,149.40,150.60,54511011 +2025-12-22,150.60,151.30,149.70,150.10,43022005 +2025-12-23,150.10,151.20,149.80,150.90,41511000 +2025-12-24,150.90,151.50,150.20,151.10,28000000 +2025-12-26,151.10,152.40,150.80,152.00,36011002 +2025-12-29,152.00,153.10,151.40,152.50,38822000 +2025-12-30,152.50,153.00,151.60,152.10,34011000 +2025-12-31,152.10,152.80,151.20,151.70,32522000 +2026-01-02,151.70,152.90,151.00,152.40,41011000 +2026-01-05,152.40,153.60,151.90,153.10,47022000 +2026-01-06,153.10,154.20,152.40,153.70,45511000 +2026-01-07,153.70,154.10,152.60,153.00,43022000 +2026-01-08,153.00,154.00,152.20,153.60,44811000 +2026-01-09,153.60,154.80,153.10,154.30,46222000 +2026-01-12,154.30,155.40,153.70,154.90,48911000 +2026-01-13,154.90,155.60,154.10,155.20,47022000 +2026-01-14,155.20,156.30,154.80,155.90,50111000 +2026-01-15,155.90,156.20,154.90,155.40,49522000 +2026-01-16,155.40,156.10,154.20,155.70,51011000 +2026-01-20,155.70,156.40,154.00,154.60,62022000 +2026-01-21,154.60,155.20,153.40,154.10,58011000 +2026-01-22,154.10,155.60,153.80,155.30,60222000 +2026-01-23,155.30,156.10,154.60,155.00,54011000 +2026-01-26,155.00,155.80,153.90,154.40,56522000 +2026-01-27,154.40,155.30,153.70,154.90,53011000 +2026-01-28,154.90,156.20,154.50,155.80,54822000 +2026-01-29,155.80,156.60,154.90,155.20,57511000 +2026-01-30,155.20,156.00,154.20,155.60,56022000 +2026-02-02,155.60,157.20,155.10,156.90,61011000 +2026-02-03,156.90,157.80,156.00,157.10,59022000 +2026-02-04,157.10,158.40,156.70,158.00,62011000 +2026-02-05,158.00,158.60,156.90,157.40,60522000 +2026-02-06,157.40,158.20,156.30,157.80,59011000 +2026-02-09,157.80,158.50,156.40,156.90,64022000 +2026-02-10,156.90,157.60,155.90,156.20,61211000 +2026-02-11,156.20,157.40,155.80,156.90,59822000 +2026-02-12,156.90,157.10,154.60,155.00,74011000 +2026-02-13,155.00,155.80,153.70,154.20,71022000 +2026-02-17,154.20,155.90,153.80,155.40,68011000 +2026-02-18,155.40,156.30,154.70,155.80,61022000 +2026-02-19,155.80,156.10,154.20,154.60,59011000 +2026-02-20,154.60,156.00,154.10,155.70,60522000 +2026-02-23,155.70,156.80,155.00,156.20,58011000 +2026-02-24,156.20,157.60,155.80,157.10,61022000 +2026-02-25,157.10,158.00,156.40,157.60,59511000 +2026-02-26,157.60,158.20,156.70,157.20,58222000 +2026-02-27,157.20,157.30,155.10,155.80,73011000 +2026-03-02,155.80,156.40,154.90,155.90,60022000 +2026-03-03,155.90,156.80,155.00,156.10,58511000 +2026-03-04,156.10,156.50,154.70,155.20,61022000 +2026-03-05,155.20,155.80,154.20,155.00,62511000 +2026-03-06,155.00,155.20,153.80,154.40,64022000 +2026-03-09,154.40,155.90,154.00,155.30,59011000 +2026-03-10,155.30,156.60,154.90,156.10,60522000 +2026-03-11,156.10,156.80,155.20,155.90,56011000 +2026-03-12,155.90,156.10,154.10,154.70,62022000 +2026-03-13,154.70,155.00,152.90,153.40,70011000 +2026-03-16,153.40,154.10,152.40,153.10,68022000 +2026-03-17,153.10,154.30,152.80,153.80,59011000 +2026-03-18,153.80,154.70,153.10,153.40,61022000 +2026-03-19,153.40,154.00,152.20,152.90,62011000 +2026-03-20,152.90,153.10,151.60,152.10,88022000 +2026-03-23,152.10,153.20,151.90,152.70,64011000 +2026-03-24,152.70,153.80,152.30,153.10,63022000 +2026-03-25,153.10,153.90,152.40,153.50,60011000 +2026-03-26,153.50,154.60,153.10,154.00,61022000 +2026-03-27,154.00,154.30,152.50,152.90,69011000 +2026-03-30,152.90,153.10,151.20,151.60,72022000 +2026-03-31,151.60,152.90,151.10,152.40,74011000 +2026-04-01,152.40,153.20,151.70,152.80,68022000 +2026-04-02,152.80,153.10,151.40,152.10,66011000 +2026-04-03,152.10,153.30,151.80,152.90,69022000 +2026-04-06,152.90,154.20,152.60,153.70,64011000 +2026-04-07,153.70,154.00,151.00,151.90,91022000 +2026-04-08,151.90,153.20,151.50,152.80,72011000 +2026-04-09,152.80,153.60,152.10,153.10,61022000 +2026-04-10,153.10,154.10,152.90,153.80,59011000 +2026-04-13,153.80,154.30,152.40,152.90,68022000 +2026-04-14,152.90,153.90,152.30,152.70,65011000 +2026-04-15,152.70,155.10,152.40,154.60,79022000 +2026-04-16,154.60,155.20,153.10,153.80,72011000 diff --git a/src/main/resources/market-data/TSLA.csv b/src/main/resources/market-data/TSLA.csv new file mode 100644 index 000000000..e27a94897 --- /dev/null +++ b/src/main/resources/market-data/TSLA.csv @@ -0,0 +1,97 @@ +Date,Open,High,Low,Close,Volume +2025-12-01,248.20,255.30,246.10,252.80,121003210 +2025-12-02,252.80,258.40,250.90,256.70,118877210 +2025-12-03,256.70,259.20,251.60,253.10,132110980 +2025-12-04,253.10,254.80,244.90,247.40,145044112 +2025-12-05,247.40,251.10,243.20,249.90,128522009 +2025-12-08,249.90,257.60,248.30,255.80,141011032 +2025-12-09,255.80,262.50,254.70,260.90,152234011 +2025-12-10,260.90,263.20,255.10,257.30,150112004 +2025-12-11,257.30,259.40,252.20,254.60,137655010 +2025-12-12,254.60,258.10,252.90,256.90,146890007 +2025-12-15,256.90,265.30,255.70,262.40,162011008 +2025-12-16,262.40,268.90,260.80,266.70,173422000 +2025-12-17,266.70,270.10,261.40,264.20,169933001 +2025-12-18,264.20,269.60,262.20,268.10,171022004 +2025-12-19,268.10,276.50,266.90,274.90,185511011 +2025-12-22,274.90,278.30,268.40,270.10,163022005 +2025-12-23,270.10,274.20,267.90,272.80,151511000 +2025-12-24,272.80,275.00,270.10,273.60,92000000 +2025-12-26,273.60,281.20,271.80,279.90,134011002 +2025-12-29,279.90,286.10,276.90,282.40,138822000 +2025-12-30,282.40,284.00,275.60,277.80,144011000 +2025-12-31,277.80,279.30,270.10,272.40,152522000 +2026-01-02,272.40,278.90,271.00,276.60,161011000 +2026-01-05,276.60,283.60,274.40,281.90,170222000 +2026-01-06,281.90,287.10,279.80,284.50,165511000 +2026-01-07,284.50,286.40,276.60,279.20,158022000 +2026-01-08,279.20,283.10,275.20,281.10,160811000 +2026-01-09,281.10,289.60,279.90,287.40,172122000 +2026-01-12,287.40,293.90,284.70,291.20,180911000 +2026-01-13,291.20,294.10,286.40,288.30,176022000 +2026-01-14,288.30,296.80,287.90,294.90,185111000 +2026-01-15,294.90,297.20,288.10,290.40,192522000 +2026-01-16,290.40,295.60,286.20,292.70,189111000 +2026-01-20,292.70,294.40,279.50,281.60,210222000 +2026-01-21,281.60,286.20,276.40,278.90,205011000 +2026-01-22,278.90,288.60,277.80,286.50,212222000 +2026-01-23,286.50,289.10,280.60,283.10,198011000 +2026-01-26,283.10,285.80,274.90,276.40,201522000 +2026-01-27,276.40,282.30,272.70,280.10,190011000 +2026-01-28,280.10,289.20,278.50,287.80,199822000 +2026-01-29,287.80,291.60,281.90,284.20,207511000 +2026-01-30,284.20,288.00,279.20,285.90,206022000 +2026-02-02,285.90,295.20,284.10,292.80,220011000 +2026-02-03,292.80,298.40,289.60,294.10,215022000 +2026-02-04,294.10,303.60,292.70,301.50,230011000 +2026-02-05,301.50,304.10,293.90,296.40,225522000 +2026-02-06,296.40,299.20,290.30,294.80,218011000 +2026-02-09,294.80,297.50,285.40,287.10,240222000 +2026-02-10,287.10,291.60,281.90,284.30,235011000 +2026-02-11,284.30,290.40,282.80,288.90,228822000 +2026-02-12,288.90,289.10,270.60,274.20,310011000 +2026-02-13,274.20,276.80,265.70,268.90,295022000 +2026-02-17,268.90,279.90,267.80,276.50,270011000 +2026-02-18,276.50,281.30,273.70,278.10,255022000 +2026-02-19,278.10,279.10,270.20,272.90,248011000 +2026-02-20,272.90,280.00,271.10,277.60,252522000 +2026-02-23,277.60,283.80,275.00,281.20,240011000 +2026-02-24,281.20,290.60,279.80,288.10,245022000 +2026-02-25,288.10,292.00,284.40,289.60,238011000 +2026-02-26,289.60,291.20,281.70,284.20,236222000 +2026-02-27,284.20,284.30,272.10,274.80,302011000 +2026-03-02,274.80,279.40,269.90,275.10,258022000 +2026-03-03,275.10,281.80,272.00,278.40,251011000 +2026-03-04,278.40,280.50,270.70,272.20,260222000 +2026-03-05,272.20,275.80,268.20,271.10,265011000 +2026-03-06,271.10,273.20,263.80,266.40,274022000 +2026-03-09,266.40,275.90,265.00,272.80,255011000 +2026-03-10,272.80,281.60,269.90,278.90,260222000 +2026-03-11,278.90,282.80,275.20,277.40,240011000 +2026-03-12,277.40,279.10,268.10,270.30,270222000 +2026-03-13,270.30,271.00,259.90,262.10,290011000 +2026-03-16,262.10,265.10,252.40,255.70,310222000 +2026-03-17,255.70,263.30,252.80,260.60,275011000 +2026-03-18,260.60,266.70,257.10,261.40,268222000 +2026-03-19,261.40,263.00,252.20,254.80,280011000 +2026-03-20,254.80,255.10,241.60,246.10,360222000 +2026-03-23,246.10,253.20,243.90,249.70,310011000 +2026-03-24,249.70,257.80,246.30,252.10,298222000 +2026-03-25,252.10,256.90,248.40,254.30,285011000 +2026-03-26,254.30,262.60,251.10,259.80,292222000 +2026-03-27,259.80,260.30,246.50,250.90,320011000 +2026-03-30,250.90,251.10,238.20,241.60,340222000 +2026-03-31,241.60,252.90,241.10,248.40,360011000 +2026-04-01,248.40,255.20,245.70,251.80,310222000 +2026-04-02,251.80,253.10,241.40,245.10,330011000 +2026-04-03,245.10,253.30,242.80,249.90,320222000 +2026-04-06,249.90,262.20,248.60,258.70,315011000 +2026-04-07,258.70,260.00,237.00,242.60,420222000 +2026-04-08,242.60,252.20,241.50,248.90,350011000 +2026-04-09,248.90,253.60,246.10,250.30,300222000 +2026-04-10,250.30,257.10,249.20,254.90,295011000 +2026-04-13,254.90,256.30,242.40,246.90,340222000 +2026-04-14,246.90,252.90,245.30,248.60,310011000 +2026-04-15,248.60,266.10,246.40,261.90,390222000 +2026-04-16,261.90,264.20,253.10,256.80,360011000 +2026-04-17,256.80,259.60,251.10,254.30,320110000