본문 바로가기

개발 기록/미국 증시 분석 자동화 시스템 운영기

[운영기 B-1] 실적·경제지표 캘린더 구현 — 예정값과 실제값이 시간차로 들어올 때 DB 설계

데이터가 한 번에 들어오지 않는 구조

 

뉴스나 시장 지수는 수집 시점에 이미 완성된 데이터가 들어온다.
실적과 경제지표는 다르다.

 

월요일   실적 일정 수집 → ticker, report_date, eps_estimate만 있음
수요일   실적 발표     → eps_actual이 채워짐, BEAT/MISS 판정 가능
06:00   경제지표 수집 → indicator, forecast만 있음
09:00   경제지표 발표 → actual이 채워짐, Surprise 계산 가능

 

같은 행이 시간을 두고 두 번 업데이트된다.


이 구조를 어떻게 설계했는지가 이 편의 핵심이다.


 

실적 캘린더 — 3단계 데이터 라이프사이클

실적 데이터는 하나의 행이 3단계를 거친다.

1단계: 수집 (매주 월요일 06:10)
  → ticker, report_date, eps_estimate, revenue_estimate, hour 채워짐
  → eps_actual = NULL, result = NULL, blog_triggered = 0

2단계: 결과 업데이트 (매일 07:50)
  → eps_actual, revenue_actual 채워짐
  → result = BEAT / MISS / INLINE 판정
  → blog_triggered = 0 유지 (블로그 미생성)

3단계: 블로그 생성
  → blog_triggered = 1로 변경
  → tb_earnings_blog_draft에 초안 INSERT

blog_triggered 플래그가 핵심이다. 07:50 스케줄러는 매일 실행되지만,

이 플래그가 0인 신규 결과만 처리한다.

 

같은 실적 결과로 블로그가 두 번 생성되는 일은 없다.

BEAT/MISS/INLINE 판정 기준

double epsDiff = (epsActual - epsEstimate) / Math.abs(epsEstimate);

if (epsDiff > 0.03)       result = "BEAT";   // +3% 초과
else if (epsDiff < -0.03) result = "MISS";   // -3% 미만
else                       result = "INLINE"; // ±3% 이내

 

±3%를 기준으로 잡은 이유가 있다.

 

EPS가 $0.01 수준인 소형주는 0.1%만 차이 나도 수치상 차이가 크게 보인다.

절대값이 아닌 비율로 판단하고, 어느 정도의 오차는 허용해야 "진짜 서프라이즈"를 잡을 수 있다.

 

epsEstimate가 0이거나 null인 경우도 처리해야 한다.

예상치가 없는 종목은 INLINE으로 처리하고 블로그 생성 대상에서 제외한다.

[이미지 1 — 실적 데이터 라이프사이클 타임라인]


실적 블로그 — Claude 3단계 파이프라인

신규 실적 결과가 감지되면 EarningsBlogService가 자동으로 호출된다.
일간 블로그의 1회 호출과 달리, 3단계로 나눠서 Claude에 요청한다.

1단계: 실적 원인 분석
  → EPS 실제값 vs 예상치, 매출, 발표 타이밍(BMO/AMC)
  → "왜 이 결과가 나왔는가"

2단계: 시장 영향 분석
  → 섹터 연관성, 경쟁사 영향, 당일 주가 반응 컨텍스트
  → "시장이 어떻게 반응할 것인가"

3단계: 향후 전망
  → 다음 분기 guidance, 밸류에이션 변화
  → "투자자에게 무엇이 달라지는가"

각 단계마다 Telegram으로 진행 상황 알림([1/4], [2/4], [3/4], [4/4])이 온다.


4단계는 DALL-E 이미지 생성이다.

 

발표 시간(hour) 필드도 컨텍스트로 포함한다.

BMO(장 시작 전)와 AMC(장 마감 후)는 시장 반응 시점이 완전히 다르기 때문이다.


