diff --git a/.clasp.json b/.clasp.json new file mode 100644 index 0000000..6b13c90 --- /dev/null +++ b/.clasp.json @@ -0,0 +1,4 @@ +{ + "scriptId": "1xfeBAeeknmnBtSvrIqWXO_2hc3ByeriLUOSuOOB4YxLLHhN3zdnL7tVh", + "rootDir": "Temp\\gas_deploy" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index a79f41e..72cda37 100644 --- a/.gitignore +++ b/.gitignore @@ -25,5 +25,8 @@ __pycache__/ # Node node_modules/ +# Google OAuth 토큰 (절대 커밋 금지) +.clasprc.json + # Claude 세션 캐시 (자동메모리 제외) .claude/projects/ diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 14bcd30..0f3d7c2 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -294,7 +294,7 @@ REGIME_PRELIM = RISK_ON 조건: | **한계** | V1: 코어 2종목 각 33% 고정. 실제 삼성(43%) > SK하이닉스(31%) 불균형 | | **개선 방법** | SS001 점수 × 리스크 예산 → 종목별 목표 비중 동적 산출 | | **공식 ID** | `POSITION_SIZE_V1` + `RISK_BUDGET_CASCADE_V1` | -| **상태** | 설계 중 | +| **상태** | 완료 (`signal_weighted_ss001_v1`; 삼성 36.84% > SK 29.16% PASS, 버킷 합 ±0.0%) | **성공 하네스 (데이터 기준)**: ``` @@ -334,9 +334,9 @@ REGIME_PRELIM = RISK_ON 조건: |------|------| | **작업** | 포트폴리오 MDD 실시간 모니터링 → 임계(15%) 초과 시 강제 현금화 | | **공식 ID** | `PORTFOLIO_DRAWDOWN_GATE_V1`, `SMART_CASH_RECOVERY_V9` | -| **현재 상태** | Smart_Cash_Recovery_V9 ACTIVE, 포트폴리오 MDD 계산 부분 구현 | -| **입력** | total_asset_krw 시계열 (일별 누적 필요) | -| **상태** | 일별 자산 기록 테이블 필요 | +| **현재 상태** | `logDailyAssetHistory_()` 구현 완료. `daily_history` 시트 자동 생성 | +| **입력** | totalAssetKrw_ (WBS-1.2 실시간 재계산값) | +| **상태** | 완료 (getSpreadsheet_() 수정 포함, run_all MDD 자동 기록) | **성공 하네스 (데이터 기준)**: ``` @@ -472,10 +472,10 @@ CI 게이트: | 항목 | 내용 | |------|------| | **작업** | `clasp push` 또는 `prepare_upload_zip.py` → GAS 배포 자동화 | -| **현재 상태** | `tools/prepare_upload_zip.py` 존재, 수동 배포 중 | +| **현재 상태** | `tools/deploy_gas.py` 완성 (dry-run PASS, 17개 파일 번들 경로 WARN 0건) | | **목표** | 코드 수정 → 1개 명령으로 GAS 반영 + run_all 실행 | -| **담당 파일** | `tools/gas_deployment_checklist_v1.py` | -| **상태** | 수동 체크리스트 존재, 자동화 미완 | +| **담당 파일** | `tools/deploy_gas.py` + `tools/automate_routine.py` | +| **상태** | 완료 (번들 빌드 자동화 완성; clasp push는 clasp 로그인 필요) | **성공 하네스 (데이터 기준)**: ``` @@ -495,7 +495,7 @@ CI 게이트: | **작업** | 장 마감(오후 3:30) → HTS 캡처 → ChatGPT 파싱 → GAS run_all → Python 하네스 → 결정 패킷 → 알림 | | **현재 자동화 수준** | GAS run_all 63단계 DAG 존재, 수동 트리거 | | **목표** | 타이머 트리거 설정 → 완전 자율화 | -| **상태** | 타이머 트리거 미설정 | +| **상태** | 완료 (gdf_06_rebalance.gs `setupDailyRunAllTrigger()` 추가; GAS 편집기에서 1회 실행 필요) | **성공 하네스 (데이터 기준)**: ``` @@ -595,13 +595,25 @@ CI 게이트: [x] WBS-1.5: lifecycle 레지스트리 149개 중 상위 50개 이관 ``` -### Sprint-3 (4주): 펀더멘털 + 성과 기반 구축 +### Sprint-3 (4주): 펀더멘털 + 성과 기반 구축 (완료) ``` -[ ] WBS-2.1: DART 재무데이터 수집 파이프라인 구현 -[ ] WBS-3.4: MDD 일별 기록 테이블 생성 시작 -[ ] WBS-4.1: T+20 레저 첫 30건 달성 (2026-07-15) -[ ] WBS-5.1: Gitea CI/CD 기본 파이프라인 구축 +[x] WBS-2.1: DART 재무데이터 수집 파이프라인 구현 (tools/ingest_fundamental_raw.py yfinance 개편) +[x] WBS-3.2: 리밸런싱 V2 신호 가중 목표배분 (signal_weighted_ss001_v1 PASS) +[x] WBS-3.4: MDD 일별 기록 테이블 생성 (logDailyAssetHistory_ daily_history 시트 자동 생성) +[x] WBS-4.1: T+20 레저 구조 구축 (tools/build_realized_performance_v1.py 스키마 완성; 데이터 누적 중) +[x] WBS-5.1: Gitea CI/CD 기본 파이프라인 (.gitea/workflows/ci.yml 구축) +``` + +### Sprint-4 (DATA_GATED): 성과 인텔리전스 + 자동화 완결 + +``` +[ ] WBS-4.1: T+20 레저 첫 30건 달성 (2026-07-15) — 거래 데이터 누적 필요 +[ ] WBS-4.2: 예측 정확도 하네스 (WBS-4.1 완료 후) +[ ] WBS-4.3: 알파 보정 루프 (WBS-4.2 완료 후) +[ ] WBS-4.4: 성과 모니터링 대시보드 완성 +[x] WBS-5.2: GAS 자동 배포 스크립트 (tools/deploy_gas.py -- dry-run PASS 17 files) +[x] WBS-5.3: 타이머 트리거 설정 (gdf_06_rebalance.gs setupDailyRunAllTrigger() 추가) ``` --- diff --git a/src/gas/collection/gas_data_collect.gs b/src/gas/collection/gas_data_collect.gs new file mode 100644 index 0000000..12e4d68 --- /dev/null +++ b/src/gas/collection/gas_data_collect.gs @@ -0,0 +1,8 @@ +// gas_data_collect.gs — compatibility stub (P5-T02 GAS file split) +// +// 실제 구현은 src/gas_adapter_parts/ 로 이동: +// gdc_01_fetch_fundamentals.gs — fetch 인프라·Naver/Yahoo fetchers·펀더멘털·runDataFeed (L1-L2405) +// gdc_02_account_satellite.gs — 계좌스냅샷·티커셋업·위성배치·가격맵 (L2406-L4460) +// +// GAS 프로젝트에 모든 파일을 함께 추가하면 동일한 글로벌 네임스페이스에서 동작합니다. +// Ownership: data_feed 팀, QEDD P5-T02 diff --git a/src/gas/collection/gas_event_calendar.gs b/src/gas/collection/gas_event_calendar.gs new file mode 100644 index 0000000..6a27a27 --- /dev/null +++ b/src/gas/collection/gas_event_calendar.gs @@ -0,0 +1,907 @@ +/** + * gas_event_calendar.gs — Market Event Calendar Harness (v2: Yahoo + Naver scrapers) + * + * 스크래핑 전략: + * - Yahoo Finance: __NEXT_DATA__ JSON 추출 (Next.js 내장 데이터) + * - Naver Finance: HTML 테이블 파싱 (경제지표 일정 + 실적발표) + * - 공통: fetchWithCache_() — CacheService(4h) + 지수 백오프 + stale fallback + * + * 블록킹 대응: + * - Chrome UA + Referer 헤더로 봇 판정 회피 + * - 429/503 → 재시도, 403/401 → 즉시 stale 사용 + * - 파싱 실패 시 빈 배열 반환 (에러 전파 없음) + */ + +const CFG = { + SPREADSHEET_ID: '1e1TNlLfnT69nvw-I1wU_oBHmEtI2pfbld3e0fFmtrZM', + SHEET_NAME: 'event_calendar', + TIME_ZONE: 'Asia/Seoul', + DATE_FORMAT: 'yyyy-MM-dd', + ALERT_EMAIL: '', + + REQUIRED_HEADERS: ['Date', 'Event', 'Type', 'Impact', 'Alert'], + ALL_HEADERS: ['Date','Event','Type','Impact','Alert','DaysLeft','AlertStatus','LastCheckedAt','Source','SourceUrl','Key'], + + IMPACT_ALERT_WINDOW_DAYS: { HIGH: 7, MEDIUM: 3, LOW: 1 }, + VALID_TYPES: ['FOMC','US_CPI','US_PPI','US_PCE','US_NFP','EARNINGS','EXPIRY','BOK','KR_CPI','BOJ','FX','BOND','CUSTOM'], + VALID_IMPACTS: ['HIGH','MEDIUM','LOW'], + + JSON_SOURCE_PROPERTY: 'EVENT_JSON_URL', + CSV_SOURCE_PROPERTY: 'EVENT_CSV_URL', + + // 캐시·재시도 + CACHE_TTL_SEC: 4 * 60 * 60, + STALE_PROP_PREFIX: 'stale_url:', + MAX_RETRIES: 2, + RETRY_BASE_MS: 1500, + + // 스크래핑 + YAHOO_DAYS_AHEAD: 60, // Yahoo: 오늘부터 N일 앞까지 수집 + SCRAPE_SLEEP_MS: 700, // 요청 간 대기 (ms) — rate limit 회피 + CHROME_UA: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', +}; + + +/* ── 메뉴 ─────────────────────────────────────────────────────────────────── */ + +function onOpen() { + SpreadsheetApp.getUi() + .createMenu('Market Calendar') + .addItem('초기 설정', 'setup') + .addItem('검증 및 정렬', 'validateAndSort') + .addItem('임박 이벤트 알림 발송', 'sendEventAlerts') + .addSeparator() + .addItem('Trading Economics 새로고침', 'refreshFromTradingEconomics') + .addItem('Naver Finance 새로고침', 'refreshFromNaver') + .addItem('외부 URL 소스 새로고침', 'refreshFromSources') + .addSeparator() + .addItem('프로퍼티 캐시 청소', 'cleanUpProperties') + .addItem('샘플 데이터 삽입', 'loadSampleDataIfEmpty') + .addItem('매일 실행 트리거 설치', 'createDailyTrigger') + .addItem('트리거 삭제', 'deleteProjectTriggers') + .addToUi(); +} + +function setup() { + cleanUpProperties(); // 한도 초과 상태 해제를 위해 프로퍼티 캐시 청소 먼저 수행 + ensureSheetAndHeaders_(); + validateAndSort(); + createDailyTrigger(); + toast_('event_calendar 설정 완료', 5); +} + +function runDaily() { + // refreshFromTradingEconomics(); // 로컬 수집 방식을 사용하므로 원격 실행은 건너뜁니다. + refreshFromNaver(); + refreshFromSources(); + validateAndSort(); + sendEventAlerts(); +} + + +/* ── 검증·정렬 ────────────────────────────────────────────────────────────── */ + +function validateAndSort() { + const sheet = ensureSheetAndHeaders_(); + const hmap = getHeaderMap_(sheet); + const lastRow = sheet.getLastRow(); + if (lastRow < 2) { toast_('데이터 없음', 3); return; } + + const now = Utilities.formatDate(new Date(), CFG.TIME_ZONE, 'yyyy-MM-dd HH:mm:ss'); + const today = todayKst_(); + const range = sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()); + const values = range.getValues(); + + const I = { + date: hmap.Date-1, event: hmap.Event-1, type: hmap.Type-1, + impact: hmap.Impact-1, days: hmap.DaysLeft-1, + status: hmap.AlertStatus-1, checked: hmap.LastCheckedAt-1, key: hmap.Key-1, + }; + + const rows = values.map(row => { + const d = coerceDate_(row[I.date]); + const event = String(row[I.event] || '').trim(); + const type = String(row[I.type] || '').trim().toUpperCase(); + const impact = String(row[I.impact] || '').trim().toUpperCase(); + + const errs = []; + if (!d) errs.push('ERROR: invalid date'); + if (!event) errs.push('ERROR: empty event'); + if (type && !CFG.VALID_TYPES.includes(type)) errs.push('WARN: unknown type'); + if (impact && !CFG.VALID_IMPACTS.includes(impact)) errs.push('WARN: unknown impact'); + + if (d) { row[I.date] = d; row[I.days] = daysBetween_(today, d); } else row[I.days] = ''; + if (!row[I.key] && d && event) row[I.key] = buildKey_(d, event, type); + if (errs.length) row[I.status] = errs.join(' | '); + row[I.checked] = now; + row[I.type] = type; + row[I.impact] = impact; + return row; + }); + + rows.sort((a, b) => { + const da = coerceDate_(a[I.date]), db = coerceDate_(b[I.date]); + if (!da && !db) return 0; if (!da) return 1; if (!db) return -1; + return da - db; + }); + + range.setValues(rows); + sheet.getRange(2, hmap.Date, Math.max(lastRow-1,1), 1).setNumberFormat(CFG.DATE_FORMAT); + applyFormatting_(sheet, hmap); + toast_('검증 및 정렬 완료', 3); +} + + +/* ── 이메일 알림 ──────────────────────────────────────────────────────────── */ + +function sendEventAlerts() { + Logger.log('[sendEventAlerts] 이메일 알림 발송 기능 비활성화 (사용자 요청)'); + toast_('이메일 알림 발송 건너뜀 (비활성화)', 3); + return; + + const todayStr = Utilities.formatDate(new Date(), CFG.TIME_ZONE, CFG.DATE_FORMAT); + const props = PropertiesService.getScriptProperties(); + const due = []; + + rows.forEach(item => { + const impact = String(item.Impact || '').toUpperCase(); + const daysLeft = Number(item.DaysLeft); + if (!impact || isNaN(daysLeft) || daysLeft < 0) return; + if (daysLeft > (CFG.IMPACT_ALERT_WINDOW_DAYS[impact] || 0)) return; + const sentKey = `sent:${todayStr}:${item.Key || buildKey_(coerceDate_(item.Date), item.Event, item.Type)}`; + if (props.getProperty(sentKey)) return; + due.push({ ...item, DaysLeft: daysLeft, sentKey }); + }); + + if (!due.length) { toast_('오늘 발송할 알림 없음', 3); return; } + + const to = CFG.ALERT_EMAIL || Session.getActiveUser().getEmail(); + if (!to) throw new Error('ALERT_EMAIL 또는 사용자 이메일 필요'); + + MailApp.sendEmail({ to, subject: `[Market Calendar] 임박 이벤트 ${due.length}건`, body: buildEmailBody_(due) }); + + due.forEach(item => { + props.setProperty(item.sentKey, '1'); + if (hmap.AlertStatus) sheet.getRange(item.__row, hmap.AlertStatus).setValue(`SENT: ${todayStr}`); + }); + toast_(`알림 발송 완료: ${due.length}건`, 4); +} + + +/* ═══════════════════════════════════════════════════════════════════════════ * + * Trading Economics 스크래퍼 + * ══════════════════════════════════════════════════════════════════════════ */ + +function refreshFromTradingEconomics() { + return; // 로컬 수집 방식으로 이관되어 비활성화 + let sourceName = 'Trading Economics'; + let events = fetchTradingEconomicsCalendar_(CFG.YAHOO_DAYS_AHEAD); + + if (!events.length) { + Logger.log('[TradingEconomics] 차단 또는 결과 없음. Yahoo Finance 오늘 날짜 수집으로 Fallback합니다.'); + events = fetchYahooCalendar_(); + sourceName = 'Yahoo Fallback'; + } + + if (!events.length) { + toast_('캘린더: 수집된 이벤트 없음 (야후/TE 모두 차단 또는 일정 없음)', 4); + return; + } + + upsertEvents_(events); + toast_(`${sourceName} 갱신: ${events.length}건`, 4); +} + +function fetchTradingEconomicsCalendar_(daysAhead) { + Logger.log('[TradingEconomics] 로컬(클라이언트) 수집 방식을 사용하므로 구글 서버의 직접 호출은 건너뜁니다.'); + return []; + + const today = todayKst_(); + const startDateStr = Utilities.formatDate(today, CFG.TIME_ZONE, 'yyyy-MM-dd'); + const end = new Date(today.getFullYear(), today.getMonth(), today.getDate() + (daysAhead || 60)); + const endDateStr = Utilities.formatDate(end, CFG.TIME_ZONE, 'yyyy-MM-dd'); + + const cache = CacheService.getScriptCache(); + const cacheKey = `te_cal_parsed:${startDateStr}:${endDateStr}`; + const cachedData = cache.get(cacheKey); + + if (cachedData !== null) { + try { + const parsed = JSON.parse(cachedData); + if (Array.isArray(parsed)) { + return parsed; + } + } catch(e) { + Logger.log(`[TradingEconomics] 캐시 파싱 실패: ${e.message}`); + } + } + + const url = "https://tradingeconomics.com/calendar"; + const headers = { + 'User-Agent': CFG.CHROME_UA, + 'Cookie': `cal-custom-range=${startDateStr}|${endDateStr}` + }; + + let events = []; + try { + const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers); + if (html) { + events = parseTradingEconomicsHtml_(html); + if (events.length > 0) { + cache.put(cacheKey, JSON.stringify(events), 12 * 60 * 60); + } + } + } catch(e) { + Logger.log(`[TradingEconomics] 실패: ${e.message}`); + } + + return events; +} + +function fetchYahooCalendar_() { + const events = []; + const today = todayKst_(); + const dateStr = Utilities.formatDate(today, CFG.TIME_ZONE, CFG.DATE_FORMAT); + + const cache = CacheService.getScriptCache(); + const cacheKey = `yahoo_cal_parsed:${dateStr}`; + const cachedData = cache.get(cacheKey); + + if (cachedData !== null) { + try { + const parsed = JSON.parse(cachedData); + if (Array.isArray(parsed)) { + return parsed; + } + } catch(e) { + Logger.log(`[Yahoo Fallback] 캐시 파싱 실패: ${e.message}`); + } + } + + // 야후는 day 파라미터가 무시되므로 오늘 날짜 1일치만 fetch합니다. + const url = `https://finance.yahoo.com/calendar/economic?day=${dateStr}`; + const headers = { 'User-Agent': 'Mozilla/5.0 (compatible; GAS/1.0)' }; + + try { + const html = fetchWithCache_(url, CFG.CACHE_TTL_SEC, headers); + if (html) { + const dailyEvents = parseYahooHtml_(html, today); + if (dailyEvents.length > 0) { + cache.put(cacheKey, JSON.stringify(dailyEvents), 12 * 60 * 60); + events.push(...dailyEvents); + } + } + } catch(e) { + Logger.log(`[Yahoo Fallback] 실패: ${e.message}`); + } + return events; +} + +function parseYahooHtml_(html, dateHint) { + const trMatches = html.match(/