Open Net Worth
Open Net Worth is an open-source personal AI wealth assistant you run yourself: one ledger for your whole financial life, worked by an AI agent you control.
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
워렌 버핏·벤저민 그레이엄식 가치투자 기준으로 S&P 500 전 종목을 스코어링하는 웹 스크리너입니다. 저평가 우량주를 실시간으로 스캔하는 대신, 하루 1회 정도 배치로 재계산 → 결과를 캐시해서 즉시 서빙하는 구조입니다. 데이터는 SEC EDGAR(미국 증권거래위원회 공식 재무제표 API) + Yahoo Finance(시세) 조합으로 가져옵니다. 둘 다 완전 무료·키 불필요·일일 요청 한도 없음이라 별도 가입/설정 없이 바로 씁니다. 키 설정 없이도 data/fixtures.ts의 샘플 데이터로 바로 동작합니다 (대시보드 상단에 노란 배너로 표시됩니다). 키 발급이나 계정 가입 없이 바로 실행하면 됩니다: 종목당 SEC EDGAR 1회 + Yahoo Finance 1회, 총 2회 호출이며 S&P 500 전체(503종목) 기준 약 1,000회 — 두 소스 모두 일일 한도가 없어 언제든 원하는 만큼 재실행할 수 있습니다. 종목 수가 많아 한 번 실행에 10분 이상 걸릴 수 있습니다. npm run dev로 확인하면 배너가 사라지고 실제 데이터가 표시됩니다.檢視完整 README ↗
워렌 버핏·벤저민 그레이엄식 가치투자 기준으로 S&P 500 전 종목을 스코어링하는 웹 스크리너입니다. 저평가 우량주를 실시간으로 스캔하는 대신, 하루 1회 정도 배치로 재계산 → 결과를 캐시해서 즉시 서빙하는 구조입니다.
데이터는 SEC EDGAR(미국 증권거래위원회 공식 재무제표 API) + Yahoo Finance(시세) 조합으로 가져옵니다. 둘 다 완전 무료·키 불필요·일일 요청 한도 없음이라 별도 가입/설정 없이 바로 씁니다.
npm install
npm run dev
키 설정 없이도 data/fixtures.ts의 샘플 데이터로 바로 동작합니다 (대시보드 상단에 노란 배너로 표시됩니다).
키 발급이나 계정 가입 없이 바로 실행하면 됩니다:
npm run refresh
종목당 SEC EDGAR 1회 + Yahoo Finance 1회, 총 2회 호출이며 S&P 500 전체(503종목) 기준 약 1,000회 — 두 소스 모두 일일 한도가 없어 언제든 원하는 만큼 재실행할 수 있습니다. 종목 수가 많아 한 번 실행에 10분 이상 걸릴 수 있습니다. npm run dev로 확인하면 배너가 사라지고 실제 데이터가 표시됩니다.
종목 유니버스는 data/universe.json에서 관리합니다 (티커, 회사명, 섹터). 현재는 위키피디아의 S&P 500 구성종목 목록으로 채워져 있으며, 지수 구성은 분기마다 바뀌므로 최신 상태를 유지하려면 이 파일을 주기적으로 다시 생성해야 합니다.
CIK_OVERRIDES에 수동으로 매핑을 추가해 해결합니다.다섯 개 축을 각각 100점으로 따로 매기고, 중요도에 따라 가중평균해 종합 점수를 냅니다. 하나로 뭉뚱그리지 않는 이유는, "훌륭한 기업을 적당한 가격에"와 "평범한 기업을 아주 싸게"가 전혀 다른 투자이고 총점 하나로는 그 차이가 보이지 않기 때문입니다.
| 축 | 가중치 | 항목 (배점) |
|---|---|---|
| 사업의 질 | 30% | ROE 28 · ROIC 28 · 매출총이익률 수준/안정성 16 · 영업이익률 10 · FCF 마진 10 · 자사주 매입 8 |
| 저평가 | 25% | 안전마진 40 · PER 상대비교 25 · PEG 20 · 그레이엄 넘버 15 |
| 재무 안정성 | 20% | 부채비율 30 · 이자보상배율 25 · 유동비율 20 · 순부채/EBITDA 15 · 현금/부채 10 |
| 성장성 | 15% | 매출 CAGR 35 · EPS CAGR 35 · FCF CAGR 30 |
| 일관성 | 10% | 흑자 연수 40 · FCF 흑자 연수 35 · 매출성장 일관성 25 |
각 항목은 통과/실패가 아니라 구간을 선형 환산한 연속 점수입니다 (linScore()).
실제 수치·기준값·배점은 종목 상세 페이지에서 전부 공개되고, 지표 옆 ?에 초보자용 설명이 붙습니다.
종합 70점 이상 + 안전마진 양수 + 모든 축 커버리지 70% 이상인 종목에 "Buy Candidate" 태그가 붙습니다.
공시에 없는 숫자를 0점 처리하면 "확인해봤더니 나빴다"는 뜻이 됩니다. 실제로는 우리가 못 읽은 것뿐인데 말이죠. 그래서 필요한 숫자가 공시에 아예 없는 항목은 배점에서 통째로 빼고, 나머지 항목만으로 축 점수를 냅니다. 화면에는 "측정 불가"로 표시되고, 실패(빨간 X)와 다른 회색 대시로 구분됩니다.
경계는 좁게 잡았습니다 — 숫자가 있는데 나쁜 것은 측정된 것이고, 그건 받아 마땅한 0점을 받습니다. 적자, 자본잠식, 매출 감소는 전부 여기에 해당합니다. 이 구분이 없으면 적자 기업이 "PER 측정 불가"를 이유로 저평가 평가를 면제받는 일이 생깁니다.
축마다 커버리지(측정된 배점 비율)를 함께 저장해 화면에 표시합니다. 은행에 매출총이익률이나 유동비율이 없는 것처럼, 업종에 따라 애초에 존재하지 않는 항목이 있기 때문입니다.
단, 절반 미만이면 환산하지 않습니다. 측정된 항목이 나머지를 대표한다는 가정은 대부분을 측정했을 때나 성립합니다. 버크셔는 재무 안정성 5개 항목 중 1개(이자보상배율)만 읽히는데, 그 하나가 좋다고 환산하면 재무 안정성 100점이 나옵니다. 그래서 절반 아래로는 배점의 절반을 분모로 고정해, 표본이 얇으면 높은 점수가 아예 나올 수 없게 했습니다.
내재가치는 최근 3년 FCF 평균을 희석주식수로 나눈 주당 소유주이익에서 출발해, 5년 FCF CAGR(최대 12%)로 5년 투영한 뒤 할인율 9.5% · 영구성장률 2.5%로 할인합니다.
로직과 가중치는 lib/scoring.ts 상단에서 조정할 수 있습니다.
계산식을 바꿀 때는 SCORING_VERSION을 올리세요 — 모든 점수에 함께 저장되므로, 나중에 점수 추이 차트에서 "회사가 변한 것"과 "우리가 기준을 바꾼 것"을 구분할 수 있습니다.
스코어 breakdown 외에, 종목 상세 페이지를 열 때마다 아래 정보를 실시간으로 가져와 보여줍니다 (배치 캐시에 저장하지 않고 매 방문마다 새로 조회 — 특히 뉴스는 신선도가 중요해서):
data/universe.json)을 기준으로 언어간링크를 조회해 해당 언어(예: 한국어) 위키피디아 문서가 있으면 그 요약을, 없으면 영어 요약을 보여줍니다 (lib/wikipedia.ts)거래소:티커 형식으로 매핑)TradingView·Investing.com 같은 전문 트레이딩 화면을 기준으로 만들었습니다.
app/globals.css의 시맨틱 토큰(--surface, --ink-muted, --up, --down 등)으로 정의하고, 컴포넌트는 bg-surface·text-ink-muted처럼 토큰 유틸리티만 씁니다. 테마는 <html data-theme> 한 곳에서 바뀌므로 dark: 변형을 컴포넌트마다 붙일 필요가 없습니다. 첫 페인트 전에 인라인 스크립트가 저장된 값을 적용해 흰 화면이 번쩍이지 않습니다.data-theme 속성이 단일 진실 공급원이고, components/useTheme.ts가 useSyncExternalStore로 이를 구독합니다. 그래서 테마 토글과 TradingView 차트가 별도 상태 전달 없이 항상 같은 값을 봅니다 (차트는 생성 후 리스타일이 안 돼서 테마가 바뀌면 위젯을 다시 만듭니다).tabular-nums라 열이 흔들리지 않습니다. 점수는 숫자와 막대를 함께 그려 세로로 훑어보기 쉽게 했고, 상세 페이지 총점은 원형 게이지로 보여줍니다.?에 마우스를 올리면(모바일은 탭) 주식을 처음 보는 사람도 알아들을 수 있는 설명이 뜹니다. 문구는 messages/*.json의 glossary 네임스페이스 한 곳에 모여 있습니다. components/InfoTip.tsx는 말풍선을 포털 + position: fixed로 그리는데, 대부분의 ?가 스크롤되는 표 안에 있어서 그냥 붙이면 잘리기 때문입니다.position: fixed 오버레이로 구현하고 그 위에 네이티브 Fullscreen API를 얹는 방식인데, iOS 사파리와 앱 내 웹뷰에는 Element.requestFullscreen이 없어서 그것만 믿으면 모바일에서 확대가 안 되기 때문입니다. Esc로 닫힙니다.s3.tradingview.com)를 방문자 브라우저에서 직접 받아오기 때문에, 광고 차단 확장 프로그램·사내 방화벽·PC에 설치된 보안 프로그램의 HTTPS 검사(국내 은행/공공기관용 프로그램이 설치하는 루트 인증서 때문에 NET::ERR_CERT_AUTHORITY_INVALID가 납니다)로 막힐 수 있습니다. 실패하면 components/ChartUnavailable.tsx가 원인을 확인하는 순서대로 해결 방법을 안내하고, 다시 시도 버튼과 TradingView 새 창 링크를 제공합니다. 나머지 기능(스코어·재무·즐겨찾기)은 영향받지 않습니다.Supabase(무료 티어)로 이메일 회원가입/로그인과 즐겨찾기를 구현했습니다. Postgres 기반이라 나중에 모바일 앱(iOS/Android, React Native 등)에서도 같은 프로젝트의 REST API·SDK로 그대로 로그인/데이터를 공유할 수 있습니다.
.sql 파일을 번호 순서대로 실행 (이미 실행한 파일은 건너뛰세요)NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY 확인 → .env.local에 채우기 (.env.local.example 참고)로컬에서 잘 되다가 배포하면 로그인만 안 되는 경우, 대부분 아래 두 가지 중 하나입니다.
환경 변수를 빠뜨리고 배포하더라도 사이트 전체가 죽지는 않습니다 — lib/supabase/env.ts가 "아무도 로그인하지 않은 상태"로 떨어뜨려서 스크리너·종목 상세는 그대로 열리고, MY Page는 로그인 안내를 보여줍니다. 로그인 기능만 동작하지 않으므로, 로그인이 안 되면 이 값부터 확인하세요.
Site URL을 실제 도메인으로 지정하고, Redirect URLs에 https<도메인>/auth/callback을 추가합니다. 이게 없으면 로그인 후 localhost로 돌아가려다 실패합니다. 로컬 개발을 계속하려면 http://localhost:3000/auth/callback도 같이 남겨두세요.https<프로젝트>.supabase.co/auth/v1/callback)가 등록되어 있어야 합니다. 이 주소는 앱 도메인이 아니라 Supabase 주소이므로, 앱을 어디에 배포하든 한 번만 등록하면 됩니다. 발급받은 Client ID/Secret은 Supabase → Authentication → Providers → Google에 넣습니다.카카오 로그인은
account_email권한이 카카오의 비즈니스 인증(사업자등록번호 필요)을 거쳐야만 열리고, Supabase의 카카오 프로바이더가 이 권한을 항상 요청하기 때문에 개인 앱에서는 동작하지 않습니다. 그래서 구글 로그인만 지원합니다.
/login — 이메일+비밀번호로 로그인/회원가입 (app/[locale]/login/page.tsx)favorites 테이블에 (user_id, ticker, price_at_favorite, favorited_at)로 저장 — price_at_favorite는 찜한 시점의 주가입니다/mypage): 로그인한 유저의 즐겨찾기 목록을 서버에서 조회하고, 각 티커의 실시간 현재가(lib/price.ts)를 가져와 (현재가 − 찜한 시점 주가) / 찜한 시점 주가로 등락률을 계산해 보여줍니다proxy.ts(next-intl 미들웨어와 함께 실행)에서 매 요청마다 갱신됩니다 — lib/supabase/client.ts는 브라우저용, lib/supabase/server.ts는 Server Component/Route Handler용입니다data/scores.json은 스냅샷이라 npm run refresh를 돌리면 어제 점수가 사라집니다. 그래서 같은 점수를 Supabase score_history 테이블에도 함께 적재합니다 — 점수 추이 차트, "점수 급등" 알림, 백테스트는 전부 과거가 남아 있어야 가능합니다.
data/scores.json 그대로입니다. DB 왕복이 없고, 기존 동작이 바뀌지 않습니다.(ticker, as_of)가 기본키라 같은 날 배치를 다시 돌리면 그날 행을 덮어씁니다..env.local에만 두고 절대 커밋하거나 브라우저에 노출하지 마세요. NEXT_PUBLIC_ 접두사를 붙이면 Next.js가 클라이언트 번들에 넣어버립니다.npm run refresh는 정상 동작합니다. 히스토리 적재가 실패해도 스냅샷 저장은 이미 끝난 뒤라 배치 전체가 실패하지는 않습니다.npm run archive — 현재 scores.json을 그 파일의 생성 시각 기준으로 적재합니다..github/workflows/refresh.yml이 평일 23:00 UTC(미국 장 마감 몇 시간 뒤, 한국시간 다음 날 아침 8시)에 배치를 돌리고, 바뀐 data/scores.json을 커밋합니다. Vercel이 그 푸시를 받아 재배포하므로 사이트도 함께 갱신됩니다. Actions 탭에서 Run workflow로 수동 실행도 됩니다.
히스토리가 쌓이는 게 스케줄로 돌리는 이유입니다 — 안 돈 날은 시계열의 구멍입니다. (구멍이 생겨도 npm run backfill로 메울 수 있습니다. 그래서 하루 이틀 실패해도 치명적이지는 않습니다.)
저장소 Settings → Secrets and variables → Actions에 세 개가 등록돼 있어야 합니다: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY. .env.local과 같은 값입니다.
안전장치 — --max-failures=25. 배치가 덮어쓰는 건 사이트가 읽는 파일이고, 동시에 그날의 히스토리로 영구히 남습니다. 그래서 나쁜 실행은 안 도는 것보다 나쁩니다. 개별 종목 실패는 원래 몇 개씩 나지만(SEC에 쓸 만한 데이터가 아예 없는 종목들), 유니버스의 5%를 넘으면 그건 종목 문제가 아니라 데이터 소스가 죽었거나 러너 IP를 차단한 겁니다. 이때 스크립트는 scores.json과 히스토리 둘 다 손대지 않고 종료 코드 1로 끝냅니다.
npm run backfill # 최근 3년, 분기별
npm run backfill -- --years 5
npm run backfill -- --ticker LULU --dry-run # 적재 없이 계산 결과만 출력
SEC의 모든 재무 수치에는 공시일(filed) 이 붙어 있습니다. 이걸로 "그 날짜에 실제로 공개돼 있던 재무제표"만 골라내고(lib/xbrl.ts의 annualSeries(..., asOf)), 그날 종가를 붙이면 과거 시점의 점수가 재구성됩니다. 정정 공시도 자연히 처리됩니다 — 같은 회계연도가 여러 번 공시되는데 그중 "그 날짜 기준 가장 최근 것"을 쓰므로, 나중에 수정된 숫자가 과거로 새지 않습니다.
이 look-ahead 방지가 나중에 백테스트가 필요로 하는 바로 그 기능입니다. 2023년 백테스트가 2026년에야 공시된 숫자를 쓰면 전략이 실제보다 좋아 보입니다.
fetchTickerRawData)와 계산(buildTickerFinancials)이 분리돼 있어, 종목당 한 번만 받아 여러 시점을 메모리에서 계산합니다. 몇 년치를 백필하든 비용은 유니버스 1회 순회입니다.is_backfilled = true로 표시하고 차트에도 명시합니다. 그때 우리가 매긴 점수가 아니라, 그때 데이터에 현재 기준을 적용하면 나왔을 점수입니다.알려진 한계: 섹터 평균 PER이 오늘 기준 고정값이라 과거 시점에 적용하면 시대착오가 조금 있습니다 (저평가 축에만 영향).
연간 보고서 사이 기간에는 주가 말고 모든 숫자가 최대 1년까지 묵습니다. lib/quarterly.ts가 10-Q에서 최근 12개월(TTM) 실적을 만들어 붙여, 수익성·레버리지·성장이 분기마다 움직이게 합니다.
4개 분기를 더하는 방식은 쓰지 않습니다. 4분기는 10-Q로 공시되지 않고(10-K에서 1~3분기를 뺀 나머지), 3개월치 없이 누적만 보고하는 기업도 있기 때문입니다. 대신 기업들이 안정적으로 보고하는 세 값만 쓰는 항등식을 씁니다:
TTM = 현재 누적(YTD) + 직전 연간 − 1년 전 같은 기간 누적
설계 원칙 — 필요한 항목이 전부 같은 분기에 갖춰졌을 때만 TTM 행을 추가합니다. 하나라도 없으면 기존 연간 방식으로 남습니다. 반쪽짜리 행(1년치 매출에 1분기치 이익 같은)은 없느니만 못하기 때문입니다. 실제로 BRK-B(주식수 태깅 불량)와 HD(분기말 부채 태그 없음)가 이 규칙에 걸려 연간 방식을 유지합니다.
주의할 함정 두 가지가 실제로 있었고, 코드에 방어가 들어가 있습니다:
순이익 ÷ EPS로 유도하고, 직전 연간 주식수와 1.5배 이상 차이 나면 태깅 오류로 보고 TTM 행을 버립니다.대시보드 상단 "가중치 조정" 버튼으로 다섯 축의 상대적 중요도를 직접 정할 수 있습니다 (lib/customWeights.ts). 성장보다 안정성을 중시한다면 재무 안정성 슬라이더를 올리면 되고, 종합 점수·순위·Buy Candidate·평균 점수가 전부 그 기준으로 즉시 다시 계산됩니다.
서버는 이 계산에 관여하지 않습니다. 축별 점수(0~100)는 이미 data/scores.json에 있는 확정된 측정값이고, 종합은 그 다섯 숫자의 가중평균일 뿐입니다. 그래서 브라우저에서 499종목을 즉시 다시 섞는 것으로 충분합니다 — 서버 왕복도, DB 스키마 변경도 없습니다. coverage와 안전마진처럼 가중치와 무관한 값은 그대로 두고, 블렌드에 들어가는 총점만 다시 계산합니다.
슬라이더는 비율을 강제로 100%에 맞추지 않습니다. "합이 100이 되게 조절하라"는 건 만질 때마다 풀어야 하는 작은 퍼즐이라서, 대신 각 슬라이더가 상대적 중요도를 나타내고 전부 합해 나눈 값이 실제 가중치입니다. 다섯 개를 전부 0으로 내리면 나눗셈이 무너지므로 그 경우에만 기본 비율로 되돌립니다.
계정에 저장되지 않고 이 브라우저에만 남습니다. localStorage에 저장하며, 로드가 끝나기 전에 기본값으로 덮어쓰지 않도록 순서를 맞췄습니다 — 그렇지 않으면 저장된 커스텀 값이 새로고침할 때마다 기본값에 덮어써지는 버그가 생깁니다.
Buy Candidate 판정 기준(70점 이상 + 안전마진 양수 + 모든 축 커버리지 70% 이상)은 lib/scoring.ts에서 상수로 export해 커스텀 계산도 똑같은 기준을 씁니다 — 화면에 적힌 규칙과 실제로 적용되는 규칙이 몰래 달라지는 일이 없도록.
이메일도 푸시도 없습니다. 그건 돈이 들거나 별도 서비스가 필요합니다. 대신 관심 종목을 그 종목의 과거와 비교해서, 다음에 페이지를 열었을 때 다시 볼 만한 변화를 MY Page 상단에 모아 보여줍니다 (lib/alerts.ts, components/WatchlistAlerts.tsx).
즐겨찾기가 곧 watchlist입니다. 별도 목록을 하나 더 만들면 같은 UI가 두 벌 생길 뿐 얻는 게 없어서 그렇게 했습니다.
무엇을 알리느냐가 핵심입니다. 주가 등락은 증권사 앱이 이미 보여줍니다. 여기서 중요한 건 관심을 갖게 된 이유를 조용히 무너뜨리는 변화입니다:
마지막 항목이 하락만인 이유는, 즐겨찾기 목록 표에 이미 등락률 칼럼이 있어서 전부 알리면 그 칼럼을 행 수만 줄여 반복하는 셈이기 때문입니다. 자리를 얻을 만한 건 판단을 다시 하게 만드는 움직임이고, 상승은 그렇지 않습니다 — 상승이 가치에 미치는 영향은 안전마진·저평가 알림이 반대 방향에서 이미 보고합니다.
계산식 버전이 다른 시점끼리는 비교하지 않습니다. 그 차이는 회사가 변한 게 아니라 우리 기준이 바뀐 것이고, 그걸 회사 소식처럼 알리는 건 SCORING_VERSION을 두는 이유 자체를 배반하는 일입니다.
비교 기준은 30일 이전 시점 중 가장 가까운 점수이고, 그 날짜를 항목마다 표시합니다. 비교 시점이 백필된 행이면 그 사실도 함께 밝힙니다. 히스토리는 관심 종목 전체를 한 번의 쿼리로 가져옵니다 (fetchScoreHistoryForTickers) — 종목당 한 번씩 조회하면 이미 종목마다 시세를 기다리는 페이지에 왕복이 수십 번 더 붙습니다.
62점이 좋은 점수인지 나쁜 점수인지는 그 숫자만으로는 알 수 없습니다. 은행과 소프트웨어 회사는 같은 5개 축으로 채점해도 나오는 범위가 완전히 다릅니다. 그래서 종목 상세 페이지에 같은 GICS 섹터 안에서의 순위를 함께 보여줍니다 (lib/peers.ts, components/PeerComparison.tsx).
축별 막대 위의 세로선은 그 섹터의 중앙값입니다. 순위만으로는 그 판이 촘촘한지 널널한지 알 수 없는데, 막대와 세로선 사이의 거리가 그걸 보여줍니다.
아래 표의 비교 대상은 점수가 아니라 시가총액이 가까운 종목입니다. 같은 섹터라도 2조 달러 기업과 80억 달러 기업은 사실상 다른 사업이고, 섹터 최고 점수 종목들과 나란히 놓는 건 아무도 묻지 않은 질문에 답하는 셈이라서요. 크기순 정렬이라 자기 회사가 표 가운데에 오는 부수 효과도 있습니다.
데이터는 전부 data/scores.json에 이미 있습니다 — 네트워크 조회도, 새 데이터 소스도 없습니다.
주의할 점 두 가지를 표에 명시합니다. 회계연도가 회사마다 달라 "기간"이 최대 1년까지 벌어질 수 있고(소매업은 분기말 재고·매입채무 주기 때문에 마감 시점이 다르면 부채비율이 원래 다르게 나옵니다), 일부 항목을 측정하지 못한 회사는 *로 표시됩니다.
비자와 버크셔처럼 주식수를 못 읽어 시가총액이 없는 종목은 크기순 정렬에서 빠지는데, 이 경우 표를 한 줄로 만들지 않고 섹터 최대 종목들 앞에 붙입니다. 축별 순위는 애초에 시가총액이 필요 없어서 정상 동작합니다.
"Buy Candidate만 담았으면 실제로 얼마나 벌었을까"를 검증합니다. /backtest 페이지에서 이 사이트의 세 가지 내장 전략을 분기마다 다시 골라 담는(리밸런싱) 시뮬레이션을 돌리고, 진짜 시장 수익률(SPY)과 나란히 보여줍니다 (lib/backtest.ts, scripts/backtest.ts, app/[locale]/backtest/page.tsx).
데이터는 새로 받지 않습니다. Supabase score_history에 이미 있는 14개 분기말 백필 데이터(is_backfilled = true, scoring_version = 4)와, 모든 종목이 이미 쓰고 있는 Yahoo Finance 엔드포인트로 받은 SPY 가격만 씁니다 (lib/price.ts의 fetchPriceHistory/closeNear).
npm run backtest # score_history + SPY 가격 → data/backtest.json
전략은 네 가지입니다, 모두 각 분기말 시점에 그 시점 데이터만으로 다시 고릅니다:
is_buy_candidate = true인 종목 전부를 동일가중.total이 가장 높은 20종목을 동일가중 (동점은 티커 알파벳순).look-ahead가 없는 이유(lib/backtest.ts 상단 주석에도 적어 뒀습니다): T 시점의 선택은 T 시점 행의 total/is_buy_candidate/price만 봅니다. 이 값들 자체가 annualSeries(..., asOf: T)로 T 이전에 공시된 것만 써서 계산된 값이라(위 "과거 점수 재구성 (백필)" 절 참고), T 시점에 서 있는 사람이 실제로 알 수 있었던 정보만으로 고른 셈입니다. 수익률은 그다음 분기말 T'의 가격을 나눠서 계산하는데, T 시점에서 T'은 아직 미래입니다. 선택은 T만 보고, 수익률만 T'을 봅니다 — 이 둘이 절대 섞이지 않는 게 이 백테스트가 실전 검증이지 커브 피팅이 아닌 이유입니다.
알려진 한계:
data/universe.json은 배치를 돌릴 때(그리고 백필할 때)의 오늘자 S&P 500 편입 종목 목록이고, 이걸 모든 과거 시점에 그대로 적용합니다. 2023년엔 지수에 있었지만 실적 부진으로 지금은 빠진 회사는 어떤 과거 시점의 계산에도 등장하지 않습니다. 탈락한 회사들의 부진이 통째로 안 보이니, 아래 어떤 전략의 수익률도 실제보다 체계적으로 높게 나옵니다. 이 캐비엇은 /backtest 페이지 상단에도 (푸터가 아니라) 눈에 띄게 띄워 둡니다.is_backfilled = false, 현재 499건)는 지금은 아예 쓰지 않습니다 — 분기 주기와 일 주기를 섞으면 보유 기간이 들쭉날쭉해져서 서로 비교가 안 됩니다. 시간이 지나 일별 히스토리가 두터워지면 더 촘촘한 주기로도 검증할 수 있게 될 것입니다.화면의 숫자마다 어디서 왔고 언제 것인지를 함께 표시합니다 (components/DataSourceNote.tsx). 재무는 SEC EDGAR, 주가는 Yahoo Finance이며, 재무 수치가 "최근 12개월(TTM)"인지 "연간"인지와 그 기간의 마감일, 그리고 배치 갱신 시각을 붙입니다. ROE 15%가 지난달 마감 분기 기준인지 11개월 전 사업연도 기준인지는 판단이 갈리는 지점이라, 화면에서 구분되지 않으면 안 됩니다.
출처 자체(SEC / Yahoo)는 모든 종목이 같아서 data/scores.json에 499번 반복하지 않고 UI 문구에만 둡니다. 종목마다 다른 기간 정보만 dataSource로 저장합니다.
이 작업에서 드러난 실제 버그입니다. 태그되지 않은 재무제표 항목을 0으로 읽는 바람에 90개 종목이 "무차입 기업"으로 채점되고 있었습니다 — 코카콜라, GM, 포드, CSX, 골드만삭스, CVS가 전부 부채비율 0으로 재무 안정성 만점을 받았습니다.
원인은 두 가지였습니다:
LongTermDebtAndCapitalLeaseObligations 등). 코카콜라의 LongTermDebtNoncurrent는 2024년에 끊겨 있고 그 뒤로는 묶음 태그만 씁니다. 총부채를 한 항목으로 태그하는 기업(GM, 골드만삭스)을 위한 목록도 따로 뒀습니다 — 있으면 그걸 쓰고, 없을 때만 비유동+유동을 더합니다.NaN(측정 불가)이고, 진짜 무차입 기업은 ShortTermBorrowings = 0처럼 0으로 태그하므로 그대로 0으로 읽힙니다. 룰루레몬이 검증에 쓰인 대조군입니다 — 수정 전후 모두 부채비율 0.00.포드와 버크셔는 수정 후에도 총부채 태그가 없습니다. 두 회사 모두 부채를 부문별(포드 크레딧 등) 차원 태그로만 공시하는데, SEC의 companyfacts 추출본은 차원 없는 값만 담기 때문입니다. 이건 진짜 측정 불가이고, 그렇게 표시됩니다.
자본잠식도 같은 부류의 버그였습니다. 자기자본이 음수면 부채비율도 음수가 되고, "낮을수록 좋다" 구간을 그대로 통과해 만점이 나옵니다. 맥도날드·홈디포·스타벅스를 포함한 28개 종목이 이 경로로 부채비율 30/30을 받고 있었습니다. 자본잠식은 공시에 명시된 사실이라 측정 불가가 아니며, 0점으로 채점합니다.
OperatingIncomeLoss는 2014년입니다. 세전이익에 이자비용을 더하면 같은 EBIT가 나오므로, 태그가 없는 해에만 이 경로로 계산합니다. 두 값이 같은 정의를 가리키도록 별도 시리즈로 분리해 뒀습니다 — 연도마다 서로 다른 지표가 섞이면 성장률이 왜곡됩니다.companyfacts에 잡히지 않습니다. 순이익÷희석주식수로 유도합니다. 단 TTM 경로에는 넣지 않았습니다 — 희석주식수는 가중평균이고, TTM 항등식은 기간을 더하고 빼는 계산이라 평균에는 성립하지 않습니다.알려진 한계: 분기말 재무상태표는 계절성을 탑니다. 소매업은 재고·매입채무 주기 때문에 1분기말과 회계연도말 수치가 원래 다릅니다. 최신 데이터를 쓰는 대가이며, 나중에 Peer 비교를 넣을 때는 같은 분기끼리 비교해야 합니다.
종목 상세 페이지에 그 회사 임원·이사·10% 이상 보유주주의 최근 자사주 매매 내역을 보여줍니다 (lib/insiderTrading.ts, components/InsiderActivity.tsx). 미국 증권법상 이들은 자기 회사 주식을 사거나 팔면 영업일 기준 2일 이내에 SEC에 Form 4로 신고해야 하는데, 그 신고 내역을 그대로 보여주는 것입니다.
loadTickerCikMap()을 그대로 재사용합니다 — 티커→CIK 매핑에는 이미 예외 처리(CIK_OVERRIDES)가 있어서, 따로 하나 더 만들면 둘이 서로 다르게 어긋날 위험만 생깁니다.data/scores.json에 굽지 않고 페이지를 열 때마다 실시간으로 가져오며, 실패해도(신고 없음, 네트워크 오류, XML 파싱 실패 등) 빈 배열을 돌려주고 나머지 페이지는 그대로 뜹니다.primaryDocument 필드를 그대로 쓰면 안 됩니다. 이 필드는 대개 xslF345X06/form4.xml 같은 경로인데, 이름과 달리 SEC가 XSLT로 미리 렌더링한 사람이 읽기 좋은 HTML 페이지를 가리킵니다 — 정작 우리가 파싱해야 할 원본 XML은 같은 접수 폴더 바로 밑에 자기 파일명(form4.xml, doc4.xml 등 제출자마다 다름)으로 따로 있습니다. 그래서 경로의 마지막 세그먼트만 잘라서 원본 XML 파일명으로 씁니다.issuer.issuerCik이 조회 대상 회사와 일치하는 신고만 남깁니다. 발행사가 누구인지는 문서를 받아 봐야 알 수 있어서 후보를 12건까지 훑되 자사 신고 5건을 채우면 즉시 멈춥니다 — 대부분의 회사는 첫 5건이 전부 자사 것이라 추가 요청이 아예 없고, 버크셔·엑슨 같은 대형 보유자에서만 더 들어갑니다.nonDerivativeTransaction이 객체로 파싱되고, 여러 건을 담으면(같은 신고 한 건에 여러 매매가 묶인 경우가 실제로 흔합니다) 배열로 파싱됩니다. 파싱 직후 무조건 배열로 정규화하는 헬퍼를 거치지 않으면 다건 신고에서 매매가 조용히 누락됩니다.nonDerivativeTable만 다루고 derivativeTable(스톡옵션 부여, RSU 베스팅과 그 행사)은 이번 버전에서 뺐습니다. 파생 거래는 예정된 보상 이벤트로 발생하는 경우가 대부분이라 노이즈가 크고, 사람들이 이 표에서 실제로 보고 싶어하는 "본인 의사로 사고판 거래"와 섞으면 그 신호가 묻힙니다.한국어(ko)와 영어(en)를 지원합니다 (/ko, /en 경로, next-intl 사용). 브라우저 언어를 자동 감지해 첫 방문 시 알맞은 언어로 안내하며, 우측 상단 언어 선택기로 언제든 바꿀 수 있습니다.
messages/ko.json, messages/en.json — UI 문구 번역data/scores.json에는 원시 숫자만 저장하고(CriterionResult.values) 화면에 표시할 때 lib/criteriaText.ts가 해당 언어·로케일 숫자 포맷(퍼센트/통화)으로 조합합니다. 새 언어를 추가할 때 data/scores.json을 다시 만들 필요가 없는 구조입니다.i18n/routing.ts의 locales에 추가, (2) messages/{locale}.json 작성, (3) data/universe.json의 sector 값에 대응하는 sectors.* 번역 추가.app/globals.css 디자인 토큰 (다크/라이트 테마 색 정의)
app/[locale]/layout.tsx 공통 셸 (상단 네비 + 테마 초기화 스크립트)
app/[locale]/page.tsx 대시보드 (스코어 랭킹 + 필터)
app/[locale]/stock/[ticker]/page.tsx 종목 상세 (기준별 breakdown + 회사소개/뉴스/차트)
i18n/routing.ts, navigation.ts, request.ts next-intl 설정
proxy.ts locale 자동 감지/라우팅 (Next.js의 구 "middleware")
messages/ko.json, messages/en.json UI 문구 번역
lib/criteriaText.ts 평가 기준 "실제값" 문구를 로케일에 맞게 조합
lib/xbrl.ts SEC EDGAR XBRL 저수준 클라이언트 (티커→CIK, 연도별 시계열 추출)
lib/price.ts Yahoo Finance 시세 클라이언트 (현재가·과거 종가)
lib/tradingview.ts 티커를 TradingView 심볼(거래소:티커)로 변환
lib/sec.ts SEC+Yahoo를 조합해 종목별 재무 데이터 번들 생성
lib/scoring.ts 버핏/그레이엄 스코어링 로직
lib/store.ts data/scores.json 캐시 read/write
lib/wikipedia.ts 위키피디아 회사 소개 조회
lib/news.ts Yahoo Finance 뉴스 검색
components/Dashboard.tsx 스크리너 테이블 (정렬·검색·필터)
components/Panel.tsx 공통 카드 프레임
components/ScoreBar.tsx, ScoreGauge.tsx 점수 막대 / 원형 게이지
components/CriteriaTable.tsx 기준별 통과여부 breakdown 패널
components/SiteHeader.tsx 상단 네비게이션 바
components/BackToListLink.tsx "전체 목록으로" 돌아가기 링크 (상세/MY Page 공용)
components/InfoTip.tsx 지표 옆 `?` 용어 설명 툴팁
components/PriceChartPanel.tsx 차트 패널 (확대 / TradingView에서 열기)
components/ChartUnavailable.tsx 차트 로딩 실패 시 원인별 해결 방법 안내
components/TradingViewChart.tsx TradingView 임베드 차트
components/useTheme.ts data-theme 구독 훅
components/ThemeToggle.tsx 다크/라이트 전환 버튼
components/LocaleSwitcher.tsx 언어 선택 드롭다운
components/FavoriteButton.tsx, StockFavoriteButton.tsx 하트 즐겨찾기 버튼
components/MyFavoritesList.tsx MY Page 즐겨찾기 목록/해제
components/SignOutButton.tsx 로그아웃 버튼
app/[locale]/login/page.tsx 로그인/회원가입
app/[locale]/mypage/page.tsx MY Page (즐겨찾기 + 찜한 시점 대비 등락률)
app/auth/callback/route.ts 이메일 인증 콜백
lib/supabase/client.ts, server.ts Supabase 클라이언트 (브라우저용 / 서버용)
lib/supabase/useFavorites.ts 즐겨찾기 상태 구독/토글 훅
lib/supabase/admin.ts service_role 클라이언트 (배치 전용 — 브라우저에 절대 노출 금지)
lib/scoreHistory.ts 점수 스냅샷을 score_history 테이블에 적재
supabase/migrations/*.sql DB 스키마 (SQL Editor에서 번호 순서대로 1회씩 실행)
scripts/refresh.ts 배치 갱신 스크립트 (npm run refresh)
scripts/archive.ts 현재 scores.json만 히스토리에 적재 (npm run archive)
data/universe.json 스크리닝 대상 티커 + 회사명/섹터/위키피디아 문서명
data/fixtures.ts 샘플 데이터 (npm run refresh -- --fixture)
위 버튼을 클릭하면 자동으로 저장소를 fork하고 Vercel 프로젝트를 생성합니다.
본 저장소를 GitHub 계정으로 fork한 다음, 자신의 저장소 주소를 확인합니다 (https://github.com/your-username/buffett-screener).
.sql 파일을 번호 순서대로 실행NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY (anon key)NEXT_PUBLIC_SUPABASE_URL: 위에서 복사한 Supabase URLNEXT_PUBLIC_SUPABASE_ANON_KEY: 위에서 복사한 anon keySUPABASE_SERVICE_ROLE_KEY: (선택사항, 배포만으로는 필요 없음)회원가입/로그인을 활성화하려면:
Supabase 프로젝트 → Authentication → URL Configuration:
Site URL: 배포된 Vercel 도메인 (예: https://your-app.vercel.app)Redirect URLs에 https://your-app.vercel.app/auth/callback 추가(선택) Google 로그인을 쓰려면:
https://{your-project}.supabase.co/auth/v1/callback 등록GitHub Actions로 자동 점수 갱신을 하려면:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEY.github/workflows/refresh.yml이 평일 23:00 UTC(한국시간 다음 날 아침 8시)마다 자동 실행됩니다또는 수동 실행: Actions 탭에서 "점수 갱신" workflow를 선택하고 "Run workflow" 클릭.
Vercel 프로젝트 → Settings → Domains에서 자신의 도메인 연결:
# S&P 500 전체(503개 종목)를 다시 계산하고 data/scores.json 업데이트
npm run refresh
# 현재 scores.json을 Supabase 히스토리에 적재 (생성 시각 기준)
npm run archive
# 과거 시점의 점수를 역사적으로 재구성 (백테스트용)
npm run backfill # 최근 3년, 분기별
npm run backfill -- --years 5 # 최근 5년
npm run backfill -- --ticker LULU # 특정 종목만
npm run backfill -- --dry-run # 계산만 하고 적재하지 않음
.github/workflows/refresh.yml이 평일 23:00 UTC(미국 장 마감 몇 시간 뒤, 한국시간 다음 날 아침 8시)에 자동 실행됩니다.
data/scores.json 변경사항을 자동 커밋필수 조건: GitHub 저장소의 Secrets에 Supabase 키 3개 등록 (위의 "5단계" 참고)
| 항목 | 소스 | 갱신 주기 | 지연 | 비고 |
|---|---|---|---|---|
| 재무제표 | SEC EDGAR | 분기/연간 | ~1-2주 | 회사 공시 후 SEC에 올라올 때까지 |
| 주가 | Yahoo Finance | 실시간 | ~1일 | 전일 종가 기준 |
| 회사 소개 | 위키피디아 | 페이지 로드 시 | - | 종목 상세 페이지 열 때만 조회 |
| 뉴스 | Yahoo Finance | 페이지 로드 시 | - | 종목 상세 페이지 열 때만 조회 |
| 인사이더 거래 | SEC Form 4 | 페이지 로드 시 | ~2일 | 거래 후 영업일 기준 2일 이내 신고 |
data/scores.json의 타임스탬프 확인npm run refresh 후 npm run dev 재시작원인: 로컬 data/scores.json이 오래되었을 가능성이 높습니다.
해결:
npm run refresh # 최신 데이터로 재계산
npm run dev # 개발 서버 재시작
배치는 ~30분 소요되므로 기다려야 합니다. Vercel 배포 중이라면:
원인: SEC 서버 일시적 장애, 네트워크 오류, 또는 그 회사가 최근 거래 신고가 없을 수 있습니다.
해결:
확인: TradingView 임베드 차트의 테마가 현재 다크/라이트 모드에 맞게 변경되고 있습니다. 만약 차트 배경이 보이지 않는다면:
s3.tradingview.com이 차단될 수 있음)차트가 계속 로드되지 않으면 화면에 원인 설명과 해결 방법을 안내합니다.
로컬 개발 중:
.env.local에 Supabase URL과 anon key가 채워져 있는가 확인배포된 Vercel 사이트:
개별 종목 몇 개 실패는 정상입니다 (SEC에 데이터가 없는 종목들). 하지만:
500개 종목 중 25개 이상 실패: 데이터 소스(SEC/Yahoo) 장애 또는 IP 차단 가능성
특정 종목만 실패: 그 회사의 SEC 등록 정보 이상 (예: CIK 매핑 오류)
CIK_OVERRIDES에 수동 매핑 추가| 영역 | 기술 | 용도 |
|---|---|---|
| Frontend Framework | Next.js 14 (App Router) | React 기반 프레임 |
| 언어 | TypeScript + React | 타입 안전성, 컴포넌트 UI |
| 스타일 | Tailwind CSS 4 | 유틸리티 CSS, 다크/라이트 테마 |
| 다국어 | next-intl | 한국어/영어 URL 라우팅 및 메시지 관리 |
| 인증 | Supabase Auth | 이메일 로그인/회원가입 (선택사항) |
| 데이터 저장 | Supabase (PostgreSQL) | 즐겨찾기, 점수 히스토리, 사용자 정보 |
| 캐시 저장소 | 로컬 JSON 파일 (data/scores.json) | 일일 점수 스냅샷 (최속 조회) |
| 재무 데이터 | SEC EDGAR API (무료) | 분기/연간 재무제표 (XBRL 원본) |
| 주가 데이터 | Yahoo Finance (무료) | 현재가, 과거 종가, 뉴스 |
| 차트 | TradingView 무료 임베드 | 주가 차트 및 드로잉 도구 |
| 회사 정보 | 위키피디아 API | 회사 소개 요약 |
| 자동화 | GitHub Actions | 평일 자동 점수 갱신 |
| 배포 | Vercel | 호스팅 및 CI/CD |
| 런타임 | Node.js (Vercel 서버리스) | 백엔드 실행 환경 |
{
"next": "16.3.1",
"react": "19.2.8",
"typescript": "^5",
"@supabase/supabase-js": "^2.112.4",
"next-intl": "^4.13.7",
"tailwindcss": "^4",
"fast-xml-parser": "^5.11.0"
}
data/scores.json에 저장하고, 사용자는 그 파일을 즉시 로드합니다.SCORING_VERSION을 올려, 역사적 시점의 점수를 현재 기준으로 자동 재구성할 수 있음messages/*.jsoninsiderTrading.caveat