diff --git a/build.gradle b/build.gradle index 3e22d5a..c119a24 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/src/main/java/io/autoinvestor/application/AssetNotFoundException.java b/src/main/java/io/autoinvestor/application/AssetNotFoundException.java new file mode 100644 index 0000000..2f52f50 --- /dev/null +++ b/src/main/java/io/autoinvestor/application/AssetNotFoundException.java @@ -0,0 +1,9 @@ +package io.autoinvestor.application; + +import io.autoinvestor.exceptions.BadRequestException; + +public class AssetNotFoundException extends BadRequestException { + public AssetNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/io/autoinvestor/application/GetAssetPriceCommand.java b/src/main/java/io/autoinvestor/application/GetAssetPriceCommand.java new file mode 100644 index 0000000..f39f3f7 --- /dev/null +++ b/src/main/java/io/autoinvestor/application/GetAssetPriceCommand.java @@ -0,0 +1,5 @@ +package io.autoinvestor.application; + +import java.util.Date; + +public record GetAssetPriceCommand(String assetId, Date date) { } diff --git a/src/main/java/io/autoinvestor/application/GetAssetPriceCommandHandler.java b/src/main/java/io/autoinvestor/application/GetAssetPriceCommandHandler.java new file mode 100644 index 0000000..7ef5004 --- /dev/null +++ b/src/main/java/io/autoinvestor/application/GetAssetPriceCommandHandler.java @@ -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()); + } +} diff --git a/src/main/java/io/autoinvestor/application/GetAssetPriceResponse.java b/src/main/java/io/autoinvestor/application/GetAssetPriceResponse.java new file mode 100644 index 0000000..e1ecbc7 --- /dev/null +++ b/src/main/java/io/autoinvestor/application/GetAssetPriceResponse.java @@ -0,0 +1,6 @@ +package io.autoinvestor.application; + +import java.util.Date; + + +public record GetAssetPriceResponse(float price, Date date) { } diff --git a/src/main/java/io/autoinvestor/domain/Asset.java b/src/main/java/io/autoinvestor/domain/Asset.java index d30c2fb..e2d1ce0 100644 --- a/src/main/java/io/autoinvestor/domain/Asset.java +++ b/src/main/java/io/autoinvestor/domain/Asset.java @@ -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() { diff --git a/src/main/java/io/autoinvestor/domain/AssetId.java b/src/main/java/io/autoinvestor/domain/AssetId.java index 1429141..c2fbaed 100644 --- a/src/main/java/io/autoinvestor/domain/AssetId.java +++ b/src/main/java/io/autoinvestor/domain/AssetId.java @@ -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); + } } diff --git a/src/main/java/io/autoinvestor/domain/AssetPriceFetcher.java b/src/main/java/io/autoinvestor/domain/AssetPriceFetcher.java new file mode 100644 index 0000000..b94de53 --- /dev/null +++ b/src/main/java/io/autoinvestor/domain/AssetPriceFetcher.java @@ -0,0 +1,7 @@ +package io.autoinvestor.domain; + +import java.util.Date; + +public interface AssetPriceFetcher { + float priceOn(Asset asset, Date date); +} diff --git a/src/main/java/io/autoinvestor/domain/AssetRepository.java b/src/main/java/io/autoinvestor/domain/AssetRepository.java index 8b004db..1532ace 100644 --- a/src/main/java/io/autoinvestor/domain/AssetRepository.java +++ b/src/main/java/io/autoinvestor/domain/AssetRepository.java @@ -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 findById(AssetId assetId); } diff --git a/src/main/java/io/autoinvestor/infrastructure/fetchers/PriceFetchFailedException.java b/src/main/java/io/autoinvestor/infrastructure/fetchers/PriceFetchFailedException.java new file mode 100644 index 0000000..3acd3ed --- /dev/null +++ b/src/main/java/io/autoinvestor/infrastructure/fetchers/PriceFetchFailedException.java @@ -0,0 +1,7 @@ +package io.autoinvestor.infrastructure.fetchers; + +public class PriceFetchFailedException extends RuntimeException { + public PriceFetchFailedException(String message) { + super(message); + } +} diff --git a/src/main/java/io/autoinvestor/infrastructure/fetchers/PriceNotAvailableException.java b/src/main/java/io/autoinvestor/infrastructure/fetchers/PriceNotAvailableException.java new file mode 100644 index 0000000..ca82c0e --- /dev/null +++ b/src/main/java/io/autoinvestor/infrastructure/fetchers/PriceNotAvailableException.java @@ -0,0 +1,7 @@ +package io.autoinvestor.infrastructure.fetchers; + +public class PriceNotAvailableException extends RuntimeException { + public PriceNotAvailableException(String message) { + super(message); + } +} diff --git a/src/main/java/io/autoinvestor/infrastructure/fetchers/YFinanceAssetPriceFetcher.java b/src/main/java/io/autoinvestor/infrastructure/fetchers/YFinanceAssetPriceFetcher.java new file mode 100644 index 0000000..5e54fcf --- /dev/null +++ b/src/main/java/io/autoinvestor/infrastructure/fetchers/YFinanceAssetPriceFetcher.java @@ -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 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)); + } + } +} diff --git a/src/main/java/io/autoinvestor/infrastructure/repositories/AssetMapper.java b/src/main/java/io/autoinvestor/infrastructure/repositories/AssetMapper.java index 92fd922..253c88e 100644 --- a/src/main/java/io/autoinvestor/infrastructure/repositories/AssetMapper.java +++ b/src/main/java/io/autoinvestor/infrastructure/repositories/AssetMapper.java @@ -3,6 +3,8 @@ import io.autoinvestor.domain.Asset; import org.springframework.stereotype.Component; +import java.util.Date; + @Component class AssetMapper { @@ -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())); + } } diff --git a/src/main/java/io/autoinvestor/infrastructure/repositories/InMemoryAssetRepository.java b/src/main/java/io/autoinvestor/infrastructure/repositories/InMemoryAssetRepository.java index 196e2aa..0e65e66 100644 --- a/src/main/java/io/autoinvestor/infrastructure/repositories/InMemoryAssetRepository.java +++ b/src/main/java/io/autoinvestor/infrastructure/repositories/InMemoryAssetRepository.java @@ -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") @@ -24,6 +26,11 @@ public boolean exists(String mic, String ticker) { return assetStore.containsKey(mic + ":" + ticker); } + @Override + public Optional findById(AssetId assetId) { + return Optional.ofNullable(assetStore.get(assetId.value())); + } + public void clear() { assetStore.clear(); } diff --git a/src/main/java/io/autoinvestor/infrastructure/repositories/MongoAssetRepository.java b/src/main/java/io/autoinvestor/infrastructure/repositories/MongoAssetRepository.java index 390f6b3..88ec2e1 100644 --- a/src/main/java/io/autoinvestor/infrastructure/repositories/MongoAssetRepository.java +++ b/src/main/java/io/autoinvestor/infrastructure/repositories/MongoAssetRepository.java @@ -1,6 +1,7 @@ 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; @@ -8,6 +9,8 @@ import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository @Profile("prod") @@ -33,4 +36,10 @@ public boolean exists(String mic, String ticker) { .and("ticker").is(ticker)); return template.exists(q, AssetDocument.class); } + + @Override + public Optional findById(AssetId assetId) { + return Optional.ofNullable(template.findById(assetId.value(), AssetDocument.class)) + .map(mapper::toDomain); + } } diff --git a/src/main/java/io/autoinvestor/ui/GetAssetPriceController.java b/src/main/java/io/autoinvestor/ui/GetAssetPriceController.java new file mode 100644 index 0000000..4d40f4d --- /dev/null +++ b/src/main/java/io/autoinvestor/ui/GetAssetPriceController.java @@ -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 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); + } +} diff --git a/src/main/java/io/autoinvestor/ui/GlobalExceptionHandler.java b/src/main/java/io/autoinvestor/ui/GlobalExceptionHandler.java index 2a03b16..3a28a0a 100644 --- a/src/main/java/io/autoinvestor/ui/GlobalExceptionHandler.java +++ b/src/main/java/io/autoinvestor/ui/GlobalExceptionHandler.java @@ -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; @@ -27,6 +29,22 @@ public ResponseEntity handleBadRequestException(BadRequestExcepti .build(); } + @ExceptionHandler(PriceNotAvailableException.class) + public ResponseEntity handlePriceNotAvailableException(PriceNotAvailableException ex) { + return ErrorResponse.builder() + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .message(ex.getMessage()) + .build(); + } + + @ExceptionHandler(PriceFetchFailedException.class) + public ResponseEntity handlePriceFetchFailedException(PriceFetchFailedException ex) { + return ErrorResponse.builder() + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .message(ex.getMessage()) + .build(); + } + @ExceptionHandler(InternalErrorException.class) public ResponseEntity handleInternalErrorException(InternalErrorException ex) { return ErrorResponse.builder() diff --git a/src/main/java/io/autoinvestor/ui/PriceDTO.java b/src/main/java/io/autoinvestor/ui/PriceDTO.java new file mode 100644 index 0000000..934a485 --- /dev/null +++ b/src/main/java/io/autoinvestor/ui/PriceDTO.java @@ -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) { }