모델 결과 테이블: 무거운 계산과 화면 사이의 보관함

2026. 10. 27. 15:48ㆍ카테고리 없음

화면 뒤의 데이터 판단 · 아키텍처 · database/repositories.py

database/repositories.py — 모델 결과 테이블: 무거운 계산과 화면 사이의 보관함

Passive 아키텍처 도식에서 "결과 테이블" 상자는 파일이 아니라 Supabase(PostgreSQL) 안의 테이블 묶음입니다. 여러 모델이 계산한 결과를 여기 저장해 두고, 점수 엔진과 API 는 이 저장값만 꺼내 씁니다. 이 글은 그 상자 안에 실제로 어떤 테이블이 있고, 누가 넣고 누가 꺼내는지를 코드 기준으로 따라갑니다.


1. 결과 테이블은 흐름의 어디에 있나

📍 적용 화면 — 화면에 직접 노출되지 않음(모델과 화면 사이의 저장 단계).

① 수집기 원시 테이블 · 시세·지표 원본 시계열
↓ 원시 시계열
② 모델들 · 국면 모델 · 이례성(평소 이탈도) · 경로 예측 — 배치(정해진 시간 자동 실행)에서 계산
↓ upsert (있으면 덮어쓰고 없으면 추가)
③ 결과 테이블 · 계산 끝난 결과만 날짜별로 보관
↓ fetch (꺼내 읽기)
④ 합성 점수 엔진 · API · score_engine.py · /api/chart/predict 등
↓
⑤ 앱 화면 · 홈 지표 카드 · 차트 AI 예측 · 섹터 · 시장 평소 이탈도

그림 1. 아키텍처 도식(.omm/overall-architecture/processors)의 화살표를 그대로 옮긴 흐름.


2. 상자 안에 들어 있는 테이블

📍 적용 화면 — 화면에 직접 노출되지 않음(아래 4장에서 각 테이블이 닿는 화면을 정리).

도식에는 "chart_predict · regime 등"으로만 적혀 있지만, 모델 쪽 화살표를 따라 저장 함수(upsert_*)를 찾으면 아래 6개입니다. 도식의 chart_predict 는 실제 테이블 이름이 chart_predict_result 입니다.

테이블 담는 것 넣는 곳 한 줄의 기준
noise_regime 시장이 잡음(Noise)인지 신호(Signal)인지 국면 scheduler/job.py run_pipeline · processor/feature1_regime.py 지역+날짜
sector_cycle_result 경기국면 확률·국면별 강한 섹터 scheduler/job.py · scheduler/job_kr.py 지역+날짜
crash_surge_result 폭락·급등 전조 api/routers/crash_surge.py · scripts/train_kr_crash_surge.py 지역+날짜
anomaly_daily 시장이 평소에서 얼마나 벗어났나(이상 탐지) scheduler/job.py · scheduler/job_kr.py · 과거분은 scripts/backfill_anomaly.py 지역+날짜
chart_predict_result 종목별 앙상블 30일 예측 경로(실제값·예측값) scripts/run_chart_predict_batch.py 지역+날짜+종목
chart_predict_history 기준일마다 냈던 예측 경로 기록 scripts/backfill_predict_history.py 지역+종목+기준일

표 1. 저장 함수와 호출 위치는 database/repositories.py 와 각 파일에서 확인. 도식은 "등"으로만 표기해, 이 6개가 도식 작성자가 뜻한 전부인지는 도식만으로는 알 수 없습니다.

지역별로 나눠 담는 구조 — 모든 테이블에 region(us / kr) 칸이 있어서, 한 테이블에 미국·한국 결과가 같이 들어갑니다.

하루 1줄 (지역별) noise_regime · sector_cycle_result · crash_surge_result · anomaly_daily
하루 종목 수만큼 chart_predict_result · chart_predict_history

3. 넣는 방식 — upsert, 같은 날 다시 돌려도 한 줄

📍 적용 화면 — 화면에 직접 노출되지 않음(배치가 저장할 때).

저장은 모두 upsert 입니다. "지역+날짜"(예측은 +종목) 조합이 이미 있으면 덮어쓰고, 없으면 새로 추가합니다. 배치가 실패해서 같은 날 두 번 돌아도 줄이 두 개로 늘지 않습니다.

상황 region date ticker 결과
첫 실행 us 오늘 SPY 새 줄 추가
같은 날 재실행 us 오늘 SPY 그 줄을 덮어씀
다른 종목 us 오늘 QQQ 새 줄 추가

그림 2. chart_predict_result 의 기준(지역+날짜+종목)으로 본 upsert 동작(예시 값).

database/repositories.py · upsert_chart_predict() · 607~615줄

# 예측 결과 한 줄(record)과 지역을 받아 저장한다 (기본 지역은 미국)
def upsert_chart_predict(record: dict, region: str = 'us') -> None:
    # 함수 설명: 지역+날짜+종목이 같으면 덮어쓴다
    """앙상블 예측 결과를 chart_predict_result 테이블에 upsert합니다. (region+date+ticker 기준)"""
    # 받은 결과에 지역 칸을 붙인 새 묶음을 만든다
    record = {**record, 'region': region}
    # 실제값·예측값 두 칸을 차례로 본다
    for key in ('actual', 'predicted'):
        # 그 칸이 목록이면
        if isinstance(record.get(key), list):
            # JSON 글자로 바꿔서 한 칸에 통째로 넣을 수 있게 한다
            record[key] = json.dumps(record[key], ensure_ascii=False)
    # Supabase 접속 도구를 가져온다
    client = get_client()
    # 지역+날짜+종목이 겹치면 덮어쓰고, 아니면 새 줄로 넣는다
    client.table("chart_predict_result").upsert(record, on_conflict="region,date,ticker").execute()
    # 저장 완료를 로그로 남긴다
    print(f"[DB] chart_predict_result {record['date']} ({record['ticker']}, {region}) upsert 완료")