경제지표 파이프라인 — Surprise를 계산하는 구조

실적과 구조가 비슷하지만 두 가지가 다르다.
데이터가 하루 안에 예정→실제 순서로 들어오고, 판정 기준이 절대값(Surprise)이다.

06:00 — 당일 일정 수집

// UTC → KST 변환
String utcTime = event.getTime(); // "2025-01-15 13:30:00"
LocalDateTime kst = LocalDateTime.parse(utcTime, formatter)
    .atZone(ZoneId.of("UTC"))
    .withZoneSameInstant(ZoneId.of("Asia/Seoul"))
    .toLocalDateTime();

 

Finnhub의 시간 필드가 UTC 기준이라 변환이 필수다.

 

처음에 이 변환을 빠뜨려서 한국 시간으로 09:30 발표인 지표가 18:30으로 표시됐다.

수집과 동시에 Tier 분류가 붙는다.

// 키워드 매칭으로 Tier 결정
if (name.contains("CPI") || name.contains("NFP") ||
    name.contains("FOMC") || name.contains("PCE")) {
    tier = 1;
} else if (name.contains("PMI") || name.contains("GDP") ||
           name.contains("ISM") || name.contains("Retail")) {
    tier = 2;
} else {
    tier = 3;
}

이 Tier 정보가 06:20 이벤트 파이프라인의 ChatGPT 분석과 BlogService 프롬프트에 자동으로 포함된다.

 

경제지표 수집(06:00)이 이벤트 파이프라인 시작(06:20)보다 20분 먼저 끝나기 때문에 가능한 의존 구조다.

 

09:00 — 실제값 업데이트 + Surprise 계산

 

double surprise = actual - forecast;

String surpriseType;
if (surprise > 0.1)       surpriseType = "HOT";   // 예상보다 강함
else if (surprise < -0.1) surpriseType = "COOL";  // 예상보다 약함
else                      surpriseType = "INLINE";

 

Surprise 임계값 ±0.1은 대부분의 주요 지표에 적용 가능한 범위다.

CPI가 0.1% 포인트 이상 벗어나는 것은 시장에서 유의미한 서프라이즈로 본다.

 

블로그 생성 조건은 Tier 1 + (HOT 또는 COOL)이다.

 

Tier 2는 재작성 권고 알림만 보내고 블로그 자동 생성은 하지 않는다.

매일 Tier 2 지표마다 블로그가 생성되면 과잉이다.

 

[이미지 2 — 경제지표 Tier 분류 + Surprise 판정 다이어그램]


DB 스키마 설계 포인트


두 테이블에서 공통으로 쓴 패턴이 세 가지 있다.

UNIQUE KEY + ON DUPLICATE KEY UPDATE

수집을 여러 번 실행해도 안전하게 덮어쓴다.

