2026-06-23 (화) 개발일지
오늘은 FinceptTerminal 다관점 스코어링 엔진을 실제 종목 캐시에 돌릴 때 드러난 두 가지 버그를 잡았다. 하나는 yfinance가 특정 종목에 대해 'Infinity' 문자열을 그대로 내뱉는 케이스, 다른 하나는 섹터-팩터 디스패치 맵에서 이름 충돌이 일어나는 구조적 문제였다. 둘 다 결과 점수를 왜곡시키는 원인이라, 빌드 전에 걸러내지 못하면 신뢰도에 치명적이었다.
BILL 의 'Infinity' 실수값 — _extract_info 수치 필드 강제 변환
US 스코어링을 120종목에 대해 돌렸는데, 결과에서 BILL 의 점수가 이상하게 튀었다. 점수 자체는 유효범위 안이었지만, 출력 Verdict 가 pass_with_caveats 로 떴고, 퀀트 관점에서 trailingPE 가 NULL (예정)이 아니고 문자열 'Infinity' 로 저장된 게 발견됐다. yfinance 최신 데이터 중 BILL(자산운용 ETF) 의 trailing PE 가 분모 0 에 가까워 JSON 직렬화 과정에서 Infinity → 'Infinity' 로 들어온 것.
처음엔 캐시 파일을 지우고 yfinance 재호출로 해결될 거라 생각했다. _site 를 통째로 날린 jekyll 때처럼. 하지만 같은 yfinance 응답이 반복될 뿐 — 'Infinity' 는 yfinance 가 내려주는 원본 값이라 재호출로 바뀌지 않았다. 그제서야 엔진의 _extract_info 함수를 봤다.
원인은 _extract_info 가 yfinance 의 info 딕셔너리에서 숫자 필드를 그대로 꺼내는 데 있었다. trailingPE 같은 수치 필드는 None 이나 float('Infinity') 가 아닌 문자열 'Infinity' 로 들어올 수 있다. _extract_info 에서 float() 변환에 실패한 값은 그대로 문자열로 캐시에 박혔다.
해법: _extract_info 안에 숫자 필드(trailingPE, priceToBook, forwardPE 등)를 float 으로 강제 변환하는 루프를 넣었다. 변환 실패('Infinity', 'NaN', 비수치 문자열) 시 None 으로 세팅 — 기존 None 처리 로직이 그대로 동작하게 했다. 문자열 필드(shortName, sector, industry)는 건드리지 않았다.
# reco_engine.py/_extract_info
NUMERIC_KEYS = {'trailingPE', 'priceToBook', 'forwardPE', 'dividendYield',
'earningsGrowth', 'revenueGrowth', 'freeCashflow', 'marketCap'}
for k in NUMERIC_KEYS:
v = raw.get(k)
if isinstance(v, str):
try:
v = float(v)
except (ValueError, TypeError):
v = None
raw[k] = v
검증: BILL 의 기존 캐시 파일에 'Infinity' 문자열이 들어있어, 캐시를 지우고 재호출(cache_fundamentals)로 새로 저장했다. 재호출 시 _extract_info 가 'Infinity' 를 None 으로 변환해서 캐시에 기록됐고, US 120종목 스코어링이 깔끔하게 통과했다. pass_with_caveats 가 pass 로 바뀌었다.
남은 과제: yfinance 는 분기마다 비슷한 엣지 케이스(-99999 같은 정수 플래그도 있다)를 내뱉을 수 있음. _extract_info 에 float() 실패 폴백을 넣긴 했지만, 추가로 로깅을 달아서 어떤 값이 fallback 됐는지 캐치하는 게 남았다.
_sf_value 부분문자열 충돌 — P/FFO vs FFO_Proxy 이름 맵 충돌
SCORING_QA.md v3 에서 발견된 버그다. REIT 섹터의 P/FFO 팩터가 의도와 다르게 계산되고 있었다. 처음엔 단순히 부채 안전성 계산식이 잘못된 걸로 가설을 세웠다. Materials 섹터의 NEM(뉴몬트) 과 LIN(린데) 의 ICR (Interest Coverage Ratio) 값이 direction=lower_better 로 설정돼서 높은 ICR 이 오히려 음수 정규화를 받았다. NEM ICR 50.5 → norm -1.92, 전형적인 방향 오류였다. 그래서 NEM/LIN 의 팩터를 ICR 에서 ND/EBITDA 로 바꾸는 건 즉시 고쳤다.
그런데 REIT 의 P/FFO 가 _sf_value 에서 다른 팩터로 매핑되는 게 확인됐다. 디스패치 함수 _sf_value 는 sector_fundamentals.json 의 factor 이름을 key 로 받아서, 그 key 에 맞는 핸들러(_sf_* 함수)를 찾는다. 문제는 부분문자열 매칭이었다. "P/FFO" 키가 "P/FFO Proxy (시총/FFO)" 키의 앞부분과 겹쳐서, REIT 의 P/FFO 가 의도한 _sf_p_ffo 가 아닌 _sf_ffo_proxy 로 잘못 디스패치되고 있었다. 두 팩터는 완전히 다른 계산 로직을 가진다 — _sf_p_ffo 는 단순 Price/FFO 배수, _sf_ffo_proxy 는 시총 대비 FFO 비율.
시행착오: 처음엔 sector fundamentals JSON 의 키 순서 문제인가 싶어 Ruby Hash 처럼 마지막 정의가 우선하는지 확인했다. sector_fundamentals.json 에서 factor 순서를 바꿔봤지만 증상이 그대로였다. _sf_value 자체가 if name in key 식의 in 연산자로 디스패치하고 있다는 걸 그제서야 발견했다. 부분문자열 매칭 자체가 문제였다.
해법: _sf_value 의 디스패치 로직을 리팩토링했다. factor 이름을 직접 키로 쓰지 않고, 명시적인 name → handler 맵을 만들었다. P/FFO 와 P/FFO Proxy (시총/FFO) 가 서로 다른 키로 완전 분리된다. 동시에 Materials 의 부채 안전성 방향 부호 반전도 함께 수정했다.
# reco_engine.py — 리팩토링 후
SF_DISPATCH = {
'P/FFO': '_sf_p_ffo',
'P/FFO Proxy (시총/FFO)': '_sf_ffo_proxy',
'ICR': '_sf_icr',
'ND/EBITDA': '_sf_nd_ebitda',
# ... 63개 팩터 전부 명시 매핑
}
검증: 디스패치 리팩토링 후 US/KR 양 시장 재점수화를 돌렸다. 리스코어링 결과, REIT 섹터의 P/FFO 가 정확히 _sf_p_ffo 로 계산되고, Materials 의 NEM/LIN 이 ND/EBITDA 기반으로 정상화됐다. _sf_value 안에서 58개 유니크 디스패치 키가 전부 1:1 매핑되고 0 missing, 0 orphan을 확인했다. 재검증 Verdict pass_with_caveats 에서 pass 로 상향.
남은 과제: sector_fundamentals.json 에 새로운 팩터를 추가할 때 SF_DISPATCH 에 등록을 깜빡하면 스코어링만 실패하고 끝나지 않도록, 빌드 시점에 맵 대조 검증을 자동화할 필요가 있다.
오늘의 한줄
yfinance 의 엣지 케이스 문자열도, 팩터 디스패치의 미묘한 이름 충돌도 — 둘 다 “이론상 문제없는 코드” 가 실제 데이터 앞에서는 저렇게 터진다는 걸 몸소 확인한 하루였다.