Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
f059ee9
Sprint 2 Commit 1
jackoturner Mar 17, 2026
67d93a2
Sprint 2 Commit 2
jackoturner Mar 17, 2026
027ff4d
Delete stockscope/src/main/java/com/.DS_Store
lukepring Mar 20, 2026
976cbb2
Delete stockscope/src/main/java/.DS_Store
lukepring Mar 20, 2026
e0fcb9e
Delete stockscope/src/main/.DS_Store
lukepring Mar 20, 2026
88e83c9
Delete stockscope/src/.DS_Store
lukepring Mar 20, 2026
ade2314
Delete stockscope/.DS_Store
lukepring Mar 20, 2026
39ea586
Delete .DS_Store
lukepring Mar 20, 2026
533acce
Initial plan
Copilot Mar 20, 2026
7074ea7
Initial plan
Copilot Mar 20, 2026
8d78a45
Initial plan
Copilot Mar 20, 2026
b1fddb9
Update stockscope/src/main/java/com/sharecomparison/presentation/WebP…
lukepring Mar 20, 2026
7c66b3e
Initial plan
Copilot Mar 20, 2026
6f94517
Add startDate <= endDate validation in compare endpoint
Copilot Mar 20, 2026
7812b77
Update stockscope/src/main/java/com/sharecomparison/infrastructure/In…
lukepring Mar 20, 2026
9ce7e53
Align chart series by date intersection to fix index-based date mismatch
Copilot Mar 20, 2026
dbf480e
Initial plan
Copilot Mar 20, 2026
a2b66f1
Initial plan
Copilot Mar 20, 2026
28fb4a5
Refactor WebPageController to use IPriceController and ComparisonResult
Copilot Mar 20, 2026
ae61434
Add cache-first check in AlphaVantageService.fetchSharePrices()
Copilot Mar 20, 2026
bfc2838
Remove redundant WebUI @SpringBootApplication entrypoint
Copilot Mar 20, 2026
902bbb1
Fix DateTimeParseException for malformed date query params
Copilot Mar 20, 2026
235e75a
Merge branch 'Sprint-2-Jack' into copilot/sub-pr-55-another-one
lukepring Mar 20, 2026
bbd8485
Merge pull request #58 from lukepring/copilot/sub-pr-55-another-one
lukepring Mar 20, 2026
4468584
Merge pull request #61 from lukepring/copilot/sub-pr-55-please-work
lukepring Mar 20, 2026
ae829e6
Merge pull request #60 from lukepring/copilot/sub-pr-55-one-more-time
lukepring Mar 20, 2026
617a00a
Merge branch 'Sprint-2-Jack' into copilot/sub-pr-55-yet-again
lukepring Mar 20, 2026
38c290f
Fix WebPageController merge regression
Copilot Mar 20, 2026
23da9eb
Merge pull request #59 from lukepring/copilot/sub-pr-55-yet-again
lukepring Mar 20, 2026
1d7c1ee
Merge branch 'Sprint-2-Jack' into copilot/sub-pr-55-again
lukepring Mar 20, 2026
577066d
Fix WebPageController merge regression and keep date validation
Copilot Mar 20, 2026
6356ee2
Merge pull request #57 from lukepring/copilot/sub-pr-55-again
lukepring Mar 20, 2026
2051c6c
Merge branch 'Sprint-2-Jack' into copilot/sub-pr-55
lukepring Mar 20, 2026
6f66f31
Merge pull request #56 from lukepring/copilot/sub-pr-55
lukepring Mar 20, 2026
50e2279
Initial plan
Copilot Mar 20, 2026
3f588c7
Merge origin/main into copilot/sub-pr-55
Copilot Mar 20, 2026
08449ad
Merge pull request #62 from lukepring/copilot/sub-pr-55
lukepring Mar 20, 2026
6b16b8f
Merge branch 'main' into Sprint-2-Jack
lukepring Mar 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Meeting-Documentation/Meeting-2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not cool man

- Update Kanban with Sprint 2 tasks from feedback (**Jack**)
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<code>./mvnw spring-boot:run</code>
`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).
5 changes: 5 additions & 0 deletions stockscope/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

</dependencies>

<build>
Expand All @@ -50,6 +51,10 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>4.0.2</version>
<configuration>
<mainClass>com.sharecomparison.StockComparisonApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Comment on lines +13 to +31
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The project already has an @SpringBootApplication entrypoint (com.sharecomparison.presentation.WebUI). Adding a second one can confuse IDE run configurations and make it unclear which application class is authoritative. Consider consolidating to a single entrypoint (either remove WebUI or move these @Bean definitions into the existing entrypoint/config).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback

}
Original file line number Diff line number Diff line change
@@ -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<PriceData> fetchSharePrices(
String symbol,
LocalDate startDate,
LocalDate endDate
);

}
Original file line number Diff line number Diff line change
@@ -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<PriceData> prices);

List<PriceData> loadPrices(
String symbol,
LocalDate startDate,
LocalDate endDate
);

}
Original file line number Diff line number Diff line change
@@ -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<PriceData> fetchSharePrices(String symbol, LocalDate startDate, LocalDate endDate) {
try {
if (symbol == null || symbol.isBlank()) {
return repository.loadPrices(symbol, startDate, endDate);
}

List<PriceData> 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);
}
Comment on lines +51 to +67
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchSharePrices() always calls Alpha Vantage first and only uses the repository as a fallback on errors/throttling. This means cached data is never used to avoid external calls, increasing latency and the chance of hitting rate limits. Consider checking the repository for an existing result (or partially cached range) before making the HTTP request, and only call the API for missing data.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot open a new pull request to apply changes based on this feedback


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<PriceData> 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<String> 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<PriceData> parseTimeSeriesDailyJson(
String symbol,
String json,
LocalDate start,
LocalDate end) {

List<PriceData> 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) + "...";
}
}
Loading