Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'org.springframework.integration:spring-integration-core'
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'

implementation 'com.yahoofinance-api:YahooFinanceAPI:3.17.0'

testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:testcontainers'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.autoinvestor.application;

import io.autoinvestor.exceptions.BadRequestException;

public class AssetNotFoundException extends BadRequestException {
public AssetNotFoundException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.autoinvestor.application;

import java.util.Date;

public record GetAssetPriceCommand(String assetId, Date date) { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package io.autoinvestor.application;

import io.autoinvestor.domain.Asset;
import io.autoinvestor.domain.AssetId;
import io.autoinvestor.domain.AssetPriceFetcher;
import io.autoinvestor.domain.AssetRepository;
import org.springframework.stereotype.Service;


@Service
public class GetAssetPriceCommandHandler {

private final AssetPriceFetcher fetcher;
private final AssetRepository repository;

public GetAssetPriceCommandHandler(AssetRepository repository, AssetPriceFetcher fetcher) {
this.repository = repository;
this.fetcher = fetcher;
}

public GetAssetPriceResponse handle(GetAssetPriceCommand command) {
Asset asset = repository.findById(AssetId.of(command.assetId()))
.orElseThrow(() -> new AssetNotFoundException("Asset not found with id: " + command.assetId()));

float price = fetcher.priceOn(asset, command.date());
return new GetAssetPriceResponse(price, command.date());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.autoinvestor.application;

import java.util.Date;


public record GetAssetPriceResponse(float price, Date date) { }
14 changes: 9 additions & 5 deletions src/main/java/io/autoinvestor/domain/Asset.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,25 @@ public class Asset extends AggregateRoot {
private final Ticker ticker;
private final CompanyName name;
private final Date createdAt;
private Date updatedAt;
private final Date updatedAt;

private Asset(Mic mic, Ticker ticker, CompanyName name) {
private Asset(Mic mic, Ticker ticker, CompanyName name, Date createdAt, Date updatedAt) {
this.id = AssetId.generate();
this.mic = mic;
this.ticker = ticker;
this.name = name;
this.createdAt = new Date();
this.updatedAt = new Date();
this.createdAt = createdAt;
this.updatedAt = updatedAt;

this.recordEvent(AssetWasRegisteredEvent.from(this));
}

public static Asset create(String mic, String ticker, String name) {
return new Asset(Mic.from(mic), Ticker.from(ticker), CompanyName.from(name));
return new Asset(Mic.from(mic), Ticker.from(ticker), CompanyName.from(name), new Date(), new Date());
}

public static Asset create(String mic, String ticker, String name, Date createdAt, Date updatedAt) {
return new Asset(Mic.from(mic), Ticker.from(ticker), CompanyName.from(name), createdAt, updatedAt);
}

public String mic() {
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/io/autoinvestor/domain/AssetId.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ public class AssetId extends Id {
public static AssetId generate() {
return new AssetId(generateId());
}

public static AssetId of(String id) {
return new AssetId(id);
}
}
7 changes: 7 additions & 0 deletions src/main/java/io/autoinvestor/domain/AssetPriceFetcher.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.autoinvestor.domain;

import java.util.Date;

public interface AssetPriceFetcher {
float priceOn(Asset asset, Date date);
}
4 changes: 4 additions & 0 deletions src/main/java/io/autoinvestor/domain/AssetRepository.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package io.autoinvestor.domain;


import java.util.Optional;

public interface AssetRepository {
void save(Asset asset);
boolean exists(String mic, String ticker);
Optional<Asset> findById(AssetId assetId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.autoinvestor.infrastructure.fetchers;

public class PriceFetchFailedException extends RuntimeException {
public PriceFetchFailedException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package io.autoinvestor.infrastructure.fetchers;

public class PriceNotAvailableException extends RuntimeException {
public PriceNotAvailableException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package io.autoinvestor.infrastructure.fetchers;

import io.autoinvestor.domain.Asset;
import io.autoinvestor.domain.AssetPriceFetcher;
import org.springframework.stereotype.Component;
import yahoofinance.YahooFinance;
import yahoofinance.Stock;
import yahoofinance.histquotes.HistoricalQuote;
import yahoofinance.histquotes.Interval;

import java.io.IOException;
import java.math.BigDecimal;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.TimeZone;


@Component
public class YFinanceAssetPriceFetcher implements AssetPriceFetcher {

private static final int DAYS_LOOKBACK_BUFFER = 7;

@Override
public float priceOn(Asset asset, Date date) {
Calendar target = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
target.setTime(date);

Calendar from = (Calendar) target.clone();
from.add(Calendar.DAY_OF_MONTH, -DAYS_LOOKBACK_BUFFER);

Calendar to = (Calendar) target.clone();
to.add(Calendar.DAY_OF_MONTH, DAYS_LOOKBACK_BUFFER);

try {
Stock stock = YahooFinance.get(asset.ticker(), from, to, Interval.DAILY);
List<HistoricalQuote> history = stock.getHistory();

HistoricalQuote bar = history.stream()
.filter(h -> h.getDate() != null)
.sorted((a, b) -> b.getDate().compareTo(a.getDate()))
.filter(h -> !h.getDate().after(target))
.findFirst()
.orElseThrow(() -> new PriceNotAvailableException(
"No historical bar found for %s on/Before %s".formatted(asset, date)));

BigDecimal close = bar.getClose() != null ? bar.getClose() : bar.getAdjClose();
if (close == null) {
throw new PriceNotAvailableException(
"Close price missing for %s on %s".formatted(asset, date));
}
return close.floatValue();
} catch (IOException ex) {
throw new PriceFetchFailedException(
"Unable to fetch price for %s from Yahoo Finance".formatted(asset));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import io.autoinvestor.domain.Asset;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
class AssetMapper {

Expand All @@ -15,5 +17,14 @@ AssetDocument toDocument(Asset domain) {
domain.getCreatedAt().toInstant(),
domain.getUpdatedAt().toInstant());
}

public Asset toDomain(AssetDocument assetDocument) {
return Asset.create(
assetDocument.mic(),
assetDocument.ticker(),
assetDocument.name(),
Date.from(assetDocument.occurredAt()),
Date.from(assetDocument.updatedAt()));
}
}

Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package io.autoinvestor.infrastructure.repositories;

import io.autoinvestor.domain.Asset;
import io.autoinvestor.domain.AssetId;
import io.autoinvestor.domain.AssetRepository;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

@Repository
@Profile("local")
Expand All @@ -24,6 +26,11 @@ public boolean exists(String mic, String ticker) {
return assetStore.containsKey(mic + ":" + ticker);
}

@Override
public Optional<Asset> findById(AssetId assetId) {
return Optional.ofNullable(assetStore.get(assetId.value()));
}

public void clear() {
assetStore.clear();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
package io.autoinvestor.infrastructure.repositories;

import io.autoinvestor.domain.Asset;
import io.autoinvestor.domain.AssetId;
import io.autoinvestor.domain.AssetRepository;
import org.springframework.context.annotation.Profile;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Repository;

import java.util.Optional;


@Repository
@Profile("prod")
Expand All @@ -33,4 +36,10 @@ public boolean exists(String mic, String ticker) {
.and("ticker").is(ticker));
return template.exists(q, AssetDocument.class);
}

@Override
public Optional<Asset> findById(AssetId assetId) {
return Optional.ofNullable(template.findById(assetId.value(), AssetDocument.class))
.map(mapper::toDomain);
}
}
38 changes: 38 additions & 0 deletions src/main/java/io/autoinvestor/ui/GetAssetPriceController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package io.autoinvestor.ui;

import io.autoinvestor.application.GetAssetPriceCommand;
import io.autoinvestor.application.GetAssetPriceCommandHandler;
import io.autoinvestor.application.GetAssetPriceResponse;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.Instant;
import java.util.Date;

@RestController
@RequestMapping("/assets")
public class GetAssetPriceController {

private final GetAssetPriceCommandHandler handler;

public GetAssetPriceController(GetAssetPriceCommandHandler handler) {
this.handler = handler;
}

@GetMapping("/{assetId}/price")
public ResponseEntity<PriceDTO> getPrice(
@PathVariable String assetId,
@RequestParam(name = "at", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) Date at) {

Date date = at != null ? at : Date.from(Instant.now());

GetAssetPriceResponse response = handler.handle(new GetAssetPriceCommand(assetId, date));
PriceDTO dto = new PriceDTO(
response.date(),
response.price()
);
return ResponseEntity.ok(dto);
}
}
18 changes: 18 additions & 0 deletions src/main/java/io/autoinvestor/ui/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.autoinvestor.ui;

import io.autoinvestor.exceptions.*;
import io.autoinvestor.infrastructure.fetchers.PriceFetchFailedException;
import io.autoinvestor.infrastructure.fetchers.PriceNotAvailableException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
Expand All @@ -27,6 +29,22 @@ public ResponseEntity<ErrorResponse> handleBadRequestException(BadRequestExcepti
.build();
}

@ExceptionHandler(PriceNotAvailableException.class)
public ResponseEntity<ErrorResponse> handlePriceNotAvailableException(PriceNotAvailableException ex) {
return ErrorResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.message(ex.getMessage())
.build();
}

@ExceptionHandler(PriceFetchFailedException.class)
public ResponseEntity<ErrorResponse> handlePriceFetchFailedException(PriceFetchFailedException ex) {
return ErrorResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.message(ex.getMessage())
.build();
}

@ExceptionHandler(InternalErrorException.class)
public ResponseEntity<ErrorResponse> handleInternalErrorException(InternalErrorException ex) {
return ErrorResponse.builder()
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/io/autoinvestor/ui/PriceDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.autoinvestor.ui;

import com.fasterxml.jackson.annotation.JsonFormat;

import java.util.Date;

public record PriceDTO(
@JsonFormat(shape = JsonFormat.Shape.STRING)
Date date,
float price) { }