diff --git a/Meeting-Documentation/Meeting-2.md b/Meeting-Documentation/Meeting-2.md index 637bca3..971cf45 100644 --- a/Meeting-Documentation/Meeting-2.md +++ b/Meeting-Documentation/Meeting-2.md @@ -34,4 +34,4 @@ - Review Sprint 1 for code review + fix any issues (**Luke + Sameer**) - Support team with repository processes (**Jack + Azlan**) - Final check of architecture diagram to ensure it matches code structure (**Azlan**) -- Update Kanban with Sprint 2 tasks from feedback (**Luke + Jack**) +- Update Kanban with Sprint 2 tasks from feedback (**Jack**) diff --git a/README.md b/README.md index 7214545..80a099f 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,8 @@ This is a Java-based web application for charting and comparing share prices ove | **Individual Reflection** | 23.4.26 | 10% | - | ## How to run -./mvnw spring-boot:run +`cd stockscope && ./mvnw spring-boot:run` + +### Set your API key +- Mac/Linux (zsh): `export ALPHAVANTAGE_KEY="YOUR_KEY_HERE"` then restart the app. +- If you run from an IDE (IntelliJ/Eclipse), set `ALPHAVANTAGE_KEY` in the Run/Debug configuration environment variables (Terminal exports may not be inherited). diff --git a/stockscope/pom.xml b/stockscope/pom.xml index 86244eb..10738fe 100644 --- a/stockscope/pom.xml +++ b/stockscope/pom.xml @@ -35,6 +35,7 @@ spring-boot-starter-test test + @@ -50,6 +51,10 @@ org.springframework.boot spring-boot-maven-plugin + 4.0.2 + + com.sharecomparison.StockComparisonApplication + diff --git a/stockscope/src/main/java/com/sharecomparison/StockComparisonApplication.java b/stockscope/src/main/java/com/sharecomparison/StockComparisonApplication.java new file mode 100644 index 0000000..8012b7f --- /dev/null +++ b/stockscope/src/main/java/com/sharecomparison/StockComparisonApplication.java @@ -0,0 +1,32 @@ +package com.sharecomparison; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.beans.factory.annotation.Value; + +import com.sharecomparison.application.MarketDataService; +import com.sharecomparison.application.PriceRepository; +import com.sharecomparison.infrastructure.AlphaVantageService; +import com.sharecomparison.infrastructure.InMemoryPriceRepository; + +@SpringBootApplication +public class StockComparisonApplication { + + public static void main(String[] args) { + SpringApplication.run(StockComparisonApplication.class, args); + } + + @Bean + public PriceRepository priceRepository() { + return new InMemoryPriceRepository(); + } + + @Bean + public MarketDataService marketDataService( + PriceRepository repository, + @Value("${alphavantage.key:demo}") String apiKey, + @Value("${alphavantage.outputsize:compact}") String outputSize) { + return new AlphaVantageService(repository, apiKey, outputSize); + } +} diff --git a/stockscope/src/main/java/com/sharecomparison/application/MarketDataService.java b/stockscope/src/main/java/com/sharecomparison/application/MarketDataService.java new file mode 100644 index 0000000..2e21a9b --- /dev/null +++ b/stockscope/src/main/java/com/sharecomparison/application/MarketDataService.java @@ -0,0 +1,16 @@ +package com.sharecomparison.application; + +import java.time.LocalDate; +import java.util.List; + +import com.sharecomparison.domain.PriceData; + +public interface MarketDataService { + + List fetchSharePrices( + String symbol, + LocalDate startDate, + LocalDate endDate + ); + +} \ No newline at end of file diff --git a/stockscope/src/main/java/com/sharecomparison/application/PriceRepository.java b/stockscope/src/main/java/com/sharecomparison/application/PriceRepository.java new file mode 100644 index 0000000..9eaf87d --- /dev/null +++ b/stockscope/src/main/java/com/sharecomparison/application/PriceRepository.java @@ -0,0 +1,18 @@ +package com.sharecomparison.application; + +import java.time.LocalDate; +import java.util.List; + +import com.sharecomparison.domain.PriceData; + +public interface PriceRepository { + + void savePrices(List prices); + + List loadPrices( + String symbol, + LocalDate startDate, + LocalDate endDate + ); + +} \ No newline at end of file diff --git a/stockscope/src/main/java/com/sharecomparison/infrastructure/AlphaVantageService.java b/stockscope/src/main/java/com/sharecomparison/infrastructure/AlphaVantageService.java new file mode 100644 index 0000000..3a09484 --- /dev/null +++ b/stockscope/src/main/java/com/sharecomparison/infrastructure/AlphaVantageService.java @@ -0,0 +1,263 @@ +package com.sharecomparison.infrastructure; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.sharecomparison.application.MarketDataService; +import com.sharecomparison.application.PriceRepository; +import com.sharecomparison.domain.PriceData; + +public class AlphaVantageService implements MarketDataService { + + private static final Logger log = LoggerFactory.getLogger(AlphaVantageService.class); + private static final int LOG_SNIPPET_LENGTH = 250; + private static final long MIN_REQUEST_INTERVAL_MS = 1100; + private static final Object RATE_LIMIT_LOCK = new Object(); + private static long lastRequestAtMs = 0L; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final PriceRepository repository; + + private final String apiKey; + private final String outputSize; + + public AlphaVantageService(PriceRepository repository) { + this(repository, null); + } + + public AlphaVantageService(PriceRepository repository, String apiKey) { + this(repository, apiKey, null); + } + + public AlphaVantageService(PriceRepository repository, String apiKey, String outputSize) { + this.repository = repository; + this.apiKey = resolveApiKey(apiKey); + this.outputSize = normalizeOutputSize(outputSize); + } + + @Override + public List fetchSharePrices(String symbol, LocalDate startDate, LocalDate endDate) { + try { + if (symbol == null || symbol.isBlank()) { + return repository.loadPrices(symbol, startDate, endDate); + } + + List cached = repository.loadPrices(symbol, startDate, endDate); + if (!cached.isEmpty()) { + log.debug("Returning cached prices for symbol={} ({} to {})", symbol, startDate, endDate); + return cached; + } + + String json = fetchTimeSeriesDailyJson(symbol, outputSize); + if (json == null) { + return repository.loadPrices(symbol, startDate, endDate); + } + + if (json.contains("\"Note\"")) { + log.warn("Alpha Vantage throttled request: {}", extractStringFieldOrSnippet(json, "Note")); + return repository.loadPrices(symbol, startDate, endDate); + } + if (json.contains("\"Information\"")) { + log.warn("Alpha Vantage returned information: {}", extractStringFieldOrSnippet(json, "Information")); + if ("full".equals(outputSize) + && json.contains("outputsize=full") + && (json.toLowerCase().contains("premium") || json.toLowerCase().contains("subscribe"))) { + log.warn("Retrying Alpha Vantage request with outputsize=compact (free-tier fallback)."); + String fallbackJson = fetchTimeSeriesDailyJson(symbol, "compact"); + if (fallbackJson != null + && !fallbackJson.contains("\"Information\"") + && !fallbackJson.contains("\"Note\"") + && !fallbackJson.contains("\"Error Message\"") + && !fallbackJson.contains("\"error message\"")) { + json = fallbackJson; + } else { + return repository.loadPrices(symbol, startDate, endDate); + } + } else { + return repository.loadPrices(symbol, startDate, endDate); + } + } + if (json.contains("\"Error Message\"") || json.contains("\"error message\"")) { + log.warn("Alpha Vantage error for symbol={}: {}", symbol, extractStringFieldOrSnippet(json, "Error Message")); + return repository.loadPrices(symbol, startDate, endDate); + } + + List prices = parseTimeSeriesDailyJson(symbol, json, startDate, endDate); + + if (prices.isEmpty()) { + log.warn("Alpha Vantage returned no price data for symbol={}. Response snippet: {}", symbol, snippet(json)); + return repository.loadPrices(symbol, startDate, endDate); + } + + repository.savePrices(prices); + return prices; + + } catch (Exception e) { + log.warn("Alpha Vantage fetch failed for symbol={}: {}", symbol, e.toString()); + return repository.loadPrices(symbol, startDate, endDate); + } + } + + private String fetchTimeSeriesDailyJson(String symbol, String outputSize) throws Exception { + rateLimit(); + String url = "https://www.alphavantage.co/query" + + "?function=TIME_SERIES_DAILY" + + "&symbol=" + URLEncoder.encode(symbol.trim(), StandardCharsets.UTF_8) + + "&outputsize=" + URLEncoder.encode(normalizeOutputSize(outputSize), StandardCharsets.UTF_8) + + "&apikey=" + URLEncoder.encode(apiKey, StandardCharsets.UTF_8); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/json") + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + log.warn("Alpha Vantage HTTP error: status={}", response.statusCode()); + return null; + } + return response.body(); + } + + private static void rateLimit() { + synchronized (RATE_LIMIT_LOCK) { + long now = System.currentTimeMillis(); + long elapsed = now - lastRequestAtMs; + if (elapsed < MIN_REQUEST_INTERVAL_MS) { + long sleepMs = MIN_REQUEST_INTERVAL_MS - elapsed; + try { + Thread.sleep(sleepMs); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + lastRequestAtMs = System.currentTimeMillis(); + } + } + + List parseTimeSeriesDailyJson( + String symbol, + String json, + LocalDate start, + LocalDate end) { + + List prices = new ArrayList<>(); + + int timeSeriesIndex = json.indexOf("\"Time Series (Daily)\""); + if (timeSeriesIndex == -1) { + log.warn("No 'Time Series (Daily)' section found in Alpha Vantage response"); + return prices; + } + + int startObj = json.indexOf('{', timeSeriesIndex); + if (startObj == -1) return prices; + + int braceCount = 1; + int cursor = startObj + 1; + while (cursor < json.length() && braceCount > 0) { + char c = json.charAt(cursor); + if (c == '{') braceCount++; + if (c == '}') braceCount--; + cursor++; + } + if (braceCount != 0) { + log.warn("Unbalanced braces while parsing 'Time Series (Daily)'"); + return prices; + } + + String timeSeriesBlock = json.substring(startObj, cursor); + + DateTimeFormatter fmt = DateTimeFormatter.ISO_LOCAL_DATE; + + Pattern entryPattern = Pattern.compile( + "\"(\\d{4}-\\d{2}-\\d{2})\"\\s*:\\s*\\{[^}]*?\"4\\. close\"\\s*:\\s*\"([^\"]+)\"", + Pattern.DOTALL + ); + + Matcher matcher = entryPattern.matcher(timeSeriesBlock); + while (matcher.find()) { + String dateStr = matcher.group(1); + String closeStr = matcher.group(2); + LocalDate date; + try { + date = LocalDate.parse(dateStr, fmt); + } catch (Exception ex) { + continue; + } + + if (date.isBefore(start) || date.isAfter(end)) continue; + + double closePrice; + try { + closePrice = Double.parseDouble(closeStr); + } catch (NumberFormatException ex) { + continue; + } + + prices.add(new PriceData(symbol, date, closePrice)); + } + + // Sort just in case + prices.sort((a, b) -> a.getDate().compareTo(b.getDate())); + + return prices; + } + + private static String resolveApiKey(String apiKey) { + String resolvedKey = (apiKey == null || apiKey.isBlank()) + ? System.getenv("ALPHAVANTAGE_KEY") + : apiKey; + if (resolvedKey == null || resolvedKey.isBlank()) { + log.warn("Alpha Vantage API key not set (property/env). Falling back to 'demo' key."); + resolvedKey = "demo"; + } + if ("demo".equalsIgnoreCase(resolvedKey)) { + log.warn("Alpha Vantage is configured with the 'demo' key (very limited symbols). Set ALPHAVANTAGE_KEY (or alphavantage.key) to use a real key."); + } + return resolvedKey; + } + + private static String normalizeOutputSize(String outputSize) { + if (outputSize == null || outputSize.isBlank()) return "compact"; + String normalized = outputSize.trim().toLowerCase(); + if (!"compact".equals(normalized) && !"full".equals(normalized)) { + log.warn("Invalid alphavantage.outputsize='{}'. Falling back to 'compact'.", outputSize); + return "compact"; + } + return normalized; + } + + private static String extractStringFieldOrSnippet(String json, String fieldName) { + String extracted = extractStringField(json, fieldName); + return extracted != null ? extracted : snippet(json); + } + + private static String extractStringField(String json, String fieldName) { + if (json == null || json.isBlank() || fieldName == null || fieldName.isBlank()) return null; + Pattern pattern = Pattern.compile("\"" + Pattern.quote(fieldName) + "\"\\s*:\\s*\"([^\"]*)\""); + Matcher matcher = pattern.matcher(json); + if (!matcher.find()) return null; + return matcher.group(1); + } + + private static String snippet(String value) { + if (value == null) return "null"; + String trimmed = value.trim(); + if (trimmed.length() <= LOG_SNIPPET_LENGTH) return trimmed; + return trimmed.substring(0, LOG_SNIPPET_LENGTH) + "..."; + } +} diff --git a/stockscope/src/main/java/com/sharecomparison/infrastructure/InMemoryPriceRepository.java b/stockscope/src/main/java/com/sharecomparison/infrastructure/InMemoryPriceRepository.java new file mode 100644 index 0000000..21094db --- /dev/null +++ b/stockscope/src/main/java/com/sharecomparison/infrastructure/InMemoryPriceRepository.java @@ -0,0 +1,56 @@ +package com.sharecomparison.infrastructure; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import com.sharecomparison.application.PriceRepository; +import com.sharecomparison.domain.PriceData; + +public class InMemoryPriceRepository implements PriceRepository { + + private List storedPrices = new ArrayList<>(); + + @Override + public void savePrices(List prices) { + for (PriceData newPrice : prices) { + boolean replaced = false; + + for (int i = 0; i < storedPrices.size(); i++) { + PriceData existing = storedPrices.get(i); + + if (existing.getSymbol().equals(newPrice.getSymbol()) + && existing.getDate().equals(newPrice.getDate())) { + storedPrices.set(i, newPrice); + replaced = true; + break; + } + } + + if (!replaced) { + storedPrices.add(newPrice); + } + } + } + + @Override + public List loadPrices( + String symbol, + LocalDate startDate, + LocalDate endDate) { + + List results = new ArrayList<>(); + + for (PriceData p : storedPrices) { + + if (p.getSymbol().equals(symbol) + && !p.getDate().isBefore(startDate) + && !p.getDate().isAfter(endDate)) { + + results.add(p); + } + } + + return results; + } +} \ No newline at end of file diff --git a/stockscope/src/main/java/com/sharecomparison/presentation/WebPageController.java b/stockscope/src/main/java/com/sharecomparison/presentation/WebPageController.java index 8d094b4..a4c2e1e 100644 --- a/stockscope/src/main/java/com/sharecomparison/presentation/WebPageController.java +++ b/stockscope/src/main/java/com/sharecomparison/presentation/WebPageController.java @@ -1,15 +1,17 @@ package com.sharecomparison.presentation; -import com.sharecomparison.application.IPriceController; -import com.sharecomparison.domain.ComparisonResult; +import java.time.LocalDate; + import org.springframework.format.annotation.DateTimeFormat; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import java.time.LocalDate; +import com.sharecomparison.application.IPriceController; +import com.sharecomparison.domain.ComparisonResult; @Controller public class WebPageController { @@ -21,19 +23,15 @@ public WebPageController(IPriceController priceController) { } @GetMapping("/") - public String index(Model model) { - LocalDate end = LocalDate.now(); - LocalDate start = end.minusMonths(6); - + public String showForm(Model model) { model.addAttribute("symbol1", "AAPL"); model.addAttribute("symbol2", "MSFT"); - model.addAttribute("startDate", start); - model.addAttribute("endDate", end); - + model.addAttribute("startDate", LocalDate.now().minusYears(1).toString()); + model.addAttribute("endDate", LocalDate.now().toString()); return "index"; } - @PostMapping("/compare") + @GetMapping("/compare") public String compare( @RequestParam String symbol1, @RequestParam String symbol2, @@ -41,30 +39,55 @@ public String compare( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, Model model) { - String s1 = symbol1 == null ? "" : symbol1.trim().toUpperCase(); - String s2 = symbol2 == null ? "" : symbol2.trim().toUpperCase(); - - LocalDate effectiveEnd = endDate == null ? LocalDate.now() : endDate; - LocalDate effectiveStart = startDate == null ? effectiveEnd.minusMonths(6) : startDate; + LocalDate start = (startDate != null) ? startDate : LocalDate.now().minusYears(1); + LocalDate end = (endDate != null) ? endDate : LocalDate.now(); - model.addAttribute("symbol1", s1); - model.addAttribute("symbol2", s2); - model.addAttribute("startDate", effectiveStart); - model.addAttribute("endDate", effectiveEnd); + symbol1 = symbol1.trim().toUpperCase(); + symbol2 = symbol2.trim().toUpperCase(); - if (s1.isEmpty() || s2.isEmpty()) { - model.addAttribute("error", "Both symbols are required."); + if (start.isAfter(end)) { + model.addAttribute("error", "Start date must be on or before end date."); + model.addAttribute("symbol1", symbol1); + model.addAttribute("symbol2", symbol2); + model.addAttribute("startDate", start.toString()); + model.addAttribute("endDate", end.toString()); return "index"; } - if (effectiveStart.isAfter(effectiveEnd)) { - model.addAttribute("error", "Start date must be on or before end date."); + ComparisonResult result = priceController.comparePrices(symbol1, symbol2, start, end); + + if (result.getSymbol1Data().isEmpty() || result.getSymbol2Data().isEmpty()) { + model.addAttribute("error", "No data available for one or both symbols in this range."); + model.addAttribute("symbol1", symbol1); + model.addAttribute("symbol2", symbol2); + model.addAttribute("startDate", start.toString()); + model.addAttribute("endDate", end.toString()); return "index"; } - ComparisonResult result = priceController.comparePrices(s1, s2, effectiveStart, effectiveEnd); model.addAttribute("result", result); + model.addAttribute("symbol1", symbol1); + model.addAttribute("symbol2", symbol2); + model.addAttribute("startDate", start.toString()); + model.addAttribute("endDate", end.toString()); return "index"; } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public String handleDateParseError(MethodArgumentTypeMismatchException ex, Model model) { + String paramName = ex.getName(); + if (LocalDate.class.equals(ex.getRequiredType()) + && ("startDate".equals(paramName) || "endDate".equals(paramName))) { + model.addAttribute("error", + "Invalid date format for '" + paramName + "'. Please use YYYY-MM-DD (e.g. 2024-01-15)."); + } else { + model.addAttribute("error", "Invalid value for parameter '" + paramName + "'."); + } + model.addAttribute("symbol1", "AAPL"); + model.addAttribute("symbol2", "MSFT"); + model.addAttribute("startDate", LocalDate.now().minusYears(1).toString()); + model.addAttribute("endDate", LocalDate.now().toString()); + return "index"; + } } diff --git a/stockscope/src/main/java/com/sharecomparison/presentation/WebUI.java b/stockscope/src/main/java/com/sharecomparison/presentation/WebUI.java deleted file mode 100644 index e8f8828..0000000 --- a/stockscope/src/main/java/com/sharecomparison/presentation/WebUI.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.sharecomparison.presentation; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication(scanBasePackages = "com.sharecomparison") -public class WebUI { - - public static void main(String[] args) { - SpringApplication.run(WebUI.class, args); - } -} diff --git a/stockscope/src/main/resources/application.properties b/stockscope/src/main/resources/application.properties new file mode 100644 index 0000000..7139445 --- /dev/null +++ b/stockscope/src/main/resources/application.properties @@ -0,0 +1,30 @@ +# ================================ +# Alpha Vantage API Configuration +# ================================ +# Get your free key at: https://www.alphavantage.co/support/#api-key +# Use 'demo' for quick testing (limited symbols, 25 calls/day) +alphavantage.key=${ALPHAVANTAGE_KEY:demo} +# "full" is now treated as premium for TIME_SERIES_DAILY by Alpha Vantage (free keys will get an "Information" response). +alphavantage.outputsize=compact + +# ================================ +# Development / Debugging Helpers +# ================================ +# Disable Thymeleaf template caching → changes to index.html reload instantly without restart +spring.thymeleaf.cache=false + +# Show detailed startup logs and bean wiring info (helpful for debugging ambiguous mappings, etc.) +logging.level.org.springframework=INFO +# logging.level.org.springframework.web=DEBUG ← uncomment for more request details +# logging.level.com.sharecomparison=DEBUG ← uncomment to log your own classes + +# Optional: Change server port if 8080 is blocked/conflicted +# server.port=8081 + +# Optional: If you later add a database (e.g. H2 for persistent cache) +# spring.datasource.url=jdbc:h2:mem:testdb +# spring.datasource.driverClassName=org.h2.Driver +# spring.datasource.username=sa +# spring.datasource.password= +# spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +# spring.h2.console.enabled=true diff --git a/stockscope/src/main/resources/templates/index.html b/stockscope/src/main/resources/templates/index.html index 4a38c1f..f5bc6d7 100644 --- a/stockscope/src/main/resources/templates/index.html +++ b/stockscope/src/main/resources/templates/index.html @@ -175,6 +175,12 @@ display: block; } + .error { + color: #911d1d; + font-weight: bold; + margin-bottom: 1rem; + } + @media (max-width: 768px) { .cards { grid-template-columns: 1fr; @@ -201,7 +207,7 @@

Share Price Comparison

-
+ @@ -213,7 +219,7 @@

Share Price Comparison

+ th:text="|Showing ${result.symbol1} vs ${result.symbol2} from ${result.startDate} to ${result.endDate}|">

@@ -294,7 +300,10 @@

+
+
+ Enter two stock symbols and a date range above to see live historical comparison.