feat(deployment): Add deployment script and signal tracking system
배포 및 실전 운영 준비: 1. 배포 스크립트 (deploy.sh) - SSH 기반 자동 배포 - 원격 백업 생성 - nginx 자동 재시작 - 헬스 체크 2. Live Outcome Ledger (live_outcome_ledger.gs) - addSignal_(): 신호 기록 - updatePriceT5_(): T+5 가격 입력 - updatePriceT20_(): T+20 가격 + outcome 자동 계산 - calculateStats_(): 통계 계산 (win_rate, avg_margin) - checkCalibrationReady_(): CALIBRATED 전환 조건 확인 - calibrateIfReady_(): 자동 전환 (30개 신호 + 60% 승률) 3. 일일 추적 가이드 (DAILY_SIGNAL_TRACKING.md) - 신호 발생 시 → T+5 → T+20 프로세스 - 주간 리뷰 체크리스트 - 마일스톤 일정 (6주) - CALIBRATED 전환 조건 - honest_proof_score 개선 경로 배포 준비: - publish 폴더: 24MB (172개 파일) - appsettings.json: PostgreSQL 연결 설정됨 - MudBlazor UI: 반응형 대시보드 - GAS 함수: 7개 (P3~P6) 실전 운영: - 신호 수집 기간: 2026-06-25 ~ 2026-08-10 (6주) - 목표: 30개 신호 + win_rate >= 60% - 최종 목표: honest_proof_score 95.0 달성 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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 (금요일)
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user