예측 경로처럼 목록으로 된 값은 json.dumps 로 글자로 바꿔 한 칸에 통째로 넣습니다. 다른 테이블들도 확률·섹터 목록 같은 값을 같은 방식으로 담습니다. 꺼내는 쪽은 그 글자를 다시 목록으로 풀어서 씁니다(다음 장).


4. 꺼내 쓰는 쪽 — 요청 때는 모델을 돌리지 않는다

📍 적용 화면 — 아래 표의 각 화면.

꺼내는 API 읽는 테이블 호출하는 화면 코드
/api/chart/predict chart_predict_result 차트 화면의 AI 예측 버튼 (static/js/chart.js)
/api/sector-cycle/current sector_cycle_result 홈 지표 카드 (home-toss.js) · 섹터 화면 (sector.js)
/api/anomaly/current anomaly_daily 홈 지표 카드 · 시장 평소 이탈도 화면 (anomaly.js)
시황 요약 API (market_summary.py) noise_regime · sector_cycle_result · anomaly_daily 시황 요약에 국면·분위기를 함께 넣음

표 2. /api/chart/predict 의 설명 주석은 "배치가 저장한 30일 예측 조회. 요청 경로에서는 모델을 실행하지 않는다."

합성 점수 엔진도 같은 테이블을 씁니다. processor/score_engine.py 는 지수 대표 종목(미국 SPY · 한국 KODEX200 069500)의 예측을 꺼내 "AI 30일 예측" 요인 점수를 만듭니다.

processor/score_engine.py · _subscores() · 92~99줄

    # 아래에서 실패하면 이 요인만 비우고 넘어간다
    try:
        # 결과 테이블에서 대표 종목의 최신 예측 한 줄을 꺼낸다
        cp = fetch_chart_predict(tkr, region=region)
        # 글자로 저장된 예측값을 다시 묶음으로 푼다 (없으면 빈 묶음)
        pp = _j(cp["predicted"]) if cp else {}
        # 예측 경로 목록을 꺼낸다
        fc = pp.get("forecast") or []
        # 글자로 저장된 실제값도 다시 목록으로 푼다
        act = _j(cp["actual"]) if cp and cp.get("actual") else []
        # 실제값의 마지막 종가 = 현재가
        cur_px = float(act[-1]["close"]) if act else None
        # 예측 경로의 마지막 날 중앙값 = 30일 뒤 예상가
        med = float(fc[-1]["median"]) if fc else None
        # 기대수익률에 300 을 곱해 50 점에 더하고 0~100 으로 자른다 (값이 없으면 비움)
        s["ai_predict"] = _clip100(50 + (med / cur_px - 1) * _AI_RET_SCALE) if (med and cur_px) else None
현재가
500
· 30일 뒤 중앙값
515
→ 기대수익률
+3%
→ 50 + 0.03×300
59점

그림 3. AI 예측 요인 점수 계산(예시 입력). 기대수익률 10% 면 +30점, 0~100 밖은 잘라냅니다.


5. 왜 이렇게 나눴나

📍 적용 화면 — 위 모든 화면의 로딩 속도.

방식 사용자가 화면을 열 때 서버가 하는 일
요청마다 계산 ████████████████████ 원시 데이터 읽기 → 모델 실행 → 응답 (사용자 수만큼 반복)
결과 테이블 ██░░░░░░░░░░░░░░░░░░ 저장된 한 줄 읽기 → 응답 (계산은 배치가 하루 한 번)

그림 4. 막대는 일의 양을 개념적으로 비교한 것(측정값 아님).

  • 계산은 한 번, 읽기는 여러 번 — 모델은 배치가 정해진 시간에 돌리고, 화면은 저장값만 읽습니다.
  • 재실행에 안전 — upsert 라서 배치를 다시 돌려도 중복 줄이 안 생깁니다.
  • 과거 기록이 남음 — 날짜별로 쌓이니 추이 차트(예: 평소 이탈도 10년 추이)를 그릴 수 있고, chart_predict_history 로 과거 예측이 맞았는지도 되짚을 수 있습니다.
  • 한 저장값을 여러 곳이 공유 — 같은 anomaly_daily 한 줄을 홈 카드·이탈도 화면·시황 요약이 함께 씁니다.

정리

정체 파일이 아니라 Supabase 안의 모델 결과 테이블 6개 묶음.
넣기 배치·학습 스크립트가 upsert_* 로 지역+날짜(예측은 +종목) 기준 덮어쓰기.
꺼내기 API·점수 엔진이 fetch_* 로 최신 줄만 읽음, 요청 때 모델 실행 없음.
이점 화면이 빠르고, 재실행에 안전하고, 날짜별 기록이 남는다.

이 글의 점수·예측은 투자 참고용 정보이며 수익을 보장하지 않습니다.

주식 데이터 분석 앱 → dinsightlab.com