```sql
-- 실적 캘린더
UNIQUE KEY uk_ticker_date (ticker, report_date)

-- 경제지표
UNIQUE KEY uk_indicator_time (indicator, release_time)

수동 재수집, 스케줄 재실행, 중복 API 응답 — 어떤 상황이 와도 데이터가 오염되지 않는다.

 

 

NULL로 시작하는 컬럼 설계

예정값과 실제값을 같은 행에 담아야 한다.

실제값이 아직 발표되지 않은 경우 NULL이 명시적인 상태다.

-- tb_earnings_calendar
eps_estimate  DECIMAL(10, 4),         -- 항상 있음
eps_actual    DECIMAL(10, 4) NULL,    -- 발표 전: NULL
result        VARCHAR(10) NULL,        -- 발표 전: NULL (BEAT/MISS/INLINE)
blog_triggered TINYINT(1) DEFAULT 0   -- 블로그 미생성: 0

 

result IS NULLresult = 'BEAT'는 의미가 다르다.

NULL은 아직 판정 전, BEAT는 판정 완료다.

이 차이를 명시적으로 다루는 설계가 스케줄러 쿼리를 단순하게 만든다.

 

 

blog_triggered 플래그의 역할

-- 07:50 스케줄러 처리 대상 쿼리
SELECT * FROM tb_earnings_calendar
WHERE DATE(report_date) = #{today}
  AND eps_actual IS NOT NULL
  AND blog_triggered = 0

eps_actual IS NOT NULL — 실제값이 채워진 것만.
blog_triggered = 0 — 아직 블로그 미생성인 것만.

이 두 조건이 있으면 스케줄러가 매일 실행돼도 이미 처리된 건은 건너뛴다.

 

 


EarningsView — 달력 UI로 교체한 이유

처음에는 데이터 테이블(v-data-table)로 구현했다.

목록 형태라 날짜를 기준으로 "이번 주에 어떤 실적이 있는지"를 한눈에 파악하기 어려웠다. (ISSUE-008)

 

달력 뷰로 교체하면서 날짜 셀에 색상 배지가 붙었다.

배지 색상 의미
초록 BEAT
빨강 MISS
주황 INLINE
파랑 발표 예정

 

셀 클릭 시 당일 전체 실적 목록과 상세 다이얼로그가 펼쳐진다.

 

EPS 예상/실제/차이, 매출, 발표 시간, 블로그 생성 여부를 한 화면에서 확인할 수 있다.

같은 날에 실적·경제지표·ETF 배당이 모두 있을 수 있다.

 

셀 내 칩이 3개를 초과하면 +N more로 축약해서 표시한다. 셀이 너무 복잡해지지 않도록 하기 위해서다.

 

 

[이미지 3 — EarningsView 달력 UI 목업]

운영하면서 발견한 엣지 케이스들

 

EPS estimate가 0인 경우

간혹 Finnhub에서 eps_estimate = 0.00을 반환한다. 분모가 0이면 ArithmeticException이 발생한다. eps_estimate가 0이거나 null이면 INLINE으로 처리하고 블로그 대상에서 제외한다.

 

장전 발표(BMO)와 장후 발표(AMC) 처리

실적 발표 시간에 따라 시장 반응 시점이 다르다. BMO라면 당일 시장이 반응하고, AMC라면 다음 날 반응한다.

Claude 블로그 프롬프트에 hour 필드를 포함해서 이 맥락이 분석에 반영되도록 했다.

 

경제지표 actual이 null인 경우

Finnhub에서 09:00에 재조회해도 actual이 null인 경우가 있다.

발표가 지연됐거나 데이터 수집이 늦는 경우다.

이때는 surpriseType = "NA"로 기록하고 블로그 생성 대상에서 제외한다.

다음 스케줄 실행 때 다시 시도한다.


정리

항목 실적 캘린더 경제지표 캘린더
수집 주기 매주 월요일 1회 매일 06:00
결과 업데이트 매일 07:50 매일 09:00
판정 기준 EPS 차이 ±3% Surprise ±0.1
블로그 생성 조건 신규 결과 + blog_triggered=0 Tier 1 + HOT/COOL + blog_triggered=0
중복 방지 UNIQUE KEY + blog_triggered UNIQUE KEY + blog_triggered
프론트엔드 EarningsView 통합 달력 EarningsView 통합 달력

 

시간차로 들어오는 데이터를 설계할 때 핵심은 상태를 명시적으로 표현하는 것이다.

NULL은 "아직 없음", blog_triggered = 0은 "처리 전", blog_triggered = 1은 "처리 완료".

이 세 가지 상태가 명확하면 스케줄러 로직이 단순해진다.

 

다음 편에서는 ETF 배당 파이프라인을 더 깊이 다룬다.

 

ETF 유형별로 GPT 프롬프트 전략이 어떻게 달라지는지,

변화율 계산 로직, 신규 ETF 추가가 코드 없이 가능한 설계를 기록한다.