Ver código fonte

add stock info endpoint

Daniel Bohry 8 meses atrás
pai
commit
bd792a8e06

+ 1 - 1
build.gradle

@@ -27,7 +27,7 @@ dependencies {
 
     compileOnly 'org.projectlombok:lombok:1.18.30'
     annotationProcessor 'org.projectlombok:lombok:1.18.30'
-    
+
     testImplementation 'org.springframework.boot:spring-boot-starter-test'
 }
 

+ 9 - 2
src/main/java/com/danielbohry/stocks/api/stock/StockController.java

@@ -2,6 +2,7 @@ package com.danielbohry.stocks.api.stock;
 
 import com.danielbohry.stocks.context.UserContextHolder;
 import com.danielbohry.stocks.domain.Quote;
+import com.danielbohry.stocks.domain.StockInfo;
 import com.danielbohry.stocks.service.StockInfoService;
 import com.danielbohry.stocks.service.StockService;
 import io.swagger.v3.oas.annotations.Hidden;
@@ -28,7 +29,7 @@ import static java.time.LocalDateTime.now;
 public class StockController {
 
     private final StockService service;
-    private final StockInfoService stockInfoService;
+    private final StockInfoService infoService;
 
     @GetMapping
     public ResponseEntity<List<Quote>> find(@RequestParam(value = "q", required = false) String query) {
@@ -45,6 +46,12 @@ public class StockController {
         return ResponseEntity.ok(response);
     }
 
+    @GetMapping("{code}/info")
+    public ResponseEntity<StockInfo> getStockInfo(@PathVariable String code) {
+        StockInfo response = infoService.get(code);
+        return ResponseEntity.ok(response);
+    }
+
     @Hidden
     @PostMapping("/upload-csv")
     public ResponseEntity<StockUploadCSVResponse> uploadCsvFile(@RequestParam("file") MultipartFile file, @RequestParam("currency") String currency) {
@@ -71,7 +78,7 @@ public class StockController {
     @PostMapping("/generate")
     public ResponseEntity<Void> generateStockInfo() {
         if (UserContextHolder.isAdmin()) {
-            stockInfoService.generate();
+            infoService.generate();
         }
 
         return ResponseEntity.ok().build();

+ 53 - 0
src/main/java/com/danielbohry/stocks/client/FinanceClient.java

@@ -0,0 +1,53 @@
+package com.danielbohry.stocks.client;
+
+import com.danielbohry.stocks.domain.Quote;
+import com.danielbohry.stocks.domain.StockInfo;
+import com.danielbohry.stocks.repository.StockRepository.StockMetadataResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.ObjectUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.time.Instant.now;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class FinanceClient {
+
+    private final StockClient client;
+
+    @Value("${clients.stock.key}")
+    private String key;
+
+    public List<StockInfo> fetchStockInfo(List<Quote> quotes) {
+        List<StockInfo> response = new ArrayList<>();
+        try {
+            String[] tickers = quotes.stream()
+                .map(Quote::getCode)
+                .filter(ObjectUtils::isNotEmpty)
+                .toArray(String[]::new);
+
+            List<StockMetadataResponse> metadata = client.getMetadata(key, String.join(",", tickers));
+
+            metadata.forEach(meta -> response.add(StockInfo.builder()
+                .code(meta.getTicker().toUpperCase())
+                .name(meta.getName())
+                .headquarters(meta.getLocation())
+                .industry(meta.getSector() + " - " + meta.getIndustry())
+                .companyWebsite(meta.getCompanyWebsite())
+                .secWebsite(meta.getSecWebsite())
+                .updatedAt(now())
+                .build()));
+            return response;
+        } catch (Exception e) {
+            log.error(e.getMessage());
+            return null;
+        }
+    }
+
+}

+ 0 - 3
src/main/java/com/danielbohry/stocks/client/InferenceClient.java

@@ -42,7 +42,6 @@ public class InferenceClient {
         {
           "founded": "<4-digit year or 'unknown'>",
           "ipo": "<4-digit year or 'unknown'>",
-          "exchange": "<stock exchange name or 'unknown'>",
           "headquarters": "<city and state/country or 'unknown'>",
           "industry": "<industry sector or 'unknown'>",
           "description": "<description or 'unknown'>"
@@ -97,10 +96,8 @@ public class InferenceClient {
                 .description(parsed.getDescription())
                 .foundation(parsed.getFounded())
                 .ipo(parsed.getIpo())
-                .exchange(parsed.getExchange())
                 .headquarters(parsed.getHeadquarters())
                 .industry(parsed.getIndustry())
-                .updatedAt(now())
                 .build();
         } catch (Exception e) {
             log.error("Failed to parse inference response [{}]", inference, e);

+ 7 - 1
src/main/java/com/danielbohry/stocks/client/StockClient.java

@@ -1,6 +1,8 @@
 package com.danielbohry.stocks.client;
 
+import com.danielbohry.stocks.config.FeignConfig;
 import com.danielbohry.stocks.repository.StockRepository.StockInfoResponse;
+import com.danielbohry.stocks.repository.StockRepository.StockMetadataResponse;
 import com.danielbohry.stocks.repository.StockRepository.StockQuoteResponse;
 import org.springframework.cloud.openfeign.FeignClient;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -9,7 +11,7 @@ import org.springframework.web.bind.annotation.RequestParam;
 
 import java.util.List;
 
-@FeignClient(name = "stockClient", url = "${clients.stock.url}")
+@FeignClient(name = "stockClient", url = "${clients.stock.url}", configuration = FeignConfig.class)
 public interface StockClient {
 
     @GetMapping("daily/{symbol}/prices")
@@ -20,4 +22,8 @@ public interface StockClient {
     StockInfoResponse getStockInfo(@PathVariable("symbol") String symbol,
                                    @RequestParam("token") String apiKey);
 
+    @GetMapping("fundamentals/meta")
+    List<StockMetadataResponse> getMetadata(@RequestParam("token") String apiKey,
+                                            @RequestParam("tickers") String tickers);
+
 }

+ 24 - 0
src/main/java/com/danielbohry/stocks/config/FeignConfig.java

@@ -0,0 +1,24 @@
+package com.danielbohry.stocks.config;
+
+import feign.RequestInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+@Configuration
+public class FeignConfig {
+
+    @Bean
+    public RequestInterceptor customTickerInterceptor() {
+        return template -> {
+            if (template.url().contains("/fundamentals/meta") &&
+                template.queries().containsKey("tickers")) {
+
+                List<String> tickerList = template.queries().get("tickers").stream().map(i -> i.replaceAll("%2C", ",")).toList();
+                template.query("tickers", (String[]) null);
+                template.query("tickers", String.join(",", tickerList));
+            }
+        };
+    }
+}

+ 20 - 1
src/main/java/com/danielbohry/stocks/domain/StockInfo.java

@@ -5,10 +5,11 @@ import lombok.Data;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.mongodb.core.mapping.Document;
 
+import java.math.BigDecimal;
 import java.time.Instant;
 
-@Builder
 @Data
+@Builder
 @Document("stock-infos")
 public class StockInfo {
 
@@ -21,6 +22,24 @@ public class StockInfo {
     private String exchange;
     private String headquarters;
     private String industry;
+    private String companyWebsite;
+    private String secWebsite;
+    private Stats stats;
+
     private Instant updatedAt;
 
+    @Data
+    @Builder
+    public static class Stats {
+
+        private BigDecimal marketCap;
+        private BigDecimal revenue;
+        private BigDecimal ebitda;
+        private BigDecimal earningPerShare;
+        private BigDecimal priceToEarning;
+        private BigDecimal priceToGrowth;
+        private Long sharesFloat;
+
+    }
+
 }

+ 17 - 0
src/main/java/com/danielbohry/stocks/repository/StockRepository.java

@@ -146,4 +146,21 @@ public class StockRepository {
         private String exchange;
     }
 
+    @Data
+    @AllArgsConstructor
+    @NoArgsConstructor
+    @JsonIgnoreProperties(ignoreUnknown = true)
+    public static class StockMetadataResponse {
+        private String ticker;
+        private String name;
+        private String sector;
+        private String industry;
+        private String location;
+        private String companyWebsite;
+        @JsonProperty("reportingCurrency")
+        private String currency;
+        @JsonProperty("secFilingWebsite")
+        private String secWebsite;
+    }
+
 }

+ 18 - 13
src/main/java/com/danielbohry/stocks/service/StockInfoService.java

@@ -1,8 +1,9 @@
 package com.danielbohry.stocks.service;
 
-import com.danielbohry.stocks.client.InferenceClient;
+import com.danielbohry.stocks.client.FinanceClient;
 import com.danielbohry.stocks.domain.Quote;
 import com.danielbohry.stocks.domain.StockInfo;
+import com.danielbohry.stocks.exception.NotFoundException;
 import com.danielbohry.stocks.repository.StockInfoRepository;
 import com.danielbohry.stocks.repository.StockRepository;
 import lombok.AllArgsConstructor;
@@ -11,7 +12,6 @@ import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
-import java.util.Optional;
 import java.util.Set;
 
 import static java.util.stream.Collectors.toSet;
@@ -21,10 +21,15 @@ import static java.util.stream.Collectors.toSet;
 @AllArgsConstructor
 public class StockInfoService {
 
-    private InferenceClient client;
+    private FinanceClient client;
     private StockRepository stockRepository;
     private StockInfoRepository infoRepository;
 
+    public StockInfo get(String code) {
+        return infoRepository.findById(code)
+            .orElseThrow(() -> new NotFoundException("No stock found with id: " + code));
+    }
+
     @Async("stockInfoExecutor")
     public void generate() {
         List<Quote> stocks = stockRepository.findAll();
@@ -37,18 +42,18 @@ public class StockInfoService {
             .map(StockInfo::getCode)
             .collect(toSet());
 
-        stocks.stream()
+        List<Quote> request = stocks.stream()
             .filter(stock -> !existingIds.contains(stock.getCode()))
             .filter(stock -> !stock.getCode().contains(":"))
-            .forEach(stock -> {
-                try {
-                    log.info("Generating stock info for {}", stock.getCode());
-                    Optional.ofNullable(client.infer(stock))
-                        .ifPresent(info -> infoRepository.save(info));
-                } catch (Exception e) {
-                    log.error("Failed to infer stock info for code: {}", stock.getCode(), e);
-                }
-            });
+            .toList();
+
+        int batchSize = 20;
+        for (int i = 0; i < request.size(); i += batchSize) {
+            int end = Math.min(i + batchSize, request.size());
+            List<Quote> batch = request.subList(i, end);
+            List<StockInfo> stockInfos = client.fetchStockInfo(batch);
+            infoRepository.saveAll(stockInfos);
+        }
     }
 
 }

+ 5 - 1
src/main/java/com/danielbohry/stocks/service/StockService.java

@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.cache.annotation.Cacheable;
 import org.springframework.stereotype.Service;
 
+import java.util.Comparator;
 import java.util.List;
 import java.util.Objects;
 
@@ -21,7 +22,10 @@ public class StockService {
 
     @Cacheable("allStockQuotes")
     public List<Quote> getAll() {
-        return repository.findAll();
+        return repository.findAll()
+            .stream()
+            .sorted(Comparator.comparing(Quote::getName))
+            .toList();
     }
 
     @Cacheable(value = "stockQuotesQuery", key = "#query")