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
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dev-api-price:
docker-compose up -d
./gradlew :app-api-price:bootRun
41 changes: 40 additions & 1 deletion app/app-api-price/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import java.net.Socket

plugins {
java
id("org.springframework.boot") version "3.4.7"
Expand Down Expand Up @@ -25,15 +27,52 @@ repositories {

dependencies {
implementation(project(":shared"))
implementation(project(":domain:domain-price"))
implementation("org.springframework.boot:spring-boot-starter-jdbc")
runtimeOnly("org.postgresql:postgresql")
implementation(project(":infra"))

implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9")

compileOnly("org.projectlombok:lombok")
developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.projectlombok:lombok")

developmentOnly("org.springframework.boot:spring-boot-devtools")

testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

tasks.withType<Test> {
useJUnitPlatform()
}

fun waitForRedis(host: String, port: Int, timeoutSeconds: Int = 30) {
val deadline = System.currentTimeMillis() + timeoutSeconds * 1000
while (System.currentTimeMillis() < deadline) {
try {
Socket(host, port).use { return }
} catch (_: Exception) {
Thread.sleep(500)
}
}
throw RuntimeException("Redis at $host:$port not available after $timeoutSeconds seconds.")
}

tasks.register("waitForRedis") {
doLast {
println("⏳ Waiting for Redis to become available...")
waitForRedis("localhost", 6379)
println("✅ Redis is ready!")
}
}

tasks.register<Exec>("composeUp") {
workingDir = rootDir
commandLine = listOf("docker", "compose", "up", "-d")
}

tasks.register("bootWithDocker") {
dependsOn("composeUp", "waitForRedis", "bootRun")
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
package com.polynomeer.app.api.price;

import com.polynomeer.domain.price.repository.PriceCacheProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@ComponentScan(basePackages = {
"com.polynomeer.app.api.price",
"com.polynomeer.domain.price",
"com.polynomeer.infra",
})
@EnableConfigurationProperties(PriceCacheProperties.class)
public class AppApiPriceApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.polynomeer.app.api.price.controller;

import com.polynomeer.domain.price.model.ChartPoint;
import com.polynomeer.domain.price.service.ChartQueryService;
import com.polynomeer.shared.common.dto.CommonResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.time.ZonedDateTime;
import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/charts")
public class ChartController {

private final ChartQueryService chartService;

@GetMapping("/{tickerCode}")
public CommonResponse<List<ChartPoint>> getChart(
@PathVariable String tickerCode,
@RequestParam String interval,
@RequestParam ZonedDateTime from,
@RequestParam ZonedDateTime to) {
List<ChartPoint> response = chartService.getChart(tickerCode, interval, from, to);
return new CommonResponse<>("SUCCESS", response);
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
package com.polynomeer.app.api.price.controller;

import com.polynomeer.app.api.price.Price;
import com.polynomeer.app.api.price.dto.PriceResponse;
import com.polynomeer.domain.price.service.PriceQueryService;
import com.polynomeer.domain.ticker.validation.TickerFormat;
import com.polynomeer.shared.common.dto.CommonResponse;
import com.polynomeer.shared.common.error.TickerErrorCode;
import com.polynomeer.shared.common.error.TickerNotFoundException;
import com.polynomeer.shared.common.error.TickerValidationException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/quotes")
Expand All @@ -21,6 +25,8 @@
@Tag(name = "Quotes", description = "Operations related to stock quotes")
public class PriceController {

private final PriceQueryService priceQueryService;

@Operation(summary = "Get quote by ticker code", description = "Returns stock quote data for a given ticker code.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successful response"),
Expand All @@ -33,14 +39,17 @@ public CommonResponse<PriceResponse> getQuote(
) {
log.info("getQuote tickerCode={}", tickerCode);

if (tickerCode.equals("error")) {
throw new TickerNotFoundException(TickerErrorCode.TICKER_NOT_FOUND);
}
validateTickerCode(tickerCode);

var price = priceQueryService.getCurrentPrice(tickerCode);
var response = PriceResponse.from(price);
return new CommonResponse<>("SUCCESS", response);
}

return new CommonResponse<>(
"SUCCESS",
PriceResponse.from(
new Price("TEST", 1000L, 10L, 100.0, 10000L, null)
));
private void validateTickerCode(String tickerCode) {
if (!TickerFormat.isValid(tickerCode)) {
throw new TickerValidationException(TickerErrorCode.TICKER_INVALID);
}
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.polynomeer.app.api.price.dto;

import com.polynomeer.app.api.price.Price;
import com.polynomeer.domain.price.model.Price;

import java.time.ZonedDateTime;

Expand Down
30 changes: 30 additions & 0 deletions app/app-api-price/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
spring:
config:
activate:
on-profile: local

application:
name: app-api-price

data:
redis:
host: localhost
port: 6379

datasource:
url: jdbc:postgresql://localhost:5432/romanticker
username: romanticker
password: romanticker
driver-class-name: org.postgresql.Driver
sql:
init:
mode: always

logging:
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{traceId}] %msg%n"

level:
com.polynomeer.infra.redis: DEBUG
com.polynomeer.infra.timescaledb: DEBUG
com.polynomeer.domain.price: DEBUG
2 changes: 0 additions & 2 deletions app/app-api-price/src/main/resources/application.properties

This file was deleted.

10 changes: 10 additions & 0 deletions app/app-api-price/src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
spring:
application:
name: app-api-price

profiles:
active: local

logging:
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{traceId}] %msg%n"
10 changes: 10 additions & 0 deletions app/app-api-price/src/main/resources/data.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- AAPL 1분 단위 시세 예시
INSERT INTO price_history
(ticker_code, price, volume, "timestamp", exchange, currency, source)
VALUES ('AAPL', 19400, 100000, '2024-01-01T09:00:00Z', 'NASDAQ', 'USD', 'yahoo'),
('AAPL', 19420, 120000, '2024-01-01T09:01:00Z', 'NASDAQ', 'USD', 'yahoo'),
('AAPL', 19450, 150000, '2024-01-01T09:02:00Z', 'NASDAQ', 'USD', 'yahoo'),
('AAPL', 19380, 130000, '2024-01-01T09:03:00Z', 'NASDAQ', 'USD', 'yahoo'),
('AAPL', 19410, 160000, '2024-01-01T09:04:00Z', 'NASDAQ', 'USD',
'yahoo')
ON CONFLICT (ticker_code, "timestamp") DO NOTHING;
25 changes: 25 additions & 0 deletions app/app-api-price/src/main/resources/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
CREATE EXTENSION IF NOT EXISTS timescaledb;

CREATE TABLE IF NOT EXISTS price_history
(
ticker_code VARCHAR(20) NOT NULL,
"timestamp" TIMESTAMPTZ NOT NULL,
price BIGINT NOT NULL,
volume BIGINT NOT NULL,
exchange VARCHAR(20),
currency VARCHAR(10),
source VARCHAR(20)
);

ALTER TABLE price_history
DROP CONSTRAINT IF EXISTS price_history_pkey;
DROP INDEX IF EXISTS ux_price_history_id;
DROP INDEX IF EXISTS ux_price_history_ticker_only;

ALTER TABLE price_history
ADD CONSTRAINT price_history_pkey PRIMARY KEY (ticker_code, "timestamp");

SELECT create_hypertable('price_history', 'timestamp', if_not_exists => TRUE);

CREATE INDEX IF NOT EXISTS ix_price_history_ticker_ts_desc
ON price_history (ticker_code, "timestamp" DESC);
21 changes: 21 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
version: '3.8'
services:
redis:
image: redis:7
ports:
- "6379:6379"

timescaledb:
image: timescale/timescaledb:latest-pg15
container_name: timescaledb
ports:
- "5432:5432"
environment:
POSTGRES_USER: romanticker
POSTGRES_PASSWORD: romanticker
POSTGRES_DB: romanticker
volumes:
- timescale_data:/var/lib/postgresql/data

volumes:
timescale_data:
3 changes: 3 additions & 0 deletions domain/domain-price/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ repositories {
}

dependencies {
implementation(project(":shared"))
implementation("org.springframework.boot:spring-boot-starter")
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.polynomeer.domain.price.model;

import java.time.ZonedDateTime;

public record ChartPoint(
ZonedDateTime timestamp,
long open,
long high,
long low,
long close,
long volume
) {
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.polynomeer.app.api.price;
package com.polynomeer.domain.price.model;

import java.time.ZonedDateTime;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.polynomeer.domain.price.repository;

public interface BackoffStrategy {
void pause();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.polynomeer.domain.price.repository;

import com.polynomeer.domain.price.model.Price;

import java.util.Optional;

public interface CachePriceRepository {
Optional<Price> find(String tickerCode);

void save(String tickerCode, Price latestFromDb);

boolean saveIfAbsent(String tickerCode, Price price);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.polynomeer.domain.price.repository;

import com.polynomeer.domain.price.model.Price;

public interface ExternalPriceClient {
Price fetchPrice(String tickerCode);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.polynomeer.domain.price.repository;

import java.time.Duration;

public class FixedBackoff implements BackoffStrategy {
private final Duration d;

public FixedBackoff(Duration d) {
this.d = d;
}

@Override
public void pause() {
try {
Thread.sleep(d.toMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.polynomeer.domain.price.repository;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

import java.time.Duration;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

@Configuration
public class PriceCacheConfig {

@Bean
public Executor priceQueryExecutor() {
return Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
}

@Bean
@Profile("!test")
public BackoffStrategy fixedBackoff() {
return new FixedBackoff(Duration.ofMillis(30));
}

@Bean
@Profile("test")
public BackoffStrategy noOpBackoff() {
return () -> {
};
}
}
Loading
Loading