diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..3849457 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Quant Engine Web Service Deployment Script +# 목표: publish 폴더를 웹 서버에 배포 + +set -e + +# 설정 +SOURCE_DIR="src/dotnet/QuantEngine.Web/publish" +DEPLOY_USER="kjh2064" +DEPLOY_HOST="178.104.200.7" +DEPLOY_PATH="/var/www/quant" +SSH_KEY="${HOME}/.ssh/id_ed25519" + +echo "🚀 Quant Engine 웹 서비스 배포 시작" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "소스: $SOURCE_DIR" +echo "대상: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# 1. 배포 폴더 생성/준비 +echo "" +echo "📦 Step 1: 배포 폴더 준비..." +if [ ! -d "$SOURCE_DIR" ]; then + echo "❌ 오류: publish 폴더 없음. 먼저 'dotnet publish -c Release'를 실행하세요" + exit 1 +fi + +echo "✓ publish 폴더 크기: $(du -sh $SOURCE_DIR | cut -f1)" +echo "✓ 파일 수: $(find $SOURCE_DIR -type f | wc -l)" + +# 2. SSH 연결 확인 +echo "" +echo "🔐 Step 2: SSH 연결 확인..." +if [ ! -f "$SSH_KEY" ]; then + echo "❌ SSH 키 없음: $SSH_KEY" + exit 1 +fi + +ssh -i "$SSH_KEY" -o ConnectTimeout=10 "$DEPLOY_USER@$DEPLOY_HOST" "echo '✓ SSH 연결 성공'" || { + echo "❌ SSH 연결 실패" + exit 1 +} + +# 3. 원격 백업 +echo "" +echo "💾 Step 3: 원격 백업 생성..." +BACKUP_DIR="/var/www/quant_backup_$(date +%Y%m%d_%H%M%S)" +ssh -i "$SSH_KEY" "$DEPLOY_USER@$DEPLOY_HOST" \ + "sudo mkdir -p $DEPLOY_PATH && \ + if [ -d $DEPLOY_PATH/publish ]; then \ + sudo cp -r $DEPLOY_PATH/publish $BACKUP_DIR; \ + echo '✓ 백업 생성: $BACKUP_DIR'; \ + else \ + echo '✓ 기존 배포 없음'; \ + fi" + +# 4. 배포 +echo "" +echo "📤 Step 4: 파일 전송 중... (이 작업은 시간이 걸릴 수 있습니다)" +rsync -av -e "ssh -i $SSH_KEY" \ + --delete \ + "$SOURCE_DIR/" \ + "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/publish/" \ + || { + echo "❌ 배포 실패" + exit 1 + } + +echo "✓ 파일 전송 완료" + +# 5. 권한 설정 +echo "" +echo "🔧 Step 5: 원격 권한 설정..." +ssh -i "$SSH_KEY" "$DEPLOY_USER@$DEPLOY_HOST" \ + "sudo chown -R www-data:www-data $DEPLOY_PATH/publish && \ + sudo chmod -R 755 $DEPLOY_PATH/publish && \ + echo '✓ 권한 설정 완료'" + +# 6. 웹 서버 재시작 +echo "" +echo "🔄 Step 6: 웹 서버 재시작 중..." +ssh -i "$SSH_KEY" "$DEPLOY_USER@$DEPLOY_HOST" \ + "sudo systemctl restart nginx && \ + sleep 2 && \ + sudo systemctl status nginx | grep Active && \ + echo '✓ nginx 재시작 완료'" \ + || { + echo "⚠️ nginx 재시작 실패 (수동으로 확인 필요)" + } + +# 7. 배포 확인 +echo "" +echo "🧪 Step 7: 배포 확인..." +sleep 2 +HEALTH_URL="http://178.104.200.7/quant/" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" || echo "000") + +if [ "$HTTP_CODE" = "200" ]; then + echo "✅ 배포 성공! URL: $HEALTH_URL" +elif [ "$HTTP_CODE" = "301" ] || [ "$HTTP_CODE" = "302" ]; then + echo "✓ 배포 완료 (리다이렉트: $HTTP_CODE)" +else + echo "⚠️ HTTP 상태: $HTTP_CODE (nginx 설정 확인 필요)" +fi + +# 8. 최종 보고 +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "✅ 배포 완료!" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "📋 배포 정보:" +echo " 웹사이트: http://178.104.200.7/quant/" +echo " 배포 경로: $DEPLOY_PATH/publish" +echo " 백업 위치: $BACKUP_DIR (필요시)" +echo "" +echo "🔍 로그 확인:" +echo " ssh $DEPLOY_USER@$DEPLOY_HOST" +echo " sudo tail -f /var/log/nginx/error.log" +echo " sudo tail -f /var/log/nginx/access.log" +echo "" + +exit 0 diff --git a/docs/DAILY_SIGNAL_TRACKING.md b/docs/DAILY_SIGNAL_TRACKING.md new file mode 100644 index 0000000..5f6d14f --- /dev/null +++ b/docs/DAILY_SIGNAL_TRACKING.md @@ -0,0 +1,274 @@ +# 📊 Daily Signal Tracking Guide + +**목표**: 30개 거래신호 수집 → CALIBRATED 전환 → honest_proof_score 95 달성 + +**기간**: 2026-06-25 ~ 2026-08-10 (약 6주) + +--- + +## 📋 매일 해야 할 일 + +### 1️⃣ 신호 발생 시 (거래 진입 시점) + +```python +# Python 또는 GAS 콘솔에서 실행 +signal = { + "date": "2026-06-25", + "ticker": "000660", # SK하이닉스 등 + "signal_type": "BUY", # BUY 또는 SELL + "signal_score": 78, # 0-100 + "entry_price": 50000, # KRW + "entry_quantity": 10, # 주 + "entry_time": "10:30", # HH:MM + "style": "SWING", # SCALP|SWING|MOMENTUM|POSITION + "routing_confidence": 82, # buildRoutePacket_ 결과 + "notes": "MA20 돌파 + 스마트머니 매수" +} + +# GAS: addSignal_(signal) +# 또는 스프레드시트에 직접 입력 +``` + +**✅ 체크리스트:** +- [ ] signal_id 자동 생성됨 (YYYYMMDD_HHMM 형식) +- [ ] validation_status = "UNVALIDATED" +- [ ] 스프레드시트 행 추가됨 + +--- + +### 2️⃣ T+5 (5거래일 후) + +``` +거래일 기준: +- 월요일 진입 → 다음주 월요일이 T+5 +- 금요일 진입 → 그다음주 금요일이 T+5 +``` + +**해야 할 일:** +1. T+5일의 종가 조회 +2. `updatePriceT5_(signalId, priceT5)` 실행 +3. 또는 스프레드시트 "price_t5" 열에 직접 입력 + +**예시:** +``` +signal_id: 20260625_1030 +진입가: 50,000 +T+5 종가: 51,000 +``` + +--- + +### 3️⃣ T+20 (20거래일 후) ⭐ 가장 중요 + +``` +T+5 이후 추가 15거래일 경과 +``` + +**해야 할 일:** +1. T+20 종가 조회 +2. `updatePriceT20_(signalId, priceT20)` 실행 +3. **자동으로 계산됨:** + - `return_pct_t20` = (priceT20 - entryPrice) / entryPrice * 100 + - `outcome` = WIN / LOSS / BREAKEVEN + - `win_margin` = |return_pct_t20| + - `validation_status` = PROVISIONAL (자동으로 UNVALIDATED → PROVISIONAL 전환) + +**판정 기준:** +``` +return_pct_t20 > 2% → WIN +-2% ≤ ret_pct ≤ 2% → BREAKEVEN (통계 제외) +return_pct_t20 < -2% → LOSS +``` + +**예시:** +``` +signal_id: 20260625_1030 +진입가: 50,000 +T+20 종가: 51,050 +수익률: (51,050-50,000)/50,000 * 100 = 2.1% +outcome: WIN ✅ +win_margin: 2.1 +validation_status: PROVISIONAL +``` + +--- + +## 📈 주간 리뷰 (매주 금요일) + +### 확인 사항 + +```javascript +// GAS 콘솔에서 실행 +stats = calculateStats_(); +Logger.log(JSON.stringify(stats, null, 2)); +``` + +**출력 예시:** +```json +{ + "total": 8, + "completed": 4, + "win_count": 3, + "loss_count": 1, + "breakeven_count": 0, + "win_rate": "75.00", + "avg_win_margin": "2.45", + "calibrated_progress": "4/30" +} +``` + +### 분석 + +- ✅ **win_rate >= 60%?** → YES면 순조로운 진행 +- 📊 **avg_win_margin** → 평균 수익률 확인 +- 🎯 **calibrated_progress** → 남은 신호 수 (30 - 완료) + +### 보고 + +```markdown +## 주간 리포트 (Week 1) + +| 항목 | 값 | +|------|-----| +| 누적 신호 | 8개 | +| 완료됨 | 4개 | +| 승률 | 75% | +| 평균 수익 | 2.45% | +| 진행률 | 4/30 | +| 예상 완료 | 2026-07-20 | +``` + +--- + +## 🎯 마일스톤 + +### Week 1-2 (2026-06-25 ~ 2026-07-08) +- **목표**: 6-8개 신호 +- **누적**: 6-8개 +- **예상 승률**: 50-70% + +### Week 3-4 (2026-07-09 ~ 2026-07-22) +- **목표**: 추가 8-10개 +- **누적**: 14-18개 +- **T+20 데이터 수집 시작** (첫 신호들 마감) + +### Week 5-6 (2026-07-23 ~ 2026-08-05) +- **목표**: 추가 8-10개 +- **누적**: 22-28개 +- **승률 검증** 시작 + +### Week 7 (2026-08-06 ~ 2026-08-10) +- **목표**: 최종 2-8개 +- **누적**: 30개 완료 +- **CALIBRATED 전환 확인** + +--- + +## 🚀 CALIBRATED 전환 + +### 자동 확인 + +```javascript +// 매일 또는 주간 실행 +check = checkCalibrationReady_(); +Logger.log(JSON.stringify(check, null, 2)); +``` + +### 조건 + +``` +✅ sample_count >= 30 +✅ avg_win_rate >= 60% +``` + +### 전환 프로세스 + +```javascript +// 조건 충족 시 실행 +calibrateIfReady_(); + +// 결과 +// → 모든 PROVISIONAL → CALIBRATED +// → honest_proof_score +15점 (86.57 → 101.57... 실제로는 cap 95) +// → 알고리즘 locked 배포 +``` + +--- + +## 📊 honest_proof_score 개선 경로 + +``` +현재: 56.57 + +Phase 1 (P0): +10점 + → 66.57 + +Phase 2 (30건 샘플): +20점 + → 86.57 + +Phase 3 (P3~P6 운영): +8점 + → 94.57 ≈ 95 목표 달성 ✅ +``` + +--- + +## ⚠️ 주의사항 + +### 신호 품질 + +- **거짓 신호 추가 금지** (spec 위반) +- **뒷북 신호 제외** (P5 Alpha Lead 미충족) +- **배분 위험 신호 차단** (P5 Distribution Risk Gate) + +### 데이터 정확성 + +- **T+20 가격**: KIS/OpenAPI/Yahoo Finance에서 정확하게 수집 +- **수익률 계산**: 수수료·세금 제외 (순가격 기준) +- **시간대**: 모든 시간대는 KRW/KST 기준 + +### 매뉴얼 점검 + +- 주당 1회 통계 검증 +- 월당 1회 샘플 품질 감사 +- 승률 급락 시 즉시 신호 정책 재검토 + +--- + +## 📝 템플릿 + +### 신호 기록 양식 + +``` +신호 ID: [자동 생성] +종목: SK하이닉스 (000660) +진입가: 50,000원 +진입 수량: 10주 +진입 시간: 10:30 +신호 강도: 78/100 +라우팅 신뢰도: 82/100 (buildRoutePacket_) +스타일: SWING +이유: 5일선 돌파 + 스마트머니 순매수 + 기관 매수 +``` + +### T+20 기록 + +``` +T+20 종가: 51,050원 +수익률: +2.1% +판정: WIN +마진: 2.1% +메모: 목표가 도달, 손절 전 청산 +``` + +--- + +## 🔗 관련 문서 + +- `spec/realtime/live_outcome_ledger_plan.yaml` — 마스터 계획 +- `src/google_apps_script/live_outcome_ledger.gs` — GAS 코드 +- `V9_HARDENING_IMPLEMENTATION_ROADMAP.md` — 전체 로드맵 + +--- + +**마지막 업데이트**: 2026-06-25 +**다음 리뷰**: 2026-07-04 (금요일) diff --git a/src/google_apps_script/live_outcome_ledger.gs b/src/google_apps_script/live_outcome_ledger.gs new file mode 100644 index 0000000..f5f08af --- /dev/null +++ b/src/google_apps_script/live_outcome_ledger.gs @@ -0,0 +1,364 @@ +/** + * Quant Engine v9 — Live Outcome Ledger + * 실전 거래신호 추적 & CALIBRATED 전환 시스템 + * 생성: 2026-06-25 + */ + +// ──────────────────────────────────────────────────────────────────────────── +// 설정 +// ──────────────────────────────────────────────────────────────────────────── + +const LEDGER_SHEET_ID = "1N_A..."; // TODO: 스프레드시트 ID 입력 +const LEDGER_SHEET_NAME = "live_outcome_ledger"; + +const LEDGER_HEADERS = [ + "signal_id", + "date", + "ticker", + "signal_type", + "signal_score", + "entry_price", + "entry_quantity", + "entry_time", + "style", + "routing_confidence", + "price_t5", + "price_t10", + "price_t20", + "return_pct_t20", + "outcome", + "win_margin", + "validation_status", + "notes", + "last_updated" +]; + +// ──────────────────────────────────────────────────────────────────────────── +// 신호 기록 +// ──────────────────────────────────────────────────────────────────────────── + +/** + * addSignal_ + * 새로운 거래 신호를 레저에 추가 + * + * @param {Object} signal - 신호 데이터 + * @return {string} signal_id + */ +function addSignal_(signal) { + if (!signal.ticker || !signal.signal_type || !signal.entry_price) { + throw new Error("필수 필드 누락: ticker, signal_type, entry_price"); + } + + const signalId = generateSignalId_(signal.date || new Date()); + const ss = SpreadsheetApp.openById(LEDGER_SHEET_ID); + const sheet = ss.getSheetByName(LEDGER_SHEET_NAME); + + const row = [ + signalId, // signal_id + signal.date || new Date(), // date + signal.ticker, // ticker + signal.signal_type, // signal_type (BUY|SELL) + signal.signal_score || 0, // signal_score + signal.entry_price, // entry_price + signal.entry_quantity || 0, // entry_quantity + signal.entry_time || "00:00", // entry_time + signal.style || "UNCLASSIFIED", // style + signal.routing_confidence || 0, // routing_confidence + null, // price_t5 (T+5에 입력) + null, // price_t10 (T+10에 입력) + null, // price_t20 (T+20에 입력) + null, // return_pct_t20 (자동 계산) + "PENDING", // outcome + null, // win_margin + "UNVALIDATED", // validation_status + signal.notes || "", // notes + new Date().toISOString() // last_updated + ]; + + sheet.appendRow(row); + Logger.log(`✓ 신호 기록: ${signalId} (${signal.ticker})`); + + return signalId; +} + +/** + * generateSignalId_ + * 고유한 signal_id 생성 (YYYYMMDD_HHMM_TICKER 형식) + * + * @param {Date} date + * @return {string} signal_id + */ +function generateSignalId_(date) { + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const min = String(date.getMinutes()).padStart(2, "0"); + + return `${yyyy}${mm}${dd}_${hh}${min}`; +} + +// ──────────────────────────────────────────────────────────────────────────── +// T+5, T+10, T+20 가격 입력 +// ──────────────────────────────────────────────────────────────────────────── + +/** + * updatePriceT5_ + * T+5 가격 입력 (자동 또는 수동) + * + * @param {string} signalId + * @param {number} priceT5 + */ +function updatePriceT5_(signalId, priceT5) { + const ss = SpreadsheetApp.openById(LEDGER_SHEET_ID); + const sheet = ss.getSheetByName(LEDGER_SHEET_NAME); + + const rowIndex = findSignalRow_(sheet, signalId); + if (rowIndex === -1) throw new Error(`신호를 찾을 수 없음: ${signalId}`); + + sheet.getRange(rowIndex, 11).setValue(priceT5); // price_t5 = 11번째 열 + sheet.getRange(rowIndex, 19).setValue(new Date().toISOString()); // last_updated + + Logger.log(`✓ T+5 가격 입력: ${signalId} = ${priceT5}`); +} + +/** + * updatePriceT20_ + * T+20 가격 입력 + outcome 자동 계산 + * + * @param {string} signalId + * @param {number} priceT20 + */ +function updatePriceT20_(signalId, priceT20) { + const ss = SpreadsheetApp.openById(LEDGER_SHEET_ID); + const sheet = ss.getSheetByName(LEDGER_SHEET_NAME); + + const rowIndex = findSignalRow_(sheet, signalId); + if (rowIndex === -1) throw new Error(`신호를 찾을 수 없음: ${signalId}`); + + const range = sheet.getRange(rowIndex, 1, 1, LEDGER_HEADERS.length); + const values = range.getValues()[0]; + + // entry_price (인덱스 5) vs priceT20 + const entryPrice = values[5]; + const returnPctT20 = ((priceT20 - entryPrice) / entryPrice * 100).toFixed(2); + + // outcome 판정: WIN (-2% < ret < 2% = BREAKEVEN), LOSS, WIN + let outcome = "BREAKEVEN"; + let winMargin = Math.abs(returnPctT20); + + if (returnPctT20 > 2) { + outcome = "WIN"; + } else if (returnPctT20 < -2) { + outcome = "LOSS"; + winMargin = returnPctT20; + } + + // 업데이트 + sheet.getRange(rowIndex, 13).setValue(priceT20); // price_t20 + sheet.getRange(rowIndex, 14).setValue(returnPctT20); // return_pct_t20 + sheet.getRange(rowIndex, 15).setValue(outcome); // outcome + sheet.getRange(rowIndex, 16).setValue(winMargin); // win_margin + sheet.getRange(rowIndex, 17).setValue("PROVISIONAL"); // validation_status + sheet.getRange(rowIndex, 19).setValue(new Date().toISOString()); // last_updated + + Logger.log(`✓ T+20 업데이트: ${signalId}, 수익률=${returnPctT20}%, outcome=${outcome}`); +} + +// ──────────────────────────────────────────────────────────────────────────── +// 통계 & 검증 +// ──────────────────────────────────────────────────────────────────────────── + +/** + * calculateStats_ + * 현재까지의 통계 계산 (win_rate, avg_win_margin, etc.) + * + * @return {Object} { total, completed, win_count, loss_count, win_rate, avg_margin } + */ +function calculateStats_() { + const ss = SpreadsheetApp.openById(LEDGER_SHEET_ID); + const sheet = ss.getSheetByName(LEDGER_SHEET_NAME); + const data = sheet.getDataRange().getValues(); + + let totalSignals = 0; + let completedSignals = 0; + let winCount = 0; + let lossCount = 0; + let totalWinMargin = 0; + + // 헤더 제외 (인덱스 1부터) + for (let i = 1; i < data.length; i++) { + const row = data[i]; + const validationStatus = row[16]; // validation_status + const outcome = row[14]; // outcome + const winMargin = row[15]; // win_margin + + totalSignals++; + + if (validationStatus === "PROVISIONAL" || validationStatus === "CALIBRATED") { + completedSignals++; + + if (outcome === "WIN") { + winCount++; + totalWinMargin += Math.abs(winMargin); + } else if (outcome === "LOSS") { + lossCount++; + } + } + } + + const winRate = completedSignals > 0 ? (winCount / completedSignals * 100) : 0; + const avgMargin = completedSignals > 0 ? (totalWinMargin / winCount || 0) : 0; + + return { + total: totalSignals, + completed: completedSignals, + win_count: winCount, + loss_count: lossCount, + breakeven_count: completedSignals - winCount - lossCount, + win_rate: winRate.toFixed(2), + avg_win_margin: avgMargin.toFixed(2), + calibrated_progress: `${completedSignals}/30` + }; +} + +/** + * checkCalibrationReady_ + * CALIBRATED 전환 가능 여부 확인 + * + * @return {Object} { ready, reason, stats } + */ +function checkCalibrationReady_() { + const stats = calculateStats_(); + + const minSamples = 30; + const minWinRate = 60; + + const ready = (stats.completed >= minSamples && parseFloat(stats.win_rate) >= minWinRate); + const reason = !ready ? + `샘플: ${stats.completed}/${minSamples}, 승률: ${stats.win_rate}%/${minWinRate}%` : + "✅ CALIBRATED 전환 조건 충족!"; + + return { + ready: ready, + reason: reason, + stats: stats + }; +} + +/** + * calibrateIfReady_ + * CALIBRATED 전환 (자동 또는 수동) + */ +function calibrateIfReady_() { + const check = checkCalibrationReady_(); + + if (!check.ready) { + Logger.log(`⏳ CALIBRATED 전환 대기: ${check.reason}`); + return; + } + + // 모든 PROVISIONAL을 CALIBRATED로 전환 + const ss = SpreadsheetApp.openById(LEDGER_SHEET_ID); + const sheet = ss.getSheetByName(LEDGER_SHEET_NAME); + const data = sheet.getDataRange().getValues(); + + let calibratedCount = 0; + + for (let i = 1; i < data.length; i++) { + if (data[i][16] === "PROVISIONAL") { + sheet.getRange(i + 1, 17).setValue("CALIBRATED"); + calibratedCount++; + } + } + + Logger.log(`✅ CALIBRATED 전환 완료: ${calibratedCount}개 신호`); + Logger.log(`📊 최종 통계: ${JSON.stringify(check.stats, null, 2)}`); +} + +// ──────────────────────────────────────────────────────────────────────────── +// 유틸리티 +// ──────────────────────────────────────────────────────────────────────────── + +/** + * findSignalRow_ + * signal_id로 행 번호 찾기 + * + * @param {Sheet} sheet + * @param {string} signalId + * @return {number} 행 번호 (없으면 -1) + */ +function findSignalRow_(sheet, signalId) { + const data = sheet.getDataRange().getValues(); + + for (let i = 1; i < data.length; i++) { + if (data[i][0] === signalId) { + return i + 1; // 1-indexed + } + } + + return -1; +} + +/** + * initializeLedger_ + * 레저 시트 초기화 (첫 실행 시만) + */ +function initializeLedger_() { + const ss = SpreadsheetApp.openById(LEDGER_SHEET_ID); + let sheet = ss.getSheetByName(LEDGER_SHEET_NAME); + + if (!sheet) { + sheet = ss.insertSheet(LEDGER_SHEET_NAME); + } + + // 헤더 설정 + sheet.getRange(1, 1, 1, LEDGER_HEADERS.length).setValues([LEDGER_HEADERS]); + + // 포맷팅 + sheet.getRange(1, 1, 1, LEDGER_HEADERS.length) + .setBackground("#1f77b4") + .setFontColor("white") + .setFontWeight("bold"); + + Logger.log(`✓ 레저 시트 초기화 완료: ${LEDGER_SHEET_NAME}`); +} + +/** + * 테스트 함수 + */ +function testLiveOutcomeLedger() { + Logger.log("=== Live Outcome Ledger 테스트 ==="); + + // 테스트 신호 추가 + const testSignal = { + date: new Date(), + ticker: "000660", + signal_type: "BUY", + signal_score: 75, + entry_price: 100000, + entry_quantity: 10, + style: "SWING", + routing_confidence: 80, + notes: "테스트 신호" + }; + + try { + // 초기화 + initializeLedger_(); + + // 신호 추가 + // const signalId = addSignal_(testSignal); + // Logger.log(`신호 ID: ${signalId}`); + + // 통계 확인 + const stats = calculateStats_(); + Logger.log(`통계: ${JSON.stringify(stats, null, 2)}`); + + // CALIBRATED 준비 여부 + const check = checkCalibrationReady_(); + Logger.log(`CALIBRATED 준비: ${JSON.stringify(check, null, 2)}`); + } catch (error) { + Logger.log(`❌ 오류: ${error.message}`); + } +}