4. 시스템 설계 및 엔지니어링 목표
개요
스프링 서버와 postgreSQL 을 사용하는 것으로 구상하였고, 세부 최적화는 PoC 구현이후에 진행하고자 했습니다.
API와 데이터베이스 DDL의 상세 설계를 하였습니다.
실시간으로 재무제표와 마켓 데이터를 업데이트할 수 있는 이벤트 기반 파이프라인 또한 설계를 하였습니다.
지난 포스팅 리뷰
FMP에서 사용한 API 요약
| API 구분 | Endpoint (Base URL) | 주요 파라미터 | 데이터 용도 |
|---|---|---|---|
| 일별 종가 데이터 | /historical-price-eod/full | symbol | 상장 이후 전체 주가 이력 수집 (수익률 계산 및 백테스팅용) |
| 현금흐름표 | /cash-flow-statement | limit=5, period=annual | 최근 5년치 연간 현금흐름 분석 (영업현금흐름, FCF 확인용) |
| 재무상태표 | /balance-sheet-statement | limit=5, period=annual | 최근 5년치 연간 자산/부채/자본 확인 (PBR, 부채비율 계산용) |
- 입력
- ROIC(마진의 간접적인 지표), FCF(현금흐름)등의 지표들 (이하 필터)
- 보유기간
- 출력
- 필터에 맞는 기업들의 평균 수익률
- 필터에 맞는 최신 기업들 리스팅
위의 표에 나와있는 api들을 이용하여 입력에 대해 출력을 뽑아낼 수 있는 데이터를 저장하려고 했다.
시스템 설계
단순한 CRUD 시스템이라고 생각이 되었고, Spring서버와 pg(PostgreSQL)를 사용한다.
흥미를 가지고 진행하기위해, 먼저 빠르게 PoC를 쳐내고, 이후에 최적화나 설계를 변경하는 방식으로 하려한다.
fmp API 상세
FMP api 명세 링크에서 지원하는 API 명세서를 확인할 수 있다.
필요한 api는 아래와 같다.
- Ticker의 리스트 (APPL, TSLA ... 등 주식의 id의 리스트)
- Ticker에 대응하는 5개년치 재무제표, 현금 흐름표
- Ticker에 대응하는 5개년치 가격 정보
명세
- Ticker의 리스트 (APPL, TSLA ... 등 주식의 id의 리스트)
- 해당 명세는 찾을 수가 없었다. Ticker를 찾기 위해 위키피디아를 크롤링하였다.
- S&P 500 외에도, 나스닥 100, 다우존스의 리스트를 크롤링하였다.
- 재무제표, 현금흐름표의 경우 걸려있는 링크와 같이 찾을 수 있었다.
- 마지막으로 가격정보 또한 링크와 같이 찾을 수 있었다.
예외처리
- Ticker 리스트에서 버크셔 해서웨이는 BRK.B 로 나와있는데, fmp와 호환되게 BRK-B 로 바꿔야한다.
- 재무제표와 현금흐름표의 경우, 해당 회사의 국가 기준으로 작성되기때문에 변환 작업이 필요하다.
- tickers_curr.json 파일을 만들어 Ticker와 달러와의 환율을 미리 저장해둔다.
- 데이터베이스에 저장할때 환율을 곱하여 달러기준으로 저장하여 데이터 일관성을 유지한다.
- 편의상 고정환율로 정의함
- 가격정보의 경우는 국가와 상관없이 달러로 표시되므로 그대로 사용하여도 무방하다.
tickers_curr.json의 일부 데이터
[
{ "ticker": "A", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AAPL", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ABBV", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ABNB", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ABT", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ACGL", "country": "Bermuda", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ACN", "country": "Ireland", "currency": "EUR", "usd_to_local_ratio": 0.8525 },
{ "ticker": "ADBE", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ADI", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ADM", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ADP", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ADSK", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AEE", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AEP", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AES", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AFL", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AIG", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AIZ", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AJG", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AKAM", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ALB", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ALGN", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ALL", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "ALLE", "country": "Ireland", "currency": "EUR", "usd_to_local_ratio": 0.8525 },
{ "ticker": "AMAT", "country": "USA", "currency": "USD", "usd_to_local_ratio": 1.0 },
{ "ticker": "AMCR", "country": "Switzerland", "currency": "CHF", "usd_to_local_ratio": 0.8842 }
]
데이터 스키마 설계
두가지의 테이블을 구상하였다.
- market_prices table : 매일매일의 가격을 저장하는 테이블
- company_fundamentals : 재무제표와 현금흐름표의 정보를 저장하는 테이블
또한 가격등의 정보를 저장할때 Double을 사용한다.
Double의 경우 CPU내부에 부동소수점을 담당하는 FPU가 있어 속도가 매우 빠르다.
반면 Decimal의 경우는 pg가 sw단에서 계산하기 때문에 느리다고 알려져있다.
프로젝트의 목적이 직접 트레이딩을 하는 것이 아니라, 분석의 보조 도구이기 때문에 실수의 오차 누적이 허용가능하다고 생각했다.
PG의 타입과 연산 비교
| 항목 | DOUBLE PRECISION | DECIMAL / NUMERIC | INTEGER |
|---|---|---|---|
| 연산 속도 | 매우 빠름 (기준점) FPU | 약 10~50배 느림 소프트웨어 | 가장 빠름 ALU |
| 저장 효율 | 매우 좋음 (고정 8자) | 보통 (숫자가 클수록 커짐) | 4바이트로 저장하므로 DOUBLE 보다 최적화를 할 여지가 있음 |
| 정확도 | 근사치 (소수점 오차 가능성) | 완벽한 정확도 | 완벽한 정확도 |
- 작은 데이터의 경우 INTEGER, 시가총액 같은 큰 데이터는 DOUBLE을 사용하는 것이 좋아보임
스키마 DDL
스키마 DDL
-- 기존 테이블 초기화
DROP TABLE IF EXISTS market_prices CASCADE;
DROP TABLE IF EXISTS company_fundamentals CASCADE;
-- 1. Market Data (일봉 데이터 + 데일리 밸류에이션)
CREATE TABLE market_prices (
ticker VARCHAR(10),
trade_date DATE,
open_price DOUBLE PRECISION,
high_price DOUBLE PRECISION,
low_price DOUBLE PRECISION,
close_price DOUBLE PRECISION NOT NULL,
volume BIGINT,
per DOUBLE PRECISION,
pbr DOUBLE PRECISION,
psr DOUBLE PRECISION,
fcf_yield DOUBLE PRECISION,
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (ticker, trade_date)
);
-- 2. Fundamentals
CREATE TABLE company_fundamentals (
ticker VARCHAR(10),
report_period DATE,
filing_date DATE NOT NULL,
revenue BIGINT,
operating_income BIGINT,
net_income BIGINT,
eps DOUBLE PRECISION,
shares_outstanding BIGINT,
fcf BIGINT,
total_assets BIGINT,
total_debt BIGINT,
total_equity BIGINT,
per DOUBLE PRECISION,
pbr DOUBLE PRECISION,
psr DOUBLE PRECISION,
market_cap BIGINT,
roe DOUBLE PRECISION,
roic DOUBLE PRECISION,
opm DOUBLE PRECISION,
debt_to_equity DOUBLE PRECISION,
revenue_growth DOUBLE PRECISION,
eps_growth DOUBLE PRECISION,
fcf_yield DOUBLE PRECISION,
consecutive_growth_years INT,
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (ticker, report_period)
);
CREATE INDEX idx_market_prices_covering ON market_prices (ticker, trade_date);
API 설계
goal
- 입력한 기술 지표에 따라 투자를 진행할 경우 1년후에 어느정도의 수익률을 얻는지 백테스팅
- 입력한 기술 지표에 대응하는 현재 구입가능한 주식들을 리스팅
non-goal
- 자동매매등의 로직
명세
-
/backtest
- request 에 대해 손절라인과 보유기간을 적용하여 수익률 테스팅을 하는 api
-
/findStock
- 지표에 대응하는 현재 구매가능한 주식들을 보여주는 api
-
공통 dto
| Field | 설명 |
|---|---|
| user_id | 요청을 전송한 유저의 id |
| indicators | 벡테스팅 및 원하는 주식에 대한 지표 |
- indicators 내부 필드
| Field | 설명 |
|---|---|
| yoy | 지난 연도 대비 성장률 |
| eps | 주당 이익률 |
| opm | 영업이익률 |
| opm_diff | 영업이익 성장률 |
| debt | 부채 |
| min_per | 최소 주가수익비율 (주가와 순이익의 비율) |
| max_per | 최대 주가수익비율 |
| sell_cut | 처음 산 가격대비 어느정도 떨어지면 손절할지에 대한 손절라인 |
| year | 최대 얼마나 들고 있을지에 대한 년 |
| max_pbr | 주가순자산비율 (주가와 자산의 비율) |
| max_psr | 주가매출비율 (주가와 매출의 비율) |
| fcf_yield | 영업현금흐름에서 자본지출을 뺀 것을 잉여현금흐름이라고 하는데, 이를 시가총액으로 나눈것 |
| roic | 부채 + 자본대비 얼마나 영업이익을 냈는지 측정하는 지표 |
cron 설계
주식 데이터는 실시간성이 중요하므로, 하루 주가 및 재무제표가 업데이트가 될때마다 지연시간 없이 디비에 잘 넣어주는 것이 중요하다.
마켓 데이터의 경우는 휴장일이 아닌 경우 미국 주식 시장 폐장 시간(한국 시간 새벽 4~5시)의 한시간뒤에 특정 타임윈도우 단위로 재시작하는 로직을 사용할 수 있다.
또한 오전 8시에 최종적으로 데이터를 확인하고, 누락된 배치를 마지막으로 더 돌리는 Double Check 도 사용가능하다.
재무제표 데이터의 경우는 기업마다 서로 다른 시기에 올리므로 처리가 더 까다롭다.
fmp에서 재무제표가 올라온 경우 rss-feed 형식의 api로 지원했으나, 지금은 지원을 더이상하지 않는다.
SEC-rss feed
그래서 직접 미국 정부 홈페이지에서 재무제표에 대한 알림을 받고, 그를 이벤트 기반 아키텍쳐로 저장하는 방식의 처리를 구상하였다.
- 아주 가벼운 웹서버를 이용해 최신 공시 이벤트를 찌른다.
- 아래의 url에서 당일의 최신 공시를 확인할 수 있음
https://www.sec.gov/cgi-bin/browse-edgar?action=getcurrent&output=atom
- 아래의 url에서 당일의 최신 공시를 확인할 수 있음
- 최신 공시가 발생할 경우, 해당 날짜와 티커를 kafka의 key로 적용해두어 중복처리가 되지 않게 설정한다.
- key가 중복되지 않을 경우, kafka에 push하여 최신 공시를 처리하라는 알림을 제공한다.
- Batch 어플리케이션은 그 이벤트가 발생할시, SEC의 CIK 에서 ticker로 변경 및 fmp api에서 해당 공시를 확인하고 디비에 집어넣는등 무거운 작업을 처리한다.
- 멱등성 보장 필요
- kafka의 key와 DB의 key를 일치 시켜 중복 메시지에 대한 일관성 보장
- Back-pressure 로직 필요
- 하루에 API 제한이 제한되어있고, 1분에 보낼 수 있는 개수가 정해져 있기 때문에, 무한히 촘촘히 재시도 하는 것이 아닌, concurrency나 poll 간격 조절이 필요함