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