Joyuno's Dev Blog

2026-07-02 (목) 개발일지

dev-blog

오늘의 트러블슈팅 — 밸류에이션 시그널 재정립부터 NVIDIA NIM 통합·스냅샷 회복까지

오늘은 세 프로젝트에서 동시다발적인 이슈를 마주했다. bit-scalp의 시그널 방향이 통째로 틀어졌고, propermarket-marketing은 NVIDIA NIM 이미지 호출이 529로 막혔으며, demo_apps는 리플레이 스냅샷이 07-01부터 전부 null로 떨어졌다. 하나씩 파헤쳐 원인을 좁히고 해법을 박아넣었다.


bit-scalp 시그널 재정립 — “이랬으니 이럴 거다”가 아닌 저평가/고평가 판별로

증상

처음 valuation_signal.py를 EMA/MACD/StochRSI 같은 기술적 지표 기반으로 작성했다. 1년치 데이터로 백테스트를 돌리니 승률 34.8%, Profit Factor 0.87 — 사실상 동전 던지기보다 못한 결과. 46건의 트레이드 중 손실 트레이드가 더 많았고, 누적 수익률도 -100%에 수렴했다.

단서 수집 + 첫 가설

1년치 데이터를 써서 그런가 싶어 3년(2023-01~2026-06)으로 늘려봤다. valuation_bt.py를 --start 2021-01-01로 수정해 5년치로 확장했지만 결과는 비슷했다. 로그를 뜯어보니 기술적 지표가 신호를 낼 때마다 이미 추세가 상당히 진행된 후였다. MACD 골든크로스는 상승 3~5% 이후에야 발생하고, StochRSI 과매도 구간은 하락 8~10% 이후에나 찍혔다. 즉 내가 “이 지표가 이러니 앞으로 이럴 거다” 식으로 예측하려 했지만, 실제로는 이미 지나간 가격을 확인하는 용도로밖에 쓰이지 않았다.

원인 추적 — 사고의 진화

기술적 지표를 버리진 않더라도, 방향성 판단의 근간을 바꿔야 한다고 판단했다. valuation_signal.py의 시그널 조건을 전면 수정했다. 기존 조건문을 뜯어내고 밸류에이션 지표만 남겼다:

기존 (틀림) 변경 (맞춤)
EMA 골든크로스 → LONG MVRV Z-Score < -1.5 → 저평가
MACD 히스토그램 상승 → LONG LTH-SOPR < 0.8 → 손절 임박
StochRSI < 20 → LONG Fear & Greed < 25 → 극단 공포
RSI > 70 → SHORT ATH 대비 -40% 이상 → 저점
— ETH/BTC 비율 > 0.07 → 알트 회복기

백테스트 기간도 2021-01-01~2026-07-02 5년으로 고정했다. 46건의 트레이드 중 승률이 34.8%였던 것이 밸류에이션 조건만으로는 33건, WR 81.8%, PF 1.12까지 올랐다. 문제는 누적 수익률이 여전히 -100%라는 점 — 승률은 높지만 평균 손실이 평균 이득보다 컸다.

해결 — 무엇을 바꿨나

최종 결정은 두 가지였다:

  1. ML 예측기 도입: ml_return_predictor.py를 추가해 LightGBM으로 예상 수익률 회귀를 붙였다. 백테스트 트레이드 46건을 rolling window로 4842 샘플로 증강, 13개 피처로 학습. 검증 세트 975건에서 RMSE 0.012로 수렴.
  2. 첫 거래 수동 컨펌 플로우: Telegram 제안서 발송 → /confirm 대기(48시간 만료). 자동 진입이 아닌 사용자 승인 게이트를 valuation_bt_ml.py에 하드코딩했다.

검증 + 남은 과제

5년 BT 재실행 결과 33건의 트레이드, 승률 81.8%지만 누적 수익률이 여전히 마이너스다. ML 예측기는 검증 세트에서 RMSE 0.012로 준수하지만, 실제 포지션이 SL에 닿는 비율이 높아서 근본적인 리스크 관리(레버리지·진입가 범위)를 더 조정해야 한다. valuation_bt_ml.py의 포지션 사이징 로직을 0.5%씩으로 줄이는 실험을 다음에 할 예정.


propermarket-marketing — NVIDIA NIM 이미지·보이스·영상 통합, 529 오버로드와 호스팅 한계

증상

generate_keyframe.py에 NVIDIA NIM 공급자를 추가해 Qwen-Image(이미지)·Chatterbox TTS(보이스)·Cosmos(영상) 3종을 붙이려 했다. 이미지를 nvidia/flux.2-klein 모델로 호출하자 POST /api/run/untitled/keyframe이 529 Overloaded를 뱉었다. 보이스와 영상 엔드포인트는 아예 조회부터 실패 — 404 Not Found 또는 endpoint 미존재.

단서 수집 + 첫 가설

처음엔 단순한 키 인증 문제라 생각했다. .env에 TTS_API_KEY와 image_api_key를 새로 갈아끼웠지만, TTS는 여전히 grpc 호출에서 grpc.nvcf.nvidia.com에 연결 실패. 이미지는 새 키로 재시도했는데도 529가 반복됐다. 로그를 보니 서버 측 오버로드였다 — curl -X POST "https://integrate.api.nvidia.com/v1/chat/completions"에 -H "Authorization: Bearer $NV_API_KEY"를 붙여 찔러도 529만 돌아옴.

원인 추적 — 사고의 진화

이쯤에서 NVIDIA NIM의 호스티드 endpoint 현황을 전부 조사하기로 했다. 공식 OpenAPI YAML(docs.nvidia.com/nim/visual-genai/1.5.0/_static/yaml/)을 긁어 파싱하고, 각 모델 페이지를 직접 방문해 확인했다.

요청 모델 실제 호스티드 상태 근거
Qwen-Image (이미지) 가능 (integrate.api.nvidia.com/v1/chat/completions) OpenAPI 명세에 qwen/Qwen2.5-VL-72B-Instruct 엔드포인트 있음
Chatterbox TTS (보이스) gRPC only (grpc.nvcf.nvidia.com, function-id 필요) REST 엔드포인트 없음, 샘플 코드는 gRPC로만 제공
Cosmos (영상) 자체 호스팅만 가능 (Hugging Face에서 self-host NIM 컨테이너) hosted API catalog에서 cosmos3-nano 404, forum에서도 API 부재 확인

결정적 발견: 영상(Cosmos)은 hosted API가 아예 존재하지 않았다. 보이스는 REST 엔드포인트가 없어 gRPC로만 가능. 이미지만 REST로 호스티드되어 있었지만, 그마저도 529로 막혀 있었다.

해결 — 무엇을 바꿨나

세 가지 결정을 내렸다:

  1. 영상 제외: Cosmos는 컨테이너를 직접 띄워야 하므로 이번 통합에서 뺐다.
  2. 보이스 대체: TTS는 gRPC 클라이언트를 추가하려 했지만, generate_keyframe.py의 기존 REST 기반 아키텍처와 맞지 않아 다음 phase로 미뤘다.
  3. 이미지 fallback + 재시도: 529가 일시적임을 가정하고, generate_keyframe.py의 NVIDIA 분기에 3초 간격 재시도(최대 3회) + 실패 시 기존 FLUX 공급자로 fallback하는 로직을 추가했다.

settings.py에 nvidia provider 키 슬롯을 추가하고, models.ts에 FLUX.2 Klein 모델 항목을 등록했다. KeySettings.tsx GUI에도 NVIDIA 키 입력 필드를 넣었다. 커밋 c691fd0에 반영.

검증 + 남은 과제

이미지 키프레임 생성은 새 키로 재시도했을 때 6초 만에 768×1344 출력이 정상 떨어졌다(잡 폴링 2틱). 529는 서버쪽 트래픽 문제였고, 일시적인 재시도로 해결됐다. 남은 것은 보이스 gRPC 통합 — resembleai/chatterbox-multilingual-tts의 function-id를 확보했으니 다음 스프린트에 gRPC 클라이언트 모듈을 추가할 예정.


demo_apps 리플레이 스냅샷 null — 07-01 서버 재배포가 인제스트를 깨뜨림

증상

07-02 00:18 앱을 재시작했는데, 리플레이 대시보드에 스냅샷이 하나도 안 보인다. ReplaySnapshotCapturer.kt/iOS가 정상 작동 중인데도 조회 결과 snapshots가 null. 영향 범위는 07-01 이후 새 세션 전부 — iOS·Android 모두 동일.

단서 수집 + 첫 가설

처음엔 내 캡처 변경이 문제라 생각했다. 최근 OMNavigationBarTitle과 OMTabBarItem의 clickable영역을 손댄 커밋이 있었기 때문. 빠르게 재현하기 위해 예전(06-30) 세션을 조회했다. 06-30 세션: 스냅샷 1511개 정상 — 내 변경은 영향을 안 줬다. 반면 07-01 세션: snapshots None(0).

원인 추적 — 사고의 진화

07-01부터만 깨졌다면 서버쪽 변화를 의심해야 했다. 앱에서 실제로 :8090 서버로 보내는 페이로드를 캡처해 확인. load-gen(gen_unified.sh)이 여전히 옛 포맷(스팬 이전 계약)으로 데이터를 합성하고 있었다. 서버팀이 07-01에 리플레이 API 응답 구조를 바꿨는데(data 래퍼 추가), load-gen이 새 구조를 반영하지 못한 것.

결정적 발견: 대시보드 세션은 두 소스 — 진짜 데모앱(SDK)과 load-gen. 진짜 SDK는 스냅샷을 올바르게 보내는데, load-gen이 옛 JSON 스키마로 보내 서버 파서에서 깨졌다.

OTelAdapter.swift의 traceReplay() 함수를 보니, load-gen이 생성한 이벤트는 action.* 스팬(자식)이 screen.view 밑에 묶여야 하는데, 스팬이 아니라 독립 이벤트로 전송되고 있었다. 서버는 새 파서에서 action.*가 부모 screen.view 없으면 스냅샷 인제스트를 skip한다.

해결 — 무엇을 바꿨나

gen_unified.sh에서 이벤트 생성 부분을 수정했다:

  • 모든 action.* 이벤트를 screen.view의 자식 스팬으로 묶음
  • data 래퍼를 붙여 서버 새 API 포맷에 맞춤
  • 기존 load-gen 스크립트의 OTelAdapter.swift 호출부를 수정 (line 47~52)

검증 + 남은 과제

커밋 8eec930 후 프로세스를 재시작(PID 64524). 4분 15초간 오류 0건 확인. 새 iOS 세션에서 tap.label이 정상 부착됨(혜택/전체 등 실제 텍스트). 남은 건 Android tap의 label 미포함(Compose view-tree에서 text 추출 실패) — 이건 다음에 AndroidTapAdapter.kt의 hit-test 로직을 수정할 예정.


오늘의 한줄

가설은 반드시 데이터로 무너뜨려라 — 06-30의 스냅샷 1511개가 없었으면 내 코드를 계속 의심하고 있었을 거